Update upstream source from tag 'upstream/2.1.0'
Update to upstream version '2.1.0'
with Debian dir ff6319a1a9c8b4cca2001fb6e9a8b8f9e94e2610
Jochen Sprickerhof
2 years ago
0 | --- | |
1 | name: Bug report | |
2 | about: Create a report to help us improve | |
3 | title: '' | |
4 | labels: '' | |
5 | assignees: '' | |
6 | ||
7 | --- | |
8 | ||
9 | **Describe the bug** | |
10 | <!-- A clear and concise description of what the bug is. --> | |
11 | ||
12 | **To Reproduce** | |
13 | <!-- Steps to reproduce the behavior. --> | |
14 | ||
15 | **Expected behavior** | |
16 | <!-- A clear and concise description of what you expected to happen. --> | |
17 | ||
18 | **Test case** | |
19 | If reproducing your issue requires any TaskWarrior setup, please produce a [test case](https://github.com/vit-project/vit/blob/2.x/TEST-CASE.md) script. |
0 | --- | |
1 | name: Feature request | |
2 | about: Suggest an idea for this project | |
3 | title: '' | |
4 | labels: '' | |
5 | assignees: '' | |
6 | ||
7 | --- | |
8 | ||
9 | **Is your feature request related to a problem? Please describe.** | |
10 | <!-- A clear and concise description of what the problem is. --> | |
11 | ||
12 | **Describe the solution you'd like** | |
13 | <!-- A clear and concise description of what you want to happen. --> | |
14 | ||
15 | **Additional context** | |
16 | <!-- Add any other context or screenshots about the feature request here. --> |
0 | # Byte-compiled / optimized / DLL files | |
1 | __pycache__/ | |
2 | *.py[cod] | |
3 | *$py.class | |
4 | ||
5 | # C extensions | |
6 | *.so | |
7 | ||
8 | # Distribution / packaging | |
9 | .Python | |
10 | build/ | |
11 | develop-eggs/ | |
12 | dist/ | |
13 | downloads/ | |
14 | eggs/ | |
15 | .eggs/ | |
16 | lib/ | |
17 | lib64/ | |
18 | parts/ | |
19 | sdist/ | |
20 | var/ | |
21 | wheels/ | |
22 | share/python-wheels/ | |
23 | *.egg-info/ | |
24 | .installed.cfg | |
25 | *.egg | |
26 | MANIFEST | |
27 | ||
28 | # PyInstaller | |
29 | # Usually these files are written by a python script from a template | |
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. | |
31 | *.manifest | |
32 | *.spec | |
33 | ||
34 | # Installer logs | |
35 | pip-log.txt | |
36 | pip-delete-this-directory.txt | |
37 | ||
38 | # Unit test / coverage reports | |
39 | htmlcov/ | |
40 | .tox/ | |
41 | .nox/ | |
42 | .coverage | |
43 | .coverage.* | |
44 | .cache | |
45 | nosetests.xml | |
46 | coverage.xml | |
47 | *.cover | |
48 | *.py,cover | |
49 | .hypothesis/ | |
50 | .pytest_cache/ | |
51 | cover/ | |
52 | ||
53 | # Translations | |
54 | *.mo | |
55 | *.pot | |
56 | ||
57 | # Django stuff: | |
58 | *.log | |
59 | local_settings.py | |
60 | db.sqlite3 | |
61 | db.sqlite3-journal | |
62 | ||
63 | # Flask stuff: | |
64 | instance/ | |
65 | .webassets-cache | |
66 | ||
67 | # Scrapy stuff: | |
68 | .scrapy | |
69 | ||
70 | # Sphinx documentation | |
71 | docs/_build/ | |
72 | ||
73 | # PyBuilder | |
74 | .pybuilder/ | |
75 | target/ | |
76 | ||
77 | # Jupyter Notebook | |
78 | .ipynb_checkpoints | |
79 | ||
80 | # IPython | |
81 | profile_default/ | |
82 | ipython_config.py | |
83 | ||
84 | # pyenv | |
85 | # For a library or package, you might want to ignore these files since the code is | |
86 | # intended to run in multiple environments; otherwise, check them in: | |
0 | 87 | .python-version |
1 | 88 | |
2 | # Setuptools build/distribution folder. | |
3 | /build/ | |
4 | /dist/ | |
89 | # pipenv | |
90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. | |
91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies | |
92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not | |
93 | # install all needed dependencies. | |
94 | #Pipfile.lock | |
5 | 95 | |
6 | # Python egg metadata, regenerated from source files by setuptools. | |
7 | /*.egg-info | |
96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow | |
97 | __pypackages__/ | |
98 | ||
99 | # Celery stuff | |
100 | celerybeat-schedule | |
101 | celerybeat.pid | |
102 | ||
103 | # SageMath parsed files | |
104 | *.sage.py | |
105 | ||
106 | # Environments | |
107 | .env | |
108 | .venv | |
109 | env/ | |
110 | venv/ | |
111 | ENV/ | |
112 | env.bak/ | |
113 | venv.bak/ | |
114 | ||
115 | # Spyder project settings | |
116 | .spyderproject | |
117 | .spyproject | |
118 | ||
119 | # Rope project settings | |
120 | .ropeproject | |
121 | ||
122 | # mkdocs documentation | |
123 | /site | |
124 | ||
125 | # mypy | |
126 | .mypy_cache/ | |
127 | .dmypy.json | |
128 | dmypy.json | |
129 | ||
130 | # Pyre type checker | |
131 | .pyre/ | |
132 | ||
133 | # pytype static type analyzer | |
134 | .pytype/ | |
135 | ||
136 | # Cython debug symbols | |
137 | cython_debug/ |
0 | ##### Sun Feb 28 2021 - released v2.1.0 | |
1 | ||
2 | This release includes a breaking change to the keybinding parser, and may affect | |
3 | users who have implemented custom keybindings in their configuration. | |
4 | ||
5 | See https://github.com/vit-project/vit/commit/dd0f34347e7b77dce37fe72e3797581d212f0d90 for more information. | |
6 | ||
7 | * **Mon Feb 01 2021:** fix: blocked marker displaying for deleted/completed depends | |
8 | * **Thu Jan 28 2021:** add quick start instructions to README | |
9 | * **Wed Dec 30 2020:** [BREAKING CHANGE] correctly parse bracket expressions for keybinding keys | |
10 | * **Thu Dec 24 2020:** Add 'abort_backspace' config, False by default. If true, hitting backspace against an empty prompt aborts the prompt. | |
11 | * **Fri Dec 25 2020:** Use Python.ignore from github | |
12 | ||
13 | ##### Wed Dec 23 2020 - released v2.1.0b1 | |
14 | ||
15 | This release includes compatibility with Taskwarrior 2.5.2 -- earlier releases | |
16 | of VIT may not work with Taskwarrior 2.5.2 and beyond. | |
17 | ||
18 | * **Wed Dec 23 2020:** update URL to vit-project | |
19 | * **Wed Oct 14 2020:** Make VIT uppercase | |
20 | * **Wed Oct 14 2020:** Add digit-jumping keybindings suggestion to CUSTOMIZE doc | |
21 | * **Sun Sep 13 2020:** fix #269: Can't set priority when uda.priority.values is customized | |
22 | * **Fri Sep 11 2020:** fix #266: print.empty.columns truthness values are not properly handled | |
23 | * **Fri Sep 11 2020:** fix #265: Project name completion only works for projects with pending tasks | |
24 | * **Fri Sep 11 2020:** add logo | |
25 | * **Thu Sep 03 2020:** fix #268: remove priority formatters, use uda formatters instead | |
26 | * **Tue Jul 28 2020:** fix #256: Non-pending blocking tasks should not appear in reports as dependencies | |
27 | * **Tue Jul 28 2020:** fix #260: n / N commands should honor search direction | |
28 | * **Tue Jul 28 2020:** add debug section to devel readme | |
29 | * **Mon Jul 27 2020:** Update issue templates | |
30 | * **Sun Jul 26 2020:** add TEST-CASE description | |
31 | * **Sat Jul 25 2020:** fix #255: vit freezes on startup if there is no taskrc file present | |
32 | * **Tue Jul 07 2020:** API for variable replacements in keybindings | |
33 | * **Wed Jul 22 2020:** fix #253: Crash on switch to newly created context | |
34 | * **Mon Jul 20 2020:** fix #252: catch filter errors and display nicely | |
35 | * **Sat Jul 18 2020:** Fix: #251 -- Filtering with empty attribute value yields a crash | |
36 | * **Wed Jul 15 2020:** fix #250: provide default labels when none configured in report | |
37 | * **Fri Jul 10 2020:** provide some feedback on failed user module load | |
38 | * **Sat May 16 2020:** Fix crash when report has project column but no sort | |
39 | * **Wed Mar 11 2020:** Fix #203: Column resizing incorrect when column labels are wider than column | |
40 | * **Fri Mar 06 2020:** fix #222, fix #221: first step to properly supporting spaces in tab completion | |
41 | * **Sat Feb 29 2020:** Fix #227: Provide default report/annotation format if none configured | |
42 | * **Tue Feb 25 2020:** resolves #225: document keybinding config for capital letters | |
43 | * **Sun Nov 03 2019:** fix #176: Readline edit shortcuts for command bar | |
44 | * **Wed Jan 08 2020:** fix #186, part two: get correct project column index after cleaning | |
45 | * **Wed Jan 08 2020:** add project to dummy task script | |
46 | * **Sat Dec 07 2019:** fix #218: specify minimum versions for dependent packages | |
47 | * **Tue Nov 05 2019:** Allows config option value to contain '=' | |
48 | * **Sun Oct 20 2019:** fix #211: Space at the end of keybinding not parsed | |
49 | * **Sun Oct 13 2019:** cleanup on #203, only resize again if any columns need it | |
50 | * **Sun Oct 13 2019:** fix #203: Descriptions not shown when mulitple columns need reducing | |
51 | * **Sun Oct 13 2019:** fix display of last header column when abuts side of terminal window | |
52 | * **Sun Oct 06 2019:** fix #206: passing negative filters through to task don't work | |
53 | * **Wed Oct 02 2019:** Fix #192: add disallowed reports with error message | |
54 | ||
0 | 55 | ##### Sat Sep 28 2019 - released v2.0.0 |
1 | 56 | |
2 | 57 | * **Fri Sep 27 2019:** add UPGRADE.md, include v2.0.0 upgrade instructions |
51 | 106 | * Table-row striping |
52 | 107 | * Show version/context/report execution time in status area |
53 | 108 | * Customizable config dir |
54 | * Comand line bash completion wrapper | |
109 | * Command line bash completion wrapper | |
55 | 110 | |
56 | 111 | This release also changes the software license from GPL to MIT. |
57 | 112 | |
195 | 250 | Fri Nov 30 2012 - added ./configure (autoconf) (e.g. "./configure --prefix=/usr/local/vit") |
196 | 251 | Thu Nov 29 2012 - added support for '^w' (erase word) at the command line |
197 | 252 | Thu Nov 29 2012 - added default.command to the list of available reports |
198 | Wed Nov 28 2012 - added support for ":REPORT <filter>" syntax (e.g. ":mimimal prio:H") | |
253 | Wed Nov 28 2012 - added support for ":REPORT <filter>" syntax (e.g. ":minimal prio:H") | |
199 | 254 | Wed Nov 28 2012 - added support for the DEL key ('^?') as per bug #1134 |
200 | 255 | ``` |
201 | 256 |
58 | 58 | |
59 | 59 | To see the list of actions that can be mapped, execute ```vit --list-actions```. |
60 | 60 | |
61 | ##### To override default keybindings: | |
61 | #### To override default keybindings: | |
62 | 62 | |
63 | 63 | The ```[keybinding]``` section in ```config.ini``` overrides any core keybindings or keybindings that you place in the user ```keybinding``` directory. If you just want to make some small tweaks and/or add some macros, it's probably better to take this approach. |
64 | 64 | |
65 | The [config.sample.ini](https://github.com/scottkosty/vit/blob/2.x/vit/config/config.sample.ini) has many examples to illustrate how to customize keybindings for actions and add macros, check it out! | |
65 | The [config.sample.ini](vit/config/config.sample.ini) has many examples to illustrate how to customize keybindings for actions and add macros, check it out! | |
66 | 66 | |
67 | ##### To provide your own default keybindings: | |
67 | #### To provide your own custom variable replacements: | |
68 | ||
69 | VIT exposes a simple API to provide your own variable replacements in keybindings. | |
70 | ||
71 | Variables enclosed in curly brackets that VIT doesn't know about will be passed to your custom code, | |
72 | where you can match against the variable, and parse the string to extract metadata in the form of | |
73 | arguments that you pass to your custom replacement callback. | |
74 | ||
75 | To provide custom variable replacement: | |
76 | ||
77 | 1. In your user ```keybinding``` directory, create ```keybinding.py``` | |
78 | 2. Create a ```Keybinding``` class in that file | |
79 | 3. Expose a ```replacements``` method in that class, that returns a list of replacement configuration objects | |
80 | 4. Replacement configuration objects have the following keys: | |
81 | * match_callback: should be a function with one argument, which is the variable to test for a match against. | |
82 | If the variable matches your case, return a list with any arguments you want passed to your replacement callback. | |
83 | * replacement_callback: should be a function, with the task object as the first argument, followed by the other | |
84 | arguments you returned from the match callback, and should return a string replacement for the variable. | |
85 | ||
86 | Here's a minimal example: | |
87 | ||
88 | ```python | |
89 | # keybinding/keybinding.py | |
90 | class Keybinding(object): | |
91 | def replacements(self): | |
92 | def _custom_match(variable): | |
93 | if variable == 'TEST': | |
94 | return ['pass'] | |
95 | def _custom_replace(task, arg): | |
96 | return 'TEST:%s' % arg | |
97 | return [ | |
98 | { | |
99 | 'match_callback': _custom_match, | |
100 | 'replacement_callback': _custom_replace, | |
101 | }, | |
102 | ] | |
103 | ``` | |
104 | ||
105 | #### To provide your own default keybindings: | |
68 | 106 | |
69 | 107 | *NOTE: This functionality is more suited to users who want to do something completely different than a Vi-style workflow -- most users will simply want to make some tweaks in the ```[keybinding]``` section of ```config.ini```.* |
70 | 108 | |
71 | 109 | 1. Create a ```keybinding``` directory in the user directory |
72 | 110 | 2. Copy over one of the core keybindings, and customize to your liking. |
73 | 111 | 3. Set the ```default_keybindings``` setting in ```config.ini``` to the name of the keybinding file you created, without the ```.ini``` extension. For example, if you created ```keybinding/strange.ini```, you would set ```default_keybindings = strange``` in ```config.ini``` |
112 | ||
113 | #### Keybinding suggestions | |
114 | ||
115 | ##### Jumping with digits | |
116 | ||
117 | As jumping to tasks is central to VIT's operation, you might want to map each | |
118 | digit key to an `ex` command containing that digit, by adding the following to | |
119 | your keybindings: | |
120 | ||
121 | ``` | |
122 | 1 = :1 | |
123 | 2 = :2 | |
124 | 3 = :3 | |
125 | 4 = :4 | |
126 | 5 = :5 | |
127 | 6 = :6 | |
128 | 7 = :7 | |
129 | 8 = :8 | |
130 | 9 = :9 | |
131 | ``` | |
132 | ||
133 | Now, for example, to jump to a task whose ID is 42, you need to press `4`, `2` | |
134 | and `<Enter>`, instead of `:`, `4`, `2` and `<Enter>`. | |
135 | This saves a `:` keypress whenever jumping to a task. |
11 | 11 | ### Tests |
12 | 12 | * Located in the ```tests``` directory |
13 | 13 | * Run with ```./run-tests.sh``` |
14 | ||
15 | ### Debugging | |
16 | ||
17 | VIT comes with a simple ```debug``` class for pretty-printing variables to | |
18 | console or file. The file's default location is ```vit-debug.log``` in the | |
19 | system's temp directory. | |
20 | ||
21 | Usage in any of the code is straighforward: | |
22 | ||
23 | ```python | |
24 | import debug | |
25 | debug.console(variable_name) | |
26 | debug.file(variable_name) | |
27 | ``` | |
14 | 28 | |
15 | 29 | ### Architecture |
16 | 30 |
0 | 0 | # VIT |
1 | ||
2 | <img src="images/great-tit-square-small.png" alt="Logo" width="150" height="150" align="right" /> | |
1 | 3 | |
2 | 4 | Visual Interactive Taskwarrior full-screen terminal interface. |
3 | 5 | |
4 | *For VIT 1.3, [visit here](https://github.com/scottkosty/vit/tree/1.3)* | |
6 | *For VIT 1.3, [visit here](https://github.com/vit-project/vit/tree/1.3)* | |
7 | ||
5 | 8 | |
6 | 9 | ## Features |
7 | 10 | |
25 | 28 | |
26 | 29 | Follow the directions in [INSTALL.md](INSTALL.md) |
27 | 30 | |
31 | ## Quick start | |
32 | ||
33 | Run ```vit --help``` from the command line for basic usage instructions. | |
34 | ||
35 | Run ```vit``` from the command line to start VIT with default config, report, and filters. | |
36 | ||
37 | While VIT is running, type ```:help``` followed by enter to review basic command/navigation actions. | |
38 | ||
28 | 39 | #### Recommendations: |
29 | 40 | |
30 | 41 | * VIT will suggest to install a default user config file if none exists -- it's fully commented with all configuration options, check it out. |
31 | 42 | * Do ```vit --help``` *(know the vit command line arguments)* |
32 | 43 | * Do ```:help``` in vit *(look over the "commands")* |
33 | 44 | * Use an xterm terminal *(for full color support)* |
34 | * For suggestions on futher tweaks, see [CUSTOMIZE.md](CUSTOMIZE.md) | |
45 | * For suggestions on further tweaks, see [CUSTOMIZE.md](CUSTOMIZE.md) | |
35 | 46 | * VIT handles task coloring differently than Taskwarrior, see [COLOR.md](COLOR.md) for more details |
36 | 47 | |
37 | 48 | #### Troubleshooting: |
0 | ## Creating a test case | |
1 | ||
2 | If you file an issue that requires some TaskWarrior setup to replicate, the | |
3 | developers may ask you to create a 'test case' script in order to aid in | |
4 | the troubleshooting. | |
5 | ||
6 | Doing so is fairly straightforward: | |
7 | ||
8 | 1. Use the [dummy install script](scripts/generate-dummy-install.sh) as a | |
9 | starting place | |
10 | 2. Adjust the script by editing the the task commands | |
11 | * You can remove the default ones if needed | |
12 | * You can add any new ```task``` command needed to set up the data necessary | |
13 | to reproduce your issue | |
14 | 3. Once you have the script complete, run it locally to make sure you can | |
15 | reproduce the issue you're reporting | |
16 | 4. Attach the script to the issue you've filed |
0 | 0 | This file contains information relevant to upgrading VIT from one version to another. Breaking changes between major versions, and significant changes between release versions will be addressed. |
1 | 1 | |
2 | *Note: for upgrade issues prior to VIT 1.3, please see the [legacy changelog](https://github.com/scottkosty/vit/blob/1.3/CHANGES)*. | |
2 | *Note: for upgrade issues prior to VIT 1.3, please see the [legacy changelog](https://github.com/vit-project/vit/blob/1.3/CHANGES)*. | |
3 | 3 | |
4 | 4 | # v2.0.0 |
5 | 5 | |
16 | 16 | * Table-row striping |
17 | 17 | * Show version/context/report execution time in status area |
18 | 18 | * Customizable config dir *(see [CUSTOMIZE.md](CUSTOMIZE.md))* |
19 | * Comand line bash completion wrapper *(see [INSTALL.md](INSTALL.md))* | |
19 | * Command line bash completion wrapper *(see [INSTALL.md](INSTALL.md))* | |
20 | 20 | * Context support |
21 | 21 | |
22 | 22 | This release also changes the software license from GPL to MIT. |
24 | 24 | ### Breaking changes: |
25 | 25 | |
26 | 26 | * Configuration has been moved from ```${HOME}/.vitrc``` to ```${HOME}/.vit/config.ini``` -- the location of the config directory can be customized, see [CUSTOMIZE.md](CUSTOMIZE.md) for details. |
27 | * The format of the configuration file has changed, customizations in the legacy ```.vitrc``` file will need to be manually ported to the new format. The [config.sample.ini](https://github.com/scottkosty/vit/blob/2.x/vit/config/config.sample.ini) file is *heavily* commented, and should contain reference to everything you need to migrate the legacy configuration. If no ```config.ini``` exists in the VIT configuration directory, VIT will offer the option to install the sample config upon startup -- this is the easiest way to get started with porting and customization. | |
28 | * The method of denotating tasks has changed. It is now mapped to the ```ACTION_TASK_DENOTATE``` core action, which in the default keybindings is triggered by ```<Shift>e``` when the task is highlighted. | |
27 | * The format of the configuration file has changed, customizations in the legacy ```.vitrc``` file will need to be manually ported to the new format. The [config.sample.ini](vit/config/config.sample.ini) file is *heavily* commented, and should contain reference to everything you need to migrate the legacy configuration. If no ```config.ini``` exists in the VIT configuration directory, VIT will offer the option to install the sample config upon startup -- this is the easiest way to get started with porting and customization. | |
28 | * The method of removing annotations from tasks has changed. It is now mapped to the ```ACTION_TASK_DENOTATE``` core action, which in the default keybindings is triggered by ```<Shift>e``` when the task is highlighted. | |
29 | 29 | * VIT 1.3 supports Taskd sync via the ```s``` keybinding, which was undocumented. VIT 2.x properly documents this functionality, and moves it to the keybinding ```<Shift>s``` by default. |
30 | 30 | * The ```burndown``` configuration option and display has been removed -- it may be added again in a future release or via plugin functionality. |
Binary diff not shown
29 | 29 | task +LATEST start |
30 | 30 | task add c |
31 | 31 | task +LATEST start |
32 | task +LATEST modify project:foo | |
32 | 33 | |
33 | 34 | echo "Complete! |
34 | 35 |
20 | 20 | long_description_content_type='text/markdown', |
21 | 21 | install_requires=INSTALL_PACKAGES, |
22 | 22 | version=VERSION, |
23 | url='https://github.com/scottkosty/vit', | |
23 | url='https://github.com/vit-project/vit', | |
24 | 24 | author='Chad Phillips', |
25 | 25 | author_email='chad@apartmentlines.com', |
26 | 26 | classifiers=[ |
13 | 13 | import urwid |
14 | 14 | |
15 | 15 | from vit import version |
16 | from vit.exception import VitException | |
16 | 17 | from vit.formatter_base import FormatterBase |
17 | 18 | from vit import event |
18 | 19 | from vit.loader import Loader |
95 | 96 | def set_active_context(self): |
96 | 97 | self.context = self.task_config.get_active_context() |
97 | 98 | |
99 | def load_contexts(self): | |
100 | self.contexts = self.task_config.get_contexts() | |
101 | ||
98 | 102 | def bootstrap(self, load_early_config=True): |
99 | 103 | self.loader = Loader() |
100 | 104 | if load_early_config: |
101 | 105 | self.load_early_config() |
102 | self.contexts = self.task_config.get_contexts() | |
106 | self.load_contexts() | |
103 | 107 | self.set_active_context() |
104 | 108 | self.event = event.Emitter() |
105 | 109 | self.setup_config() |
106 | 110 | self.search_term_active = '' |
111 | self.search_direction_reverse = False | |
107 | 112 | self.action_registry = ActionRegistry() |
108 | 113 | self.actions = Actions(self.action_registry) |
109 | 114 | self.actions.register() |
110 | 115 | self.keybinding_parser = KeybindingParser(self.loader, self.config, self.action_registry) |
116 | self.command = Command(self.config) | |
117 | self.get_available_task_columns() | |
111 | 118 | self.setup_keybindings() |
112 | 119 | self.action_manager = ActionManagerRegistry(self.action_registry, self.key_cache.keybindings, event=self.event) |
113 | 120 | self.register_managed_actions() |
114 | self.command = Command(self.config) | |
115 | 121 | self.markers = Markers(self.config, self.task_config) |
116 | 122 | self.theme = self.init_theme() |
117 | 123 | self.theme_alt_backgrounds = self.get_theme_alt_backgrounds() |
166 | 172 | self.action_manager_registrar.register('TASK_EDIT', self.task_action_edit) |
167 | 173 | self.action_manager_registrar.register('TASK_SHOW', self.task_action_show) |
168 | 174 | |
175 | def default_keybinding_replacements(self): | |
176 | import json | |
177 | from datetime import datetime | |
178 | task_replacement_match = re.compile("^TASK_(\w+)$") | |
179 | def _task_attribute_match(variable): | |
180 | matches = re.match(task_replacement_match, variable) | |
181 | if matches: | |
182 | attribute = matches.group(1).lower() | |
183 | if attribute in self.available_columns: | |
184 | return [attribute] | |
185 | def _task_attribute_replace(task, attribute): | |
186 | if task and task[attribute]: | |
187 | if type(task[attribute]) in ['set', 'tuple', 'dict', 'list']: | |
188 | try: | |
189 | json.dumps(task[attribute]) | |
190 | except Exception as e: | |
191 | raise RuntimeError('Error parsing task attribute %s as JSON: %s' % (task[attribute], e)) | |
192 | elif isinstance(task[attribute], datetime): | |
193 | return task[attribute].strftime(self.formatter.report) | |
194 | else: | |
195 | return str(task[attribute]) | |
196 | return '' | |
197 | replacements = [ | |
198 | { | |
199 | 'match_callback': _task_attribute_match, | |
200 | 'replacement_callback': _task_attribute_replace, | |
201 | }, | |
202 | ] | |
203 | return replacements | |
204 | ||
205 | def add_user_keybinding_replacements(self, replacements): | |
206 | klass = self.loader.load_user_class('keybinding', 'keybinding', 'Keybinding') | |
207 | if klass: | |
208 | keybinding_custom = klass() | |
209 | replacements.extend(keybinding_custom.replacements()) | |
210 | return replacements | |
211 | ||
212 | def wrap_replacements_callbacks(self, replacements): | |
213 | def build_wrapper(callback): | |
214 | def wrapper(*args): | |
215 | _, task = self.get_focused_task() | |
216 | return callback(task, *args) | |
217 | return wrapper | |
218 | for i, replacement in enumerate(replacements): | |
219 | replacements[i]['replacement_callback'] = build_wrapper(replacement['replacement_callback']) | |
220 | return replacements | |
221 | ||
169 | 222 | def setup_keybindings(self): |
170 | 223 | self.keybinding_parser.load_default_keybindings() |
171 | 224 | bindings = self.config.items('keybinding') |
172 | def _task_uuid(): | |
173 | uuid, _ = self.get_focused_task() | |
174 | return uuid | |
175 | replacements = { | |
176 | 'TASK_UUID': _task_uuid, | |
177 | } | |
225 | replacements = self.wrap_replacements_callbacks(self.add_user_keybinding_replacements(self.default_keybinding_replacements())) | |
178 | 226 | keybindings = self.keybinding_parser.add_keybindings(bindings=bindings, replacements=replacements) |
179 | 227 | self.key_cache = KeyCache(keybindings) |
180 | 228 | self.key_cache.build_multi_key_cache() |
227 | 275 | |
228 | 276 | def prepare_keybinding_keypresses(self, keypresses): |
229 | 277 | def reducer(accum, key): |
230 | if isfunction(key): | |
231 | accum += list(key()) | |
278 | if type(key) is tuple: | |
279 | accum += list(key[0](*key[1])) | |
232 | 280 | else: |
233 | 281 | accum.append(key) |
234 | 282 | return accum |
295 | 343 | elif op == 'context': |
296 | 344 | # TODO: Validation if more than one arg passed. |
297 | 345 | context = args[0] if len(args) > 0 else 'none' |
346 | if context != 'none': | |
347 | # In case a new context was added between bootstraps. | |
348 | self.load_contexts() | |
298 | 349 | if self.execute_command(['task', 'context', context], wait=self.wait): |
299 | 350 | self.activate_message_bar('Context switched to: %s' % context) |
300 | 351 | else: |
322 | 373 | self.activate_message_bar('Task %s tags updated' % self.model.task_id(task['uuid'])) |
323 | 374 | elif op in ('search-forward', 'search-reverse'): |
324 | 375 | self.search_set_term(data['text']) |
376 | self.search_set_direction(op) | |
325 | 377 | self.search(reverse=(op == 'search-reverse')) |
326 | 378 | self.widget.focus_position = 'body' |
327 | 379 | if 'uuid' in metadata: |
395 | 447 | self.update_report(command) |
396 | 448 | if 'uuid' in metadata: |
397 | 449 | metadata.pop('uuid') |
450 | elif command in self.task_config.disallowed_reports: | |
451 | self.activate_message_bar("Report '%s' is non-standard, use ':!w task %s'" % (command, command), 'error') | |
398 | 452 | else: |
399 | 453 | # Matches 's/foo/bar/' and s%/foo/bar/, allowing for separators |
400 | 454 | # to be any non-word character. |
413 | 467 | |
414 | 468 | def search_set_term(self, text): |
415 | 469 | self.search_term_active = text |
470 | ||
471 | def search_set_direction(self, op): | |
472 | self.search_direction_reverse = op == 'search-reverse' | |
416 | 473 | |
417 | 474 | def search(self, reverse=False): |
418 | 475 | if not self.search_term_active: |
504 | 561 | self.autocomplete = AutoComplete(self.config, extra_filters={'report': self.reports.keys(), 'help': self.help.autocomplete_entries(), 'context': context_list}) |
505 | 562 | |
506 | 563 | def init_command_bar(self): |
507 | self.command_bar = CommandBar(autocomplete=self.autocomplete, event=self.event) | |
564 | abort_backspace = self.config.get('vit', 'abort_backspace') | |
565 | self.command_bar = CommandBar(autocomplete=self.autocomplete, abort_backspace=abort_backspace, event=self.event) | |
508 | 566 | |
509 | 567 | def build_frame(self): |
510 | 568 | self.status_report = urwid.AttrMap(urwid.Text('Welcome to VIT'), 'status') |
613 | 671 | self.activate_command_bar('search-reverse', '?', {'history': 'search'}) |
614 | 672 | |
615 | 673 | def activate_command_bar_search_next(self): |
616 | self.search() | |
674 | self.search(reverse=self.search_direction_reverse) | |
617 | 675 | |
618 | 676 | def activate_command_bar_search_previous(self): |
619 | self.search(reverse=True) | |
677 | self.search(reverse=not self.search_direction_reverse) | |
620 | 678 | |
621 | 679 | def activate_command_bar_task_context(self): |
622 | 680 | self.activate_command_bar('context', 'Context: ') |
702 | 760 | def task_action_priority(self): |
703 | 761 | uuid, _ = self.get_focused_task() |
704 | 762 | if uuid: |
705 | choices = { | |
706 | 'h': 'H', | |
707 | 'm': 'M', | |
708 | 'l': 'L', | |
709 | 'n': '', | |
710 | } | |
711 | self.activate_command_bar('priority', 'Priority (h/m/l/n): ', {'uuid': uuid, 'choices': choices}) | |
763 | choices = {} | |
764 | for choice in self.task_config.priority_values: | |
765 | key = choice.lower() or 'n' | |
766 | choices[key] = choice | |
767 | self.activate_command_bar('priority', 'Priority (%s): ' % '/'.join(choices), {'uuid': uuid, 'choices': choices}) | |
712 | 768 | |
713 | 769 | def task_action_project(self): |
714 | 770 | uuid, _ = self.get_focused_task() |
736 | 792 | if uuid: |
737 | 793 | self.execute_command(['task', uuid, 'info'], update_report=False) |
738 | 794 | self.task_list.focus_by_task_uuid(uuid) |
795 | ||
796 | def get_available_task_columns(self): | |
797 | returncode, stdout, stderr = self.command.run(['task', '_columns'], capture_output=True) | |
798 | if returncode == 0: | |
799 | self.available_columns = stdout.split() | |
800 | else: | |
801 | raise RuntimeError("Error retrieving available task columns: %s" % stderr) | |
739 | 802 | |
740 | 803 | def refresh_blocking_task_uuids(self): |
741 | 804 | returncode, stdout, stderr = self.command.run(['task', 'uuids', '+BLOCKING'], capture_output=True) |
846 | 909 | self.refresh_blocking_task_uuids() |
847 | 910 | self.formatter.recalculate_due_datetimes() |
848 | 911 | context_filters = self.contexts[self.context]['filter'] if self.context else [] |
849 | self.model.update_report(self.report, context_filters=context_filters, extra_filters=self.extra_filters) | |
912 | try: | |
913 | self.model.update_report(self.report, context_filters=context_filters, extra_filters=self.extra_filters) | |
914 | except VitException as err: | |
915 | self.activate_message_bar(str(err), 'error') | |
916 | return | |
850 | 917 | self.update_task_table() |
851 | 918 | self.update_status_report() |
852 | 919 | self.update_status_context() |
33 | 33 | for ac_type in filters: |
34 | 34 | setattr(self, ac_type, self.refresh_type(ac_type)) |
35 | 35 | |
36 | def get_refresh_type_command(self, ac_type): | |
37 | command = [ | |
38 | 'task', | |
39 | ] | |
40 | if ac_type == 'project': | |
41 | command.extend([ | |
42 | 'rc.list.all.projects=yes', | |
43 | '_projects', | |
44 | ]) | |
45 | else: | |
46 | command.extend([ | |
47 | '_%ss' % ac_type | |
48 | ]) | |
49 | return command | |
50 | ||
36 | 51 | def refresh_type(self, ac_type): |
37 | command = 'task _%ss' % ac_type | |
38 | returncode, stdout, stderr = self.command.run(command, capture_output=True) | |
52 | returncode, stdout, stderr = self.command.run(self.get_refresh_type_command(ac_type), capture_output=True) | |
39 | 53 | if returncode == 0: |
40 | 54 | items = list(filter(lambda x: True if x else False, stdout.split("\n"))) |
41 | 55 | if ac_type == 'project': |
75 | 89 | entries.sort() |
76 | 90 | return entries |
77 | 91 | |
92 | def make_space_escape_regex(self, filters, filter_config): | |
93 | prefix_parts = [] | |
94 | for ac_type in filters: | |
95 | items = getattr(self, ac_type) | |
96 | type_prefixes = filter_config[ac_type]['prefixes'] if ac_type in filter_config and 'prefixes' in filter_config[ac_type] else [] | |
97 | for prefix in type_prefixes: | |
98 | prefix_parts.append(re.escape(prefix)) | |
99 | prefix_or = "|".join(prefix_parts) | |
100 | return re.compile("^(%s).+[ ]+.+$" % prefix_or) | |
101 | ||
78 | 102 | def setup(self, text_callback, filters=None, filter_config=None): |
79 | 103 | if self.is_setup: |
80 | 104 | self.reset() |
85 | 109 | filter_config = self.default_filter_config |
86 | 110 | self.refresh() |
87 | 111 | self.entries = self.make_entries(filters, filter_config) |
112 | self.space_escape_regex = self.make_space_escape_regex(filters, filter_config) | |
88 | 113 | self.root_only_filters = list(filter(lambda f: True if f in filter_config and 'root_only' in filter_config[f] else False, filters)) |
89 | 114 | self.is_setup = True |
90 | 115 | |
152 | 177 | def is_help_request(self): |
153 | 178 | return self.prefix_parts[0] in ['help'] |
154 | 179 | |
180 | def add_space_escaping(self, text): | |
181 | if self.space_escape_regex.match(text): | |
182 | return text.replace(' ', '\ ') | |
183 | else: | |
184 | return text | |
185 | ||
186 | def remove_space_escaping(self, text): | |
187 | return text.replace('\\ ', ' ') | |
188 | ||
155 | 189 | def parse_text(self, text, edit_pos): |
156 | 190 | full_prefix = text[:edit_pos] |
157 | self.prefix_parts = util.string_to_args(full_prefix) | |
191 | self.prefix_parts = list(map(self.add_space_escaping, util.string_to_args(full_prefix))) | |
158 | 192 | if not self.prefix_parts: |
159 | 193 | self.search_fragment = self.prefix = full_prefix |
160 | 194 | self.suffix = text[(edit_pos + 1):] |
165 | 199 | self.search_fragment = self.prefix_parts.pop() |
166 | 200 | self.prefix = ' '.join(self.prefix_parts) |
167 | 201 | self.suffix = text[(edit_pos + 1):] |
202 | self.search_fragment = self.remove_space_escaping(self.search_fragment) | |
168 | 203 | |
169 | 204 | def can_tab(self, text, edit_pos): |
170 | 205 | if edit_pos == 0: |
177 | 212 | return text[edit_pos:next_pos] in (' ', '') and text[previous_pos:edit_pos] not in (' ', '') |
178 | 213 | |
179 | 214 | def assemble(self, tab_option, solo_match=False): |
180 | if solo_match and not tab_option.endswith(":"): | |
181 | tab_option += ' ' | |
215 | if not tab_option.endswith(":"): | |
216 | tab_option = self.add_space_escaping(tab_option) | |
217 | if solo_match: | |
218 | tab_option += ' ' | |
182 | 219 | parts = [self.prefix, tab_option, self.suffix] |
183 | 220 | tabbed_text = ' '.join(filter(lambda p: True if p else False, parts)) |
184 | 221 | parts.pop() |
0 | 0 | import urwid |
1 | from vit.readline import Readline | |
1 | 2 | |
2 | 3 | class CommandBar(urwid.Edit): |
3 | 4 | """Custom urwid.Edit class for the command bar. |
4 | 5 | """ |
5 | 6 | def __init__(self, **kwargs): |
6 | self.event = kwargs['event'] | |
7 | self.autocomplete = kwargs['autocomplete'] | |
7 | self.event = kwargs.pop('event') | |
8 | self.autocomplete = kwargs.pop('autocomplete') | |
9 | self.abort_backspace = kwargs.pop('abort_backspace') | |
8 | 10 | self.metadata = None |
9 | 11 | self.history = CommandBarHistory() |
10 | kwargs.pop('event') | |
11 | kwargs.pop('autocomplete') | |
12 | self.readline = Readline(self) | |
12 | 13 | return super().__init__(**kwargs) |
13 | 14 | |
14 | 15 | def keypress(self, size, key): |
15 | 16 | """Overrides Edit.keypress method. |
16 | 17 | """ |
17 | # TODO: Readline edit shortcuts. | |
18 | 18 | if key not in ('tab', 'shift tab'): |
19 | 19 | self.autocomplete.deactivate() |
20 | 20 | if 'choices' in self.metadata: |
21 | op = self.metadata['op'] | |
22 | data = { | |
23 | 'choice': None, | |
24 | 'metadata': self.get_metadata(), | |
25 | } | |
26 | if key in self.metadata['choices']: | |
27 | data['choice'] = self.metadata['choices'][key] | |
28 | self.cleanup(op) | |
29 | self.event.emit('command-bar:keypress', data) | |
21 | self.quit({'choice': self.metadata['choices'].get(key)}) | |
30 | 22 | return None |
31 | elif key in ('ctrl a',): | |
32 | self.set_edit_pos(0) | |
23 | elif key in ('up',): | |
24 | self.readline.keypress('ctrl p') | |
33 | 25 | return None |
34 | elif key in ('ctrl e',): | |
35 | self.set_edit_pos(len(self.get_edit_text())) | |
36 | return None | |
37 | elif key in ('up', 'ctrl p'): | |
38 | text = self.history.previous(self.metadata['history']) | |
39 | if text != False: | |
40 | self.set_edit_text(text) | |
41 | return None | |
42 | elif key in ('down', 'ctrl n'): | |
43 | text = self.history.next(self.metadata['history']) | |
44 | if text != False: | |
45 | self.set_edit_text(text) | |
26 | elif key in ('down',): | |
27 | self.readline.keypress('ctrl n') | |
46 | 28 | return None |
47 | 29 | elif key in ('enter', 'esc'): |
48 | 30 | text = self.get_edit_text().strip() |
49 | metadata = self.get_metadata() | |
50 | data = { | |
51 | 'key': key, | |
52 | 'text': text, | |
53 | 'metadata': metadata, | |
54 | } | |
55 | self.cleanup(metadata['op']) | |
56 | if text and key in ('enter'): | |
57 | self.history.add(metadata['history'], text) | |
58 | self.event.emit('command-bar:keypress', data) | |
31 | if text and key == 'enter': | |
32 | self.history.add(self.metadata['history'], text) | |
33 | self.quit({'key': key, 'text': text}) | |
59 | 34 | return None |
60 | 35 | elif key in ('tab', 'shift tab'): |
61 | 36 | if self.is_autocomplete_op(): |
65 | 40 | kwargs['reverse'] = True |
66 | 41 | self.autocomplete.activate(text, self.edit_pos, **kwargs) |
67 | 42 | return None |
43 | elif key in self.readline.keys(): | |
44 | return self.readline.keypress(key) | |
45 | elif self.is_aborting_backspace(key): | |
46 | self.quit({'key': key}) | |
47 | return None | |
68 | 48 | return super().keypress(size, key) |
49 | ||
50 | def is_aborting_backspace(self, key): | |
51 | return key == 'backspace' and self.abort_backspace and not self.get_edit_text() | |
69 | 52 | |
70 | 53 | def is_autocomplete_op(self): |
71 | 54 | return self.metadata['op'] not in ['search-forward', 'search-reverse'] |
79 | 62 | |
80 | 63 | def set_command_prompt(self, caption, edit_text=None): |
81 | 64 | self.set_caption(caption) |
82 | if edit_text: | |
65 | if edit_text is not None: | |
83 | 66 | self.set_edit_text(edit_text) |
84 | 67 | |
85 | 68 | def activate(self, caption, metadata, edit_text=None): |
86 | 69 | self.set_metadata(metadata) |
87 | 70 | self.set_command_prompt(caption, edit_text) |
88 | 71 | |
89 | def cleanup(self, command): | |
90 | self.set_caption('') | |
91 | self.set_edit_text('') | |
92 | self.history.cleanup(command) | |
72 | def deactivate(self): | |
73 | self.set_command_prompt('', '') | |
74 | self.history.cleanup(self.metadata['op']) | |
93 | 75 | self.set_metadata(None) |
76 | ||
77 | def quit(self, metadata_args={}): | |
78 | data = {'metadata': self.get_metadata(), **metadata_args} | |
79 | self.deactivate() | |
80 | self.event.emit('command-bar:keypress', data) # remove focus from command bar | |
94 | 81 | |
95 | 82 | def get_metadata(self): |
96 | 83 | return self.metadata.copy() if self.metadata else None |
48 | 48 | # Boolean. If true, VIT will enable mouse support for actions such as selecting |
49 | 49 | # list items. |
50 | 50 | #mouse = False |
51 | ||
52 | # Boolean. If true, hitting backspace against an empty prompt aborts the prompt. | |
53 | #abort_backspace = False | |
51 | 54 | |
52 | 55 | [report] |
53 | 56 | |
187 | 190 | # wa = {ACTION_TASK_WAIT} |
188 | 191 | # The above would disable the task wait action for the 'w' key, and instead |
189 | 192 | # assign it to the 'wa' keybinding. |
193 | # For capital letter keybindings, use the letter directly: | |
194 | # D = {ACTION_TASK_DONE} | |
190 | 195 | |
191 | 196 | # For a list of available actions, run 'vit --list-actions'. |
192 | 197 | # A great reference for many of the available meta keys, and understanding the |
198 | 203 | # the currently focused task UUID: |
199 | 204 | # o = :!wr onenote {TASK_UUID}<Enter> |
200 | 205 | |
201 | # The special '{TASK_UUID}' variable can be used in any macro, and it will be | |
202 | # replaced with the currently highlighted task's UUID. | |
206 | # The special '{TASK_[attribute]}' variable can be used in any macro, and it | |
207 | # will be replaced with the value of the attribute for the currently | |
208 | # highlighted task. Any attribute listed in 'task _columns' is supported, e.g. | |
209 | # o = :!wr echo project is {TASK_PROJECT}<Enter> | |
203 | 210 | |
204 | 211 | # Multiple keybindings can be associated with the same action/macro, simply |
205 | 212 | # separate the keybindings with a comma: |
206 | # <Ctrl>z,zz = {ACTION_TASK_UNDO} | |
213 | # <Ctrl> z,zz = {ACTION_TASK_UNDO} | |
214 | ||
215 | # 'Special' keys are indicated by enclosing them in brackets. VIT supports the | |
216 | # following special keys on either side of the keybinding declaration, by | |
217 | # internally translating them into the single character: | |
218 | # | |
219 | # <Colon> | |
220 | # <Equals> | |
221 | # <Space> | |
222 | # <Semicolon> | |
223 | # | |
224 | # Under the hood, VIT uses the Urwid mappings for keyboard input: | |
225 | # http://urwid.org/manual/userinput.html | |
226 | # | |
227 | # Any modifier, navigation, or function keys can be described in the VIT | |
228 | # keybinding configuration by wrapping them in angle brackets, matching the | |
229 | # correct Urwid keyboard input structure: | |
230 | # | |
231 | # <Ctrl> e = :!wr echo do something | |
232 | # <Shift> <Ctrl> <F5> = :!wr echo you used a function key | |
233 |
20 | 20 | FILTER_EXCLUSION_REGEX = re.compile('^limit:') |
21 | 21 | FILTER_PARENS_REGEX = re.compile('([\(\)])') |
22 | 22 | CONFIG_BOOLEAN_TRUE_REGEX = re.compile('1|yes|true', re.IGNORECASE) |
23 | # TaskParser expects clean hierachies in the Taskwarrior dotted config names. | |
24 | # However, this is occassionally violated, with a leaf ending in both a string | |
23 | # TaskParser expects clean hierarchies in the Taskwarrior dotted config names. | |
24 | # However, this is occasionally violated, with a leaf ending in both a string | |
25 | 25 | # value and another branch. The below list contains the config values that |
26 | 26 | # violate this convention, and transform them into a single additional branch |
27 | 27 | # of value CONFIG_STRING_LEAVES_DEFAULT_BRANCH |
29 | 29 | 'color.calendar.due', |
30 | 30 | 'color.due', |
31 | 31 | 'color.label', |
32 | 'dateformat', | |
32 | 33 | ] |
33 | 34 | CONFIG_STRING_LEAVES_DEFAULT_BRANCH = 'default' |
34 | 35 | |
42 | 43 | 'confirmation': True, |
43 | 44 | 'wait': True, |
44 | 45 | 'mouse': False, |
46 | 'abort_backspace': False, | |
45 | 47 | }, |
46 | 48 | 'report': { |
47 | 49 | 'default_report': 'next', |
99 | 101 | self.user_config_filepath = '%s/%s' % (self.user_config_dir, VIT_CONFIG_FILE) |
100 | 102 | if not self.config_file_exists(self.user_config_filepath): |
101 | 103 | self.optional_create_config_file(self.user_config_filepath) |
104 | self.taskrc_path = self.get_taskrc_path() | |
105 | self.validate_taskrc() | |
102 | 106 | self.config.read(self.user_config_filepath) |
103 | 107 | self.defaults = DEFAULTS |
104 | 108 | self.set_config_data() |
105 | 109 | |
106 | 110 | def set_config_data(self): |
107 | self.taskrc_path = self.get_taskrc_path() | |
108 | 111 | self.subproject_indentable = self.is_subproject_indentable() |
109 | 112 | self.row_striping_enabled = self.is_row_striping_enabled() |
110 | 113 | self.confirmation_enabled = self.is_confirmation_enabled() |
111 | 114 | self.wait_enabled = self.is_wait_enabled() |
112 | 115 | self.mouse_enabled = self.is_mouse_enabled() |
116 | ||
117 | def validate_taskrc(self): | |
118 | try: | |
119 | open(self.taskrc_path, 'r').close() | |
120 | except FileNotFoundError: | |
121 | message = """ | |
122 | %s not found. | |
123 | ||
124 | VIT requires a properly configured TaskWarrior instance in the current | |
125 | environment. Execute the 'task' binary with no arguments to initialize a new | |
126 | configuration. | |
127 | """ % (self.taskrc_path) | |
128 | print(message) | |
129 | exit(1) | |
113 | 130 | |
114 | 131 | def config_file_exists(self, filepath): |
115 | 132 | try: |
192 | 209 | self.projects = [] |
193 | 210 | self.contexts = {} |
194 | 211 | self.reports = {} |
212 | self.disallowed_reports = [ | |
213 | 'timesheet', | |
214 | ] | |
195 | 215 | self.command = Command(self.config) |
196 | 216 | self.get_task_config() |
197 | 217 | self.get_projects() |
198 | 218 | self.set_config_data() |
199 | 219 | |
200 | 220 | def set_config_data(self): |
201 | self.print_empty_columns = self.subtree('print.empty.columns') == 'yes' | |
221 | self.print_empty_columns = self.is_truthy(self.subtree('print.empty.columns')) | |
222 | self.priority_values = self.get_priority_values() | |
202 | 223 | |
203 | 224 | def get_task_config(self): |
225 | self.task_config = [] | |
204 | 226 | returncode, stdout, stderr = self.command.run('task _show', capture_output=True) |
205 | 227 | if returncode == 0: |
206 | 228 | lines = list(filter(lambda x: True if x else False, stdout.split("\n"))) |
207 | 229 | for line in lines: |
208 | hierarchy, values = line.split('=') | |
230 | hierarchy, values = line.split('=', maxsplit=1) | |
209 | 231 | self.task_config.append((hierarchy, values)) |
210 | 232 | else: |
211 | 233 | raise RuntimeError('Error parsing task config: %s' % stderr) |
225 | 247 | self.projects.pop() |
226 | 248 | else: |
227 | 249 | raise RuntimeError('Error parsing task projects: %s' % stderr) |
250 | ||
251 | def get_priority_values(self): | |
252 | return self.subtree('uda.priority.values').split(',') | |
228 | 253 | |
229 | 254 | def transform_string_leaves(self, hierarchy): |
230 | 255 | if hierarchy in CONFIG_STRING_LEAVES: |
295 | 320 | |
296 | 321 | def get_contexts(self): |
297 | 322 | contexts = {} |
323 | self.get_task_config() | |
298 | 324 | subtree = self.subtree('context.') |
299 | 325 | for context, filters in list(subtree.items()): |
300 | 326 | filters = shlex.split(re.sub(FILTER_PARENS_REGEX, r' \1 ', filters)) |
308 | 334 | reports = {} |
309 | 335 | subtree = self.subtree('report.') |
310 | 336 | for report, attrs in list(subtree.items()): |
337 | if report in self.disallowed_reports: | |
338 | continue | |
311 | 339 | reports[report] = { |
312 | 340 | 'name': report, |
313 | 341 | 'subproject_indentable': False, |
323 | 351 | reports[report]['filter'] = [f for f in filters if not FILTER_EXCLUSION_REGEX.match(f)] |
324 | 352 | if 'labels' in attrs: |
325 | 353 | reports[report]['labels'] = attrs['labels'].split(',') |
354 | else: | |
355 | reports[report]['labels'] = [ column.title() for column in attrs['columns'].split(',') ] | |
326 | 356 | if 'sort' in attrs: |
327 | 357 | columns = attrs['sort'].split(',') |
328 | 358 | reports[report]['sort'] = [self.parse_sort_column(c) for c in columns] |
339 | 369 | report['subproject_indentable'] = self.has_project_column(report_name) and self.has_primary_project_ascending_sort(report) |
340 | 370 | return report |
341 | 371 | |
372 | def is_truthy(self, value): | |
373 | value = str(value) | |
374 | return value.lower() in ['y', 'yes', 'on', 'true', '1'] | |
375 | ||
342 | 376 | def has_project_column(self, report_name): |
343 | 377 | return self.get_column_index(report_name, 'project') is not None |
344 | 378 | |
345 | 379 | def has_primary_project_ascending_sort(self, report): |
346 | primary_sort = report['sort'][0] | |
380 | try: | |
381 | primary_sort = report['sort'][0] | |
382 | except KeyError: | |
383 | return False | |
384 | ||
347 | 385 | return primary_sort[0] == 'project' and primary_sort[1] == 'ascending' |
348 | 386 | |
349 | 387 | def get_column_index(self, report_name, column): |
39 | 39 | } |
40 | 40 | |
41 | 41 | class Formatter(object): |
42 | def __init__(self, column, report, formatter_base, **kwargs): | |
42 | def __init__(self, column, report, formatter_base, blocking_task_uuids, **kwargs): | |
43 | 43 | self.column = column |
44 | 44 | self.report = report |
45 | 45 | self.formatter = formatter_base |
46 | 46 | self.colorizer = self.formatter.task_colorizer |
47 | self.blocking_task_uuids = blocking_task_uuids | |
47 | 48 | |
48 | 49 | def format(self, obj, task): |
49 | 50 | if not obj: |
66 | 67 | def colorize(self, obj): |
67 | 68 | return None |
68 | 69 | |
70 | def filter_by_blocking_task_uuids(self, depends): | |
71 | return [ task for task in depends if task['uuid'] in self.blocking_task_uuids ] | |
72 | ||
69 | 73 | class Marker(Formatter): |
70 | 74 | def __init__(self, report, defaults, report_marker_columns, blocking_task_uuids): |
71 | super().__init__(None, report, defaults) | |
75 | super().__init__(None, report, defaults, blocking_task_uuids) | |
72 | 76 | self.columns = report_marker_columns |
73 | self.blocking_tasks = blocking_task_uuids | |
74 | 77 | self.labels = self.formatter.markers.labels |
75 | 78 | self.udas = self.formatter.markers.udas |
76 | 79 | self.require_color = self.formatter.markers.require_color |
99 | 102 | return (self.colorize(obj), formatted_duration) |
100 | 103 | |
101 | 104 | class DateTime(Formatter): |
102 | def __init__(self, column, report, defaults, **kwargs): | |
105 | def __init__(self, column, report, defaults, blocking_task_uuids, **kwargs): | |
103 | 106 | self.custom_formatter = None if not 'custom_formatter' in kwargs else kwargs['custom_formatter'] |
104 | super().__init__(column, report, defaults) | |
107 | super().__init__(column, report, defaults, blocking_task_uuids) | |
105 | 108 | |
106 | 109 | def format(self, dt, task): |
107 | 110 | if not dt: |
2 | 2 | |
3 | 3 | class Depends(List): |
4 | 4 | def format_list(self, depends, task): |
5 | return ','.join(list(map(lambda t: str(util.task_id_or_uuid_short(t)), depends))) if depends else '' | |
5 | return ','.join(list(map(lambda t: str(util.task_id_or_uuid_short(t)), self.filter_by_blocking_task_uuids(depends)))) if depends else '' | |
6 | 6 | |
7 | 7 | def colorize(self, depends): |
8 | 8 | return self.colorizer.blocked(depends) |
1 | 1 | |
2 | 2 | class DependsCount(Depends): |
3 | 3 | def format_list(self, depends, task): |
4 | return '[%d]' % len(depends) if depends else '' | |
4 | return '[%d]' % len(self.filter_by_blocking_task_uuids(depends)) if depends else '' |
1 | 1 | |
2 | 2 | class DependsIndicator(Depends): |
3 | 3 | def format_list(self, depends, task): |
4 | return self.formatter.indicator_dependency if depends else '' | |
4 | return self.formatter.indicator_dependency if self.filter_by_blocking_task_uuids(depends) else '' |
11 | 11 | width, text_markup = self.format_due(width, text_markup, task['due'], task) |
12 | 12 | if self.mark_status: |
13 | 13 | width, text_markup = self.format_status(width, text_markup, task['status']) |
14 | if self.mark_depends and task['depends']: | |
14 | if self.mark_depends and self.filter_by_blocking_task_uuids(task['depends']): | |
15 | 15 | width, text_markup = self.format_blocked(width, text_markup, task['depends']) |
16 | 16 | if self.mark_start and task['start']: |
17 | 17 | width, text_markup = self.format_active(width, text_markup, task['start'], task) |
24 | 24 | for uda_name, uda_type in self.udas.items(): |
25 | 25 | if getattr(self, 'mark_%s' % uda_name): |
26 | 26 | width, text_markup = self.format_uda(width, text_markup, uda_name, uda_type, task[uda_name]) |
27 | if task['uuid'] in self.blocking_tasks: | |
27 | if task['uuid'] in self.blocking_task_uuids: | |
28 | 28 | width, text_markup = self.format_blocking(width, text_markup) |
29 | 29 | return (width, '' if width == 0 else text_markup) |
30 | 30 |
0 | 0 | from vit.formatter import String |
1 | 1 | |
2 | 2 | class Project(String): |
3 | def __init__(self, column, report, defaults, **kwargs): | |
4 | super().__init__(column, report, defaults) | |
3 | def __init__(self, column, report, defaults, blocking_task_uuids, **kwargs): | |
4 | super().__init__(column, report, defaults, blocking_task_uuids) | |
5 | 5 | self.indent_subprojects = self.is_subproject_indentable() |
6 | 6 | |
7 | 7 | def format(self, project, task): |
9 | 9 | if not dt: |
10 | 10 | return self.markup_none(self.colorize()) |
11 | 11 | # TODO: Remove this once tasklib bug is fixed. |
12 | # https://github.com/robgolding/tasklib/issues/30 | |
12 | 13 | dt = dt if isinstance(dt, datetime.datetime) else serializer.timestamp_deserializer(dt) |
13 | 14 | formatted_date = dt.strftime(self.custom_formatter or self.formatter.report) |
14 | 15 | return (len(formatted_date), (self.colorize(dt), formatted_date)) |
22 | 22 | self.task_config = task_config |
23 | 23 | self.markers = markers |
24 | 24 | self.task_colorizer = task_colorizer |
25 | self.report = self.task_config.translate_date_markers(self.task_config.subtree('dateformat.report')) | |
26 | self.annotation = self.task_config.translate_date_markers(self.task_config.subtree('dateformat.annotation')) | |
25 | self.date_default = self.task_config.translate_date_markers(self.task_config.subtree('dateformat')["default"]) | |
26 | self.report = self.task_config.translate_date_markers(self.task_config.subtree('dateformat.report')) or self.date_default | |
27 | self.annotation = self.task_config.translate_date_markers(self.task_config.subtree('dateformat.annotation')) or self.date_default | |
27 | 28 | self.description_truncate_len = DEFAULT_DESCRIPTION_TRUNCATE_LEN |
28 | 29 | self.zone = get_localzone() |
29 | 30 | self.epoch_datetime = datetime(1970, 1, 1, tzinfo=timezone('UTC')) |
26 | 26 | return list(filter(None, sorted_keybindings)) |
27 | 27 | |
28 | 28 | def get_non_modified_keybindings(self): |
29 | return [k for k in self.keybindings if not self.keybindings[k]['has_modifier']] | |
29 | return [k for k in self.keybindings if self.keybindings[k]['has_special_keys'] or not self.keybindings[k]['has_modifier']] | |
30 | 30 | |
31 | 31 | def add_keybinding_to_key_cache(self, to_cache, keybinding, existing_keybindings, key_cache): |
32 | 32 | if to_cache in existing_keybindings: |
38 | 38 | # The parser doesn't recognize a space on the left side of an assignment, |
39 | 39 | # the special <Space> marker is used instead. |
40 | 40 | <Down>,j,<Space> = {ACTION_LIST_DOWN} |
41 | <Page Up>,<Ctrl>b = {ACTION_LIST_PAGE_UP} | |
42 | <Page Down>,<Ctrl>f = {ACTION_LIST_PAGE_DOWN} | |
41 | <Page Up>,<Ctrl> b = {ACTION_LIST_PAGE_UP} | |
42 | <Page Down>,<Ctrl> f = {ACTION_LIST_PAGE_DOWN} | |
43 | 43 | gg,0 = {ACTION_LIST_HOME} |
44 | 44 | G = {ACTION_LIST_END} |
45 | 45 | H = {ACTION_LIST_SCREEN_TOP} |
49 | 49 | |
50 | 50 | [report] |
51 | 51 | f = {ACTION_REPORT_FILTER} |
52 | <Ctrl>l = {ACTION_REFRESH} | |
52 | <Ctrl> l = {ACTION_REFRESH} |
12 | 12 | BASE_DIR = os.path.dirname(os.path.realpath(__file__)) |
13 | 13 | BRACKETS_REGEX = re.compile("[<>]") |
14 | 14 | DEFAULT_KEYBINDINGS_SECTIONS = ('global', 'navigation', 'command', 'report') |
15 | CONFIG_NAME_SPECIAL_KEY_SUBSTITUTIONS = { | |
15 | CONFIG_SPECIAL_KEY_SUBSTITUTIONS = { | |
16 | 16 | 'colon': ':', |
17 | 17 | 'equals': '=', |
18 | 18 | 'space': ' ', |
19 | 'semicolon': ';', | |
19 | 20 | } |
20 | 21 | |
21 | 22 | class KeybindingError(Exception): |
59 | 60 | bindings = self.items(section) |
60 | 61 | self.add_keybindings(bindings) |
61 | 62 | |
62 | def keybinding_special_keys_substitutions(self, name): | |
63 | if name in CONFIG_NAME_SPECIAL_KEY_SUBSTITUTIONS: | |
64 | name = CONFIG_NAME_SPECIAL_KEY_SUBSTITUTIONS[name] | |
65 | return name | |
63 | def keybinding_special_keys_substitutions(self, value): | |
64 | is_special_key = value in CONFIG_SPECIAL_KEY_SUBSTITUTIONS | |
65 | if is_special_key: | |
66 | value = CONFIG_SPECIAL_KEY_SUBSTITUTIONS[value] | |
67 | return value, is_special_key | |
66 | 68 | |
67 | 69 | def parse_keybinding_keys(self, keys): |
68 | 70 | has_modifier = bool(re.match(BRACKETS_REGEX, keys)) |
69 | parsed_keys = ' '.join(re.sub(BRACKETS_REGEX, ' ', keys).strip().split()).lower() if has_modifier else keys | |
70 | return self.keybinding_special_keys_substitutions(parsed_keys), has_modifier | |
71 | def reducer(accum, char): | |
72 | if char == '<': | |
73 | accum['in_brackets'] = True | |
74 | accum['bracket_string'] = '' | |
75 | elif char == '>': | |
76 | accum['in_brackets'] = False | |
77 | value, is_special_key = self.keybinding_special_keys_substitutions(accum['bracket_string'].lower()) | |
78 | accum['keys'] += value | |
79 | accum['has_special_keys'] = is_special_key | |
80 | else: | |
81 | if accum['in_brackets']: | |
82 | accum['bracket_string'] += char | |
83 | else: | |
84 | accum['keys'] += char | |
85 | return accum | |
86 | accum = reduce(reducer, keys, { | |
87 | 'keys': '', | |
88 | 'in_brackets': False, | |
89 | 'bracket_string': '', | |
90 | 'has_special_keys': False, | |
91 | }) | |
92 | return accum['keys'], has_modifier, accum['has_special_keys'] | |
71 | 93 | |
72 | 94 | def parse_keybinding_value(self, value, replacements={}): |
73 | 95 | def reducer(accum, char): |
76 | 98 | accum['bracket_string'] = '' |
77 | 99 | elif char == '>': |
78 | 100 | accum['in_brackets'] = False |
79 | accum['keybinding'].append(accum['bracket_string'].lower()) | |
101 | value, is_special_key = self.keybinding_special_keys_substitutions(accum['bracket_string'].lower()) | |
102 | accum['keybinding'].append(value) | |
80 | 103 | elif char == '{': |
81 | 104 | accum['in_variable'] = True |
82 | 105 | accum['variable_string'] = '' |
84 | 107 | accum['in_variable'] = False |
85 | 108 | if accum['variable_string'] in self.actions: |
86 | 109 | accum['action_name'] = accum['variable_string'] |
87 | elif accum['variable_string'] in replacements: | |
88 | accum['keybinding'].append(replacements[accum['variable_string']]) | |
89 | 110 | else: |
111 | for replacement in replacements: | |
112 | args = replacement['match_callback'](accum['variable_string']) | |
113 | if isinstance(args, list): | |
114 | accum['keybinding'].append((replacement['replacement_callback'], args)) | |
115 | return accum | |
90 | 116 | raise ValueError("unknown config variable '%s'" % accum['variable_string']) |
91 | 117 | else: |
92 | 118 | if accum['in_brackets']: |
124 | 150 | bound_keys, action_name = self.parse_keybinding_value(value, replacements) |
125 | 151 | self.validate_parsed_value(key_groups, bound_keys, action_name) |
126 | 152 | for keys in key_groups.strip().split(','): |
127 | parsed_keys, has_modifier = self.parse_keybinding_keys(keys) | |
153 | parsed_keys, has_modifier, has_special_keys = self.parse_keybinding_keys(keys) | |
128 | 154 | self.keybindings[parsed_keys] = { |
129 | 155 | 'label': keys, |
130 | 156 | 'has_modifier': has_modifier, |
157 | 'has_special_keys': has_special_keys, | |
131 | 158 | } |
132 | 159 | if action_name: |
133 | 160 | self.keybindings[parsed_keys]['action_name'] = action_name |
16 | 16 | filepath = '%s/%s/%s.py' % (self.user_config_dir, module_type, module_name) |
17 | 17 | try: |
18 | 18 | mod = self.import_from_path(module, filepath) |
19 | except SyntaxError as e: | |
20 | raise SyntaxError("User class: %s (%s) -- %s" % (class_name, filepath, e)) | |
19 | 21 | except: |
20 | 22 | return None |
21 | 23 | return getattr(mod, class_name) |
22 | 24 | |
23 | 25 | def import_from_path(self, module, filepath): |
24 | try: | |
25 | spec = importlib.util.spec_from_file_location(module, filepath) | |
26 | mod = importlib.util.module_from_spec(spec) | |
27 | spec.loader.exec_module(mod) | |
28 | return mod | |
29 | except: | |
30 | mod = imp.load_source(module, filepath) | |
31 | return mod | |
26 | spec = importlib.util.spec_from_file_location(module, filepath) | |
27 | mod = importlib.util.module_from_spec(spec) | |
28 | spec.loader.exec_module(mod) | |
29 | return mod |
0 | 0 | import sys |
1 | import optparse | |
1 | import argparse | |
2 | 2 | |
3 | 3 | from vit import version |
4 | 4 | |
5 | class OptionParser(optparse.OptionParser): | |
6 | def format_epilog(self, formatter): | |
7 | return self.epilog | |
8 | ||
9 | parser = OptionParser( | |
10 | usage='%prog [options] [report] [filters]', | |
11 | version=version.VIT, | |
12 | epilog=""" | |
13 | VIT (Visual Interactive Taskwarrior) is a lightweight, curses-based front end for | |
5 | parser = argparse.ArgumentParser( | |
6 | description="VIT (Visual Interactive Taskwarrior)", | |
7 | usage='%(prog)s [options] [report] [filters]', | |
8 | formatter_class=argparse.RawTextHelpFormatter, | |
9 | allow_abbrev=False, | |
10 | epilog=""" | |
11 | VIT (Visual Interactive Taskwarrior) is a lightweight, curses-based front end for | |
14 | 12 | Taskwarrior that provides a convenient way to quickly navigate and process tasks. |
15 | 13 | VIT allows you to interact with tasks in a Vi-intuitive way. |
16 | 14 | |
20 | 18 | |
21 | 19 | While VIT is running, type :help followed by enter to review basic command/navigation actions. |
22 | 20 | |
23 | See https://github.com/scottkosty/vit for more information. | |
21 | See https://github.com/vit-project/vit for more information. | |
24 | 22 | |
25 | 23 | """ |
26 | 24 | ) |
27 | 25 | |
28 | parser.add_option('--list-actions', | |
29 | dest="list_actions", | |
30 | default=False, | |
31 | action="store_true", | |
32 | help="list all available actions", | |
26 | parser.add_argument('-v', '--version', | |
27 | action='version', | |
28 | version=version.VIT, | |
29 | ) | |
30 | parser.add_argument('--list-actions', | |
31 | dest="list_actions", | |
32 | default=False, | |
33 | action="store_true", | |
34 | help="list all available actions", | |
33 | 35 | ) |
34 | 36 | |
35 | 37 | def parse_options(): |
36 | options, filters = parser.parse_args() | |
38 | options, filters = parser.parse_known_args() | |
37 | 39 | if options.list_actions: |
38 | 40 | list_actions() |
39 | 41 | sys.exit(0) |
0 | import string | |
1 | import re | |
2 | ||
3 | class Readline(object): | |
4 | def __init__(self, edit_obj): | |
5 | self.edit_obj = edit_obj | |
6 | word_chars = string.ascii_letters + string.digits + "_" | |
7 | self._word_regex1 = re.compile( | |
8 | "([%s]+)" % "|".join(re.escape(ch) for ch in word_chars) | |
9 | ) | |
10 | self._word_regex2 = re.compile( | |
11 | "([^%s]+)" % "|".join(re.escape(ch) for ch in word_chars) | |
12 | ) | |
13 | ||
14 | def keys(self): | |
15 | return ('ctrl p', 'ctrl n', 'ctrl a', 'ctrl e', 'ctrl b', 'ctrl f', | |
16 | 'ctrl h', 'ctrl d', 'ctrl t', 'ctrl u', 'ctrl k', 'meta b', | |
17 | 'meta f', 'ctrl w', 'meta d') | |
18 | ||
19 | def keypress(self, key): | |
20 | # Move to the previous line. | |
21 | if key in ('ctrl p',): | |
22 | text = self.edit_obj.history.previous(self.edit_obj.metadata['history']) | |
23 | if text != False: | |
24 | self.edit_obj.set_edit_text(text) | |
25 | return None | |
26 | # Move to the next line. | |
27 | elif key in ('ctrl n',): | |
28 | text = self.edit_obj.history.next(self.edit_obj.metadata['history']) | |
29 | if text != False: | |
30 | self.edit_obj.set_edit_text(text) | |
31 | return None | |
32 | # Jump to the beginning of the line. | |
33 | elif key in ('ctrl a',): | |
34 | self.edit_obj.set_edit_pos(0) | |
35 | return None | |
36 | # Jump to the end of the line. | |
37 | elif key in ('ctrl e',): | |
38 | self.edit_obj.set_edit_pos(len(self.edit_obj.get_edit_text())) | |
39 | return None | |
40 | # Jump backward one character. | |
41 | elif key in ('ctrl b',): | |
42 | self.edit_obj.set_edit_pos(self.edit_obj.edit_pos - 1) | |
43 | return None | |
44 | # Jump forward one character. | |
45 | elif key in ('ctrl f',): | |
46 | self.edit_obj.set_edit_pos(self.edit_obj.edit_pos + 1) | |
47 | return None | |
48 | # Delete previous character. | |
49 | elif key in ('ctrl h',): | |
50 | if self.edit_obj.edit_pos > 0: | |
51 | self.edit_obj.set_edit_pos(self.edit_obj.edit_pos - 1) | |
52 | self.edit_obj.set_edit_text( | |
53 | self.edit_obj.get_edit_text()[0 : self.edit_obj.edit_pos] | |
54 | + self.edit_obj.get_edit_text()[self.edit_obj.edit_pos + 1 :]) | |
55 | return None | |
56 | # Delete next character. | |
57 | elif key in ('ctrl d',): | |
58 | if self.edit_obj.edit_pos < len(self.edit_obj.get_edit_text()): | |
59 | edit_pos = self.edit_obj.edit_pos | |
60 | self.edit_obj.set_edit_text( | |
61 | self.edit_obj.get_edit_text()[0 : self.edit_obj.edit_pos] + | |
62 | self.edit_obj.get_edit_text()[self.edit_obj.edit_pos + 1 :]) | |
63 | self.edit_obj.set_edit_pos(edit_pos) | |
64 | return None | |
65 | # Transpose characters. | |
66 | elif key in ('ctrl t',): | |
67 | # Can't transpose if there are less than 2 chars | |
68 | if len(self.edit_obj.get_edit_text()) < 2: | |
69 | return None | |
70 | self.edit_obj.set_edit_pos(max(2, self.edit_obj.edit_pos + 1)) | |
71 | new_edit_pos = self.edit_obj.edit_pos | |
72 | ||
73 | edit_text = self.edit_obj.get_edit_text() | |
74 | self.edit_obj.set_edit_text( | |
75 | edit_text[: new_edit_pos - 2] + | |
76 | edit_text[new_edit_pos - 1] + | |
77 | edit_text[new_edit_pos - 2] + | |
78 | edit_text[new_edit_pos :]) | |
79 | self.edit_obj.set_edit_pos(new_edit_pos) | |
80 | return None | |
81 | # Delete backwards to the beginning of the line. | |
82 | elif key in ('ctrl u',): | |
83 | self.edit_obj.set_edit_text(self.edit_obj.get_edit_text()[self.edit_obj.edit_pos :]) | |
84 | self.edit_obj.set_edit_pos(0) | |
85 | return None | |
86 | # Delete forwards to the end of the line. | |
87 | elif key in ('ctrl k',): | |
88 | self.edit_obj.set_edit_text(self.edit_obj.get_edit_text()[: self.edit_obj.edit_pos]) | |
89 | return None | |
90 | # Jump backward one word. | |
91 | elif key in ('meta b',): | |
92 | self.jump_backward_word() | |
93 | return None | |
94 | # Jump forward one word. | |
95 | elif key in ('meta f',): | |
96 | self.jump_forward_word() | |
97 | return None | |
98 | # Delete backwards to the beginning of the current word. | |
99 | elif key in ('ctrl w',): | |
100 | old_edit_pos = self.edit_obj.edit_pos | |
101 | self.jump_backward_word() | |
102 | new_edit_pos = self.edit_obj.edit_pos | |
103 | self.edit_obj.set_edit_text(self.edit_obj.edit_text[: new_edit_pos] | |
104 | + self.edit_obj.edit_text[old_edit_pos :]) | |
105 | self.edit_obj.set_edit_pos(new_edit_pos) | |
106 | return None | |
107 | # Delete forwards to the end of the current word. | |
108 | elif key in ('meta d',): | |
109 | edit_pos = self.edit_obj.edit_pos | |
110 | self.jump_forward_word() | |
111 | self.edit_obj.set_edit_text(self.edit_obj.edit_text[: edit_pos] | |
112 | + self.edit_obj.edit_text[self.edit_obj.edit_pos :]) | |
113 | self.edit_obj.set_edit_pos(edit_pos) | |
114 | return None | |
115 | ||
116 | def jump_backward_word(self): | |
117 | for match in self._word_regex1.finditer( | |
118 | self.edit_obj.edit_text[: self.edit_obj.edit_pos][::-1] | |
119 | ): | |
120 | self.edit_obj.set_edit_pos(self.edit_obj.edit_pos - match.end(1)) | |
121 | return | |
122 | self.edit_obj.set_edit_pos(0) | |
123 | ||
124 | def jump_forward_word(self): | |
125 | for match in self._word_regex2.finditer( | |
126 | self.edit_obj.edit_text[self.edit_obj.edit_pos :] | |
127 | ): | |
128 | if match.start(1) > 0: | |
129 | self.edit_obj.set_edit_pos(self.edit_obj.edit_pos + match.start(1)) | |
130 | return | |
131 | self.edit_obj.set_edit_pos(len(self.edit_obj.edit_text)) | |
132 |
2 | 2 | |
3 | 3 | import tasklib |
4 | 4 | from tasklib.task import Task |
5 | from tasklib.backends import TaskWarriorException | |
5 | 6 | |
6 | 7 | from vit import util |
8 | from vit.exception import VitException | |
7 | 9 | |
8 | 10 | class TaskListModel(object): |
9 | 11 | def __init__(self, task_config, reports, report=None, data_location=None): |
24 | 26 | def active_report(self): |
25 | 27 | return self.reports[self.report] |
26 | 28 | |
29 | def parse_error(self, err): | |
30 | messages = filter(lambda l : l.startswith('Error:'), str(err).splitlines()) | |
31 | return "\n".join(messages) | |
32 | ||
27 | 33 | def update_report(self, report, context_filters=[], extra_filters=[]): |
28 | 34 | self.report = report |
29 | 35 | active_report = self.active_report() |
30 | 36 | report_filters = active_report['filter'] if 'filter' in active_report else [] |
31 | 37 | filters = self.build_task_filters(context_filters, report_filters, extra_filters) |
32 | self.tasks = self.tw.tasks.filter(filters) if filters else self.tw.tasks.all() | |
38 | try: | |
39 | self.tasks = self.tw.tasks.filter(filters) if filters else self.tw.tasks.all() | |
40 | # NOTE: tasklib uses lazy loading and some operation is necessary | |
41 | # for self.tasks to actually be populated here. | |
42 | # See https://github.com/robgolding/tasklib/issues/81 | |
43 | len(self.tasks) | |
44 | except TaskWarriorException as err: | |
45 | raise VitException(self.parse_error(err)) | |
33 | 46 | |
34 | 47 | def build_task_filters(self, *all_filters): |
35 | 48 | def reducer(accum, filters): |
36 | 49 | if filters: |
37 | accum.append('(%s)' % ' '.join(filters)) | |
50 | accum.append('( %s )' % ' '.join(filters)) | |
38 | 51 | return accum |
39 | 52 | filter_parts = reduce(reducer, all_filters, []) |
40 | 53 | return ' '.join(filter_parts) if filter_parts else '' |
88 | 88 | self.indent_subprojects = self.subproject_indentable() |
89 | 89 | self.project_cache = {} |
90 | 90 | # TODO: This is for the project placeholders, feels sloppy. |
91 | self.project_formatter = ProjectFormatter('project', self.report, self.formatter) | |
91 | self.project_formatter = ProjectFormatter('project', self.report, self.formatter, self.get_blocking_task_uuids()) | |
92 | 92 | self.build_rows() |
93 | 93 | self.clean_columns() |
94 | self.has_project_column = self.project_column_present() | |
94 | self.project_column_idx = self.get_project_column_idx() | |
95 | self.reconcile_column_width_for_label() | |
95 | 96 | self.resize_columns() |
96 | self.reconcile_column_width_for_label() | |
97 | 97 | self.build_table() |
98 | 98 | self.listbox.set_focus_position() |
99 | 99 | self.update_focus() |
100 | 100 | |
101 | 101 | def update_header(self, size): |
102 | if self.has_project_column: | |
102 | if self.project_column_idx is not None: | |
103 | 103 | self.update_project_column_header(size) |
104 | 104 | |
105 | def project_column_present(self): | |
106 | for _, column in enumerate(self.columns): | |
105 | def get_project_column_idx(self): | |
106 | for idx, column in enumerate(self.columns): | |
107 | 107 | if column['name'] == 'project': |
108 | return True | |
109 | return False | |
108 | return idx | |
109 | return None | |
110 | 110 | |
111 | 111 | def get_project_from_row(self, row): |
112 | 112 | return row.task['project'] if isinstance(row, SelectableRow) else row.project |
123 | 123 | self.set_project_column_header() |
124 | 124 | |
125 | 125 | def set_project_column_header(self, parents=None): |
126 | column_index = self.task_config.get_column_index(self.report['name'], 'project') | |
127 | if self.has_marker_column(): | |
128 | column_index += 1 | |
126 | column_index = self.project_column_idx | |
129 | 127 | (columns_widget, _) = self.header.original_widget.contents[column_index] |
130 | 128 | (widget, _) = columns_widget.contents[0] |
131 | 129 | label = self.project_label_for_parents(parents) |
220 | 218 | kwargs = self.column_formatter_kwargs() |
221 | 219 | for idx, column_formatter in enumerate(self.report['columns']): |
222 | 220 | name, formatter_class = self.formatter.get(column_formatter) |
223 | self.add_column(name, self.report['labels'][idx], formatter_class(name, self.report, self.formatter, **kwargs)) | |
221 | self.add_column(name, self.report['labels'][idx], formatter_class(name, self.report, self.formatter, self.get_blocking_task_uuids(), **kwargs)) | |
224 | 222 | |
225 | 223 | def is_marker_column(self, column): |
226 | 224 | return column == MARKER_COLUMN_NAME |
314 | 312 | to_adjust.append({'idx': idx, 'width': width}) |
315 | 313 | if total_width > cols: |
316 | 314 | self.adjust_oversized_columns(total_width - cols, to_adjust) |
315 | if to_adjust: | |
316 | # This is called recursively to account for cases when further | |
317 | # reduction is necessary because one or more column's reductions | |
318 | # were limited to REDUCE_COLUMN_WIDTH_LIMIT. | |
319 | self.resize_columns() | |
317 | 320 | |
318 | 321 | def adjust_oversized_columns(self, reduce_by, to_adjust): |
319 | 322 | to_adjust = list(map(lambda c: c.update({'ratio': (c['width'] - REDUCE_COLUMN_WIDTH_LIMIT) / c['width']}) or c, to_adjust)) |
320 | 323 | ratio_total = reduce(lambda acc, c: acc + c['ratio'], to_adjust, 0) |
321 | 324 | to_adjust = list(map(lambda c: c.update({'percentage': c['ratio'] / ratio_total}) or c, to_adjust)) |
322 | 325 | for c in to_adjust: |
323 | # This shouldn't technically be necessary, but there are edge cases | |
324 | # when a single adjusted column is 1 column too wide for the | |
325 | # display, which results in it not being displayed. | |
326 | adjusted_width = c['width'] - (reduce_by + 1 if c['percentage'] == 1 else math.ceil(reduce_by * c['percentage'])) | |
326 | adjusted_width = c['width'] - math.ceil(reduce_by * c['percentage']) | |
327 | 327 | self.columns[c['idx']]['width'] = adjusted_width if adjusted_width > REDUCE_COLUMN_WIDTH_LIMIT else REDUCE_COLUMN_WIDTH_LIMIT |
328 | 328 | |
329 | 329 | def reconcile_column_width_for_label(self): |
364 | 364 | self.header = urwid.AttrMap(list_header, 'list-header') |
365 | 365 | |
366 | 366 | def make_header_column(self, column, is_last, space_between=COLUMN_PADDING): |
367 | total_width = column['width'] + space_between | |
367 | padding_width = 0 if is_last else space_between | |
368 | total_width = column['width'] + padding_width | |
368 | 369 | column_content = urwid.AttrMap(urwid.Padding(urwid.Text(column['label'], align='left')), 'list-header-column') |
369 | 370 | padding_content = self.make_padding(is_last and 'list-header-column' or 'list-header-column-separator') |
370 | columns = urwid.Columns([(column['width'], column_content), (space_between, padding_content)]) | |
371 | columns = urwid.Columns([(column['width'], column_content), (padding_width, padding_content)]) | |
371 | 372 | return (total_width, columns) |
372 | 373 | |
373 | 374 | def make_padding(self, display_attr): |
440 | 441 | if self.on_select: |
441 | 442 | key = self.on_select(self, size, key) |
442 | 443 | return key |
443 | ||
444 | # ... and update the displayed items. | |
445 | for t, (w, _) in zip(contents, self._columns.contents): | |
446 | w.set_text(t) | |
447 | 444 | |
448 | 445 | class ProjectPlaceholderRow(urwid.WidgetWrap): |
449 | 446 | """Wraps 'urwid.Columns' for a project placeholder row. |