Skip to content
Commits on Source (5)
  • Michał Góral's avatar
    Implement built-in help · b5351cb3
    Michał Góral authored
    Issue #33
    b5351cb3
  • Michał Góral's avatar
    Merge branch 'builtin-help' into 'master' · 597a05e1
    Michał Góral authored
    Implement built-in help
    
    See merge request !14
    597a05e1
  • Michał Góral's avatar
    New format markup · b84cce9b
    Michał Góral authored
    b84cce9b
  • Michał Góral's avatar
    Merge branch 'new-format-markup' into 'master' · a0ccaf66
    Michał Góral authored
    New format markup
    
    This changes the HTML-like markup to a simpler and more maintainable list-like markup. Reasoning behind it is that HTML parser was buggy, hard to maintain, and had many corner cases. Additionally, from user perspective, syntax was hard to read and reason about. Users had to use many obscure HTML tags to achieve small effects.
    
    This is a big, breaking change. Here follows the list of breakages:
    - the format itself (obviously)
    - `block.title` has hardcoded format string
    - `c.add_block` `format` parameter changes its name to `items`
    - drop-in styles support is removed; all styles used in new format strings must be named and predefined.
    
    See merge request !15
    a0ccaf66
  • Michał Góral's avatar
    Release 0.9.0 · 9083bb23
    Michał Góral authored
    9083bb23
image: python:3.5
image: python:3.7
stages:
- test
......@@ -27,6 +27,10 @@ py37:
<<: *test
image: python:3.7
py38:
<<: *test
image: python:3.8
lint:
<<: *test
......
......@@ -13,7 +13,8 @@ disable=fixme,
bare-except,
missing-docstring,
invalid-name,
subprocess-run-check
subprocess-run-check,
duplicate-code
[BASIC]
attr-rgx=[a-z_][a-z0-9_]{0,30}$
......
twc (0.9.0)
* Breaking: new formatting markup which replaces old HTML-like markup.
* Breaking: agenda's block title has now hardcoded format
(c.add_block(title='...') accepts unformatted string.
* Breaking: c_add_block `format` parameter is renamed to to `items` to
reflect new formatting markup.
* Breaking: removed support for drop-in styles in formatting; all styles must
be now named and predefined with c.set_style.
* Added "flags" item to available formatting items, which shows
flags/indicators about presence of task's attributes (e.g. presence of
annotations, tags etc.)
* Added built-in help page (by default available under F1 keybinding).
-- Michał Góral <dev@goral.net.pl> Sat, 28 Mar 2020 16:18:02 +0100
twc (0.8.0)
* Support for Python 3.8
......
......@@ -9,7 +9,7 @@ pytest-cov = "*"
pytest-xdist = "*"
pylint = "*"
flake8 = "*"
twine = "==1.13.0"
twine = "*"
[packages]
twc = {path = ".",editable = true}
......
......@@ -8,7 +8,8 @@ terminal frontend for task and TODO manager - TaskWarrior.
:align: center
For full documentation please refer to the `User Manual
<https://mgoral.gitlab.io/twc/>`_.
<https://mgoral.gitlab.io/twc/>`_. There's also built-in help available
after pressing ``F1``.
Features
~~~~~~~~
......@@ -19,7 +20,7 @@ Features
* bulk edits: select arbitrary tasks and modify them all at the same time
* autocomplete and tab-complete writing task descriptions, annotations, tags
etc.
* styling and task formatting (with HTML-like markup)
* styling and task formatting
* tasks and sub-tasks grouping (influenced by
`taskwiki <https://github.com/tbabej/taskwiki>`_)
* synchronize tasks with task server
......@@ -55,7 +56,7 @@ To add agenda, first create a configuration file inside
title='Projects',
filter='-WAITING and (+BLOCKING or +BLOCKED) and -INSTANCE',
sort='project-,priority-,order+,urgency-',
format='* {description}<info>{tags}</info>')
items='* description,tags:info:')
Style and colors
~~~~~~~~~~~~~~~~
......@@ -76,29 +77,36 @@ Style examples:
Any style name can be used in task formatting. Some interface elements however
use specific style names.
Task Format
~~~~~~~~~~~
Formatting
~~~~~~~~~~
Block's ``items`` and ``statusleft`` and ``statusright`` parameters are
composed of lists of displayed items; they can be separated by comma, which
will produce space between items, or by "+" sign, which will concatenate items
to each other without leaving space between them.
Block's format (``format``) is a mix of `Python's string format
<https://docs.python.org/3/library/string.html#formatspec>`_ and HTML-like
markup.
Each item can be optionally followed by a name of style which should be applied
to this item and item-specific string formatting. When style or formatting are
added, they must be separated and ended by a colon ":": ``name:style:format:``.
You can use any TaskWarrior's attribute name as format's placeholder and it will
be displayed if present.
All TaskWarrior's attribute names work as item names and there are some
additional names defined for either blocks and status line.
.. code:: html
Example format strings are:
.. code:: python
<sr left=" ["> right="] ">{id}</sr>{description}
items = '[priority:warning:],(due:comment:%Y-%m-%d:),description'
items = '[flags::%a%s%d:]+id,description,tags:info:
Some additional markup can be added to the tasks. The following tags are
available:
Items will be only displayed when they are present. For example if there are no
tags defined for a task, ``tags`` item will not produce any output.
* ``<sr left="[", right="]>text</sr>``: surrounds text with *left* and *right*.
* ``<ind value="A">text</ind>``: if there is any text inside a tag, it will be
replaced with *value*. It's particularily useful for indicating that some
task's property is present, without displaying it (like long list of
annotations):
``<sr left="[" right="]"><ind value="A">{annotations}</ind></sr>``
Items might contain additional characters in place of their names and TWC will
try to intelligently/magically (with regular expressions ;)) guess name. These
additional charactes will be printed only when item is present so they can be
used e.g. to visually delimit some items from the others (e.g. surround tags
with braces, delimit items with "|" etc.)
Key bindings
~~~~~~~~~~~~
......@@ -113,19 +121,19 @@ a list of commands and other default key bindings.
Status line
~~~~~~~~~~~
Bottom status line can display arbitrary informations and is configurable by
Bottom status line displays arbitrary informations and is configurable by
two variables: ``statusleft`` and ``statusright``. They describe format similar
to the one described in `Task Format`_ The main difference is that task
attributes are referenced by ``{task.<attribute>}`` placeholder and that there
to the one described in `Formatting`_ The main difference is that task
attributes are referenced by ``task.<attribute>`` placeholder and that there
additional placeholders available.
.. code:: python
c.set('statusleft', '{COMMAND} {task.id}')
c.set('statusright', '<ind value=A>{task.annotations}</ind>')
c.set('statusleft', 'COMMAND,task.id')
c.set('statusright', 'flags::%a:')
Status line placeholders also include: ``taskrc``, ``command``, ``COMMAND``,
``agenda.pos``, ``agenda.size``, ``agenda.ppos``.
Status line items also include: ``taskrc``, ``command``, ``COMMAND``,
``agenda.pos``, ``agenda.size``, ``agenda.ppos``, ``flags``.
Installation
~~~~~~~~~~~~
......
......@@ -31,14 +31,7 @@ contains all necessary methods to do so.
.Example config.py
[source,python]
----
def title(s):
return '<heading>== {} ==</heading>'.format(s)
next_fmt = \
'<sr right=" "><warning>{priority}</warning></sr>' \
'{description} ' \
'<comment><sr left="[" right="] "><ind value=A>{annotations}</ind></sr>{id}</comment>' \
'<sr left=" "><info>{tags}</info></sr>'
displayed_items = 'priority:warning:,description,[flags:comment:%a:],id:comment:,tags:info:
c.set('incsearch', False)
c.set('taskrc', '~/other_taskrc')
......@@ -48,16 +41,16 @@ c.set_style('comment', 'fg:lightblue bold')
c.add_block(
agenda='Next tasks',
title=title('Tasks with tags'),
title='Tasks with tags',
filter='-WAITING -BLOCKING -BLOCKED +PENDING (+tag1 or +tag2)',
fmt=next_fmt,
items=displayed_items,
sort='urgency-,priority')
c.add_block(
agenda='Next tasks',
title=title('Inbox'),
title='Inbox',
filter='( status:pending tags.is: )',
fmt=next_fmt)
items=displayed_items)
----
// tag::manpage[]
......@@ -74,48 +67,80 @@ with *c.add_block()* function, which requires specifying parent agenda of block.
Apart from _agenda_, all parameters passed to *c.add_block()* must be named,
i.e. given in form of _name=value_, like: _sort='urgency'_, _limit=5_ etc.
[[config-tasks-format]]
==== Tasks Format
[[formatting]]
=== Formatting
Format of tasks displayed inside a block is a mix of {formatspec}[Python's
string format] and HTML-like markup.
Block's `items` and `statusleft` and `statusright` parameters are composed of
lists of displayed items; they can be separated by comma, which produces space
between items, or by "+" sign, which concatenates items to each other without
leaving space between them.
You can use any TaskWarrior's attribute name (includeing UDAs) as
format's placeholder and it will be displayed if it is present in a particular
task.
Each item can optionally be followed by a name of style which will be applied
to it and item-specific string formatting. When style of formatting are added,
they must be separated and ended by a colon ":".
----
{id} -- {description} -- {tags} {priority} {customuda}
formatting = 'item_name:style:item_fmt:'
many_items = 'item1,item2+item3
----
Items are displayed only when they are present (not empty). For example if
there are no tags defined for a specific task, `tags` item will not produce any
output.
Items might contain additional characters in place of their names. TWC
recognizes item name in this case. These characters are not independent however:
they will also be printed only when item is present. They can be used e.g.
to visually delimit some items from the others (e.g. by surrounding tags with
braces).
You can use any TaskWarrior's attribute name (including UDAs).
Some examples:
----
items = '[priority:warning:],due:info:%Y-%m-%d:,description,id:comment:'
items = 'id+[flags::%a],description:info:,customuda:comment:'
----
[NOTE]
For some types TWC returns pre-formatted strings. For example, all lists (like
list of tags or annotations) will have their elements separated by a colon.
Some additional markup can be added. The following tags are available:
==== Agenda Block Items
- `<sr left="[" right="]">text</sr>` - surrounds text with _left_ and _right_;
- `<ind value="A">text</ind>` - if there is any text inside a tag, it is
replaced with _value_. It is particularily useful for indicating that some
attribute is present without displaying it (like long list of annotations):
`<ind value="A">{annotations}</ind>`;
- any style name: `<somestyle>text</somestyle>`. See <<styles>> section for
additional informations.
The following items are available inside block (by calling
`c.add_block(items="...")`):
Markup elements (surrounding text, indicators) won't be added if there is no
text inside the tags. For example, if task has no annotations, `<ind
value="A">{annotations}</ind>` won't display "A" indicator.
- any TaskWarrior's attribute name (including UDAs)
- _flags_ - list of flags/indicators of presence of certain attributes which
normally might be not disired to be displayed in full (like long annotations)
[TIP]
====
This can be used to conditionally insert separators (e.g. spaces) between task
attributes. For example `<sr>` can be used to add a space only after a priority
and nothing when task doesn't have a priority set:
==== Status Line Items
----
<sr right=" ">{priority}</sr>{description}
----
====
- _task.<attr>_ - any TaskWarrior's attribute name, but they must be preceded by
_task._ string
- _flags_ - list of flags/indicators of presence of certain task attributes
- _taskrc_ - path of currently used taskrc
- _command_ - name of current command, when command line is active (e.g. add,
modify, annotate,...)
- _COMMAND_ - same as before, but command is UPPER CASED
- _task.<attribute>_ - any attribute of currently highlighted task
- _agenda.pos_ - position of highlighted item
- _agenda.size_ - size of current agenda
- _agenda.ppos_ - percentage position of highlighted item
==== Items Formatting
* all ordinary types can be formatted by using Python's {formatspec}[Format
Specification Mini-Language]
* date/times (_due_, _scheduled_ attributes) can be formatted with ordinary
strftime-like formatting, for example _%Y-%m-%d %H:%M_.
* _flags_ accepts any combination of the following strings, for example _%a%d_:
** _%a_: annotations indicator
** _%d_: due indicator
** _%s_: scheduled indicator
** _%t_: tags indicator
[[key-bindings]]
=== Key Bindings
......@@ -171,6 +196,12 @@ which allows selecting which URLs should be opened.
+
Default bindings: kbd:[f].
*help*::
Displays built-in short help reference.
+
Default bindings: kbd:[F1].
==== Navigation
*scroll.down*::
......@@ -369,20 +400,8 @@ Default: _True_.
*statusright*::
Formattings of status lines. *statusleft* contains elements aligned to the left
and *statusright* - to the right. Status line is disabled when both of these
settings are disabled (set to empty strings).
+
Status line format is similar to <<config-tasks-format>>. Main difference is
that task attributes are referenced by _{task.<attribute>}_, e.g.
"{task.description}". Additionally, there are more placeholders available:
+
- {taskrc} - path of currently used taskrc
- {command} - name of current command, when command line is active (e.g. add,
modify, annotate,...)
- {COMMAND} - same as before, but command is UPPER CASED
- {task.<attribute>} - any attribute of currently highlighted task
- {agenda.pos} - position of highlighted item
- {agenda.size} - size of current agenda
- {agenda.ppos} - percentage position of highlighted item
settings are disabled (set to empty strings). See <<formatting>> section for
details about formatting strings.
[[settings-taskrc]]
*taskrc*::
......@@ -447,34 +466,21 @@ Such styles can be used e.g. to change appearence of tasks or status line.
----
c.set_style('mystyle', 'fg:#EEEEEE bg:black bold')
c.set('statusright', '<mystyle>{task.id}</mystyle>')
c.add_block(...
format='<mystyle>{description}</mystyle>')
----
To only change colors (but not other appearence options, like blinking or color
reversing) you can also use drop-in unnamed styles. To do that use any tag with
_fg_, _color_ or _bg_ attributes.
.Unnamed styles
[source,python]
----
c.set_style('mystyle', 'fg:#EEEEEE bg:black bold')
c.set('statusright', '<mystyle>{task.id}</mystyle>')
c.add_block(...
format='<blah fg=blue bg="#999333">{description} </blah>')
c.set('statusright', 'task.id:mystyle:')
c.add_block(..., items='description:mystyle:')
----
=== Configuration Reference
[[add_block]]
*c.add_block(agenda, *, title, format='{description}', filter=None, sort=None, limit=None)*::
*c.add_block(agenda, *, title, items='description', filter=None, sort=None, limit=None)*::
Adds a new block to a given _agenda_, which will be created if it doesn't exist.
+
Block contains a _title_ which is displayed above all of its tasks. Tasks
formatting is described by _format_ string (see <<config-tasks-format>> section
for details). By default only raw task description is displayed.
Block contains a _title_ which is displayed above all of its tasks. Block
titles are automatically styled with `heading` style.
Task formatting is described by _items_ string (see <<formatting>> section for
details). By default only raw task description is displayed.
+
When given, _sort_ parameter decides order of tasks inside block. It is
compatible with TaskWarrior's reports sorting. It iss defined by a
......@@ -496,7 +502,7 @@ is applied after sorting. By default number of tasks is not limited.
c.add_block(
agenda="My Agenda",
title="All tasks",
format='<sr right=" ">{id}</sr>{description}',
items='id,description',
limit=20)
----
......@@ -526,7 +532,7 @@ section for a list and description of available settings.
+
.Example
----
c.set('statusleft', '{COMMAND} {task.id}')
c.set('statusleft', 'COMMAND,task.id')
c.set('ignorecase', False)
----
......
......@@ -18,22 +18,15 @@
'''User commands'''
from prompt_toolkit.key_binding import KeyBindings
from twc.utils import cancel_all_async_tasks
from twc.utils import cancel_all_async_tasks, event_to_controller
from twc.help import helptext
from twc.widgets.text import TextView
from twc.conditions import (
is_normal_state,
)
def event_to_controller(fn):
def _new_fn(event):
controller = event.app.controller()
if not controller:
return None
return fn(controller)
return _new_fn
def global_bindings(cfg):
kb = KeyBindings()
......@@ -43,4 +36,11 @@ def global_bindings(cfg):
cancel_all_async_tasks()
event.app.exit()
@cfg.command_handler('help', kb, filter=is_normal_state)
@event_to_controller
def _(controller):
text = helptext(cfg.commands)
tv = TextView(text.splitlines(), cfg)
controller.push(tv)
return kb
......@@ -30,7 +30,7 @@ from twc.settings import Settings
@attr.s
class Block:
title = attr.ib()
format = attr.ib('{description}')
items = attr.ib('description')
filter = attr.ib(None)
sort = attr.ib(None)
limit = attr.ib(None)
......@@ -98,6 +98,8 @@ class Config:
self.bind('S', 'task.synchronize')
self.bind('space', 'task.select')
self.bind('f1', 'help')
def add_block(self, agenda, **kwargs):
'''Adds a new block to specified agenda. Keywords are passed directly
to Block constructor. Agenda is created if it doesn't exist yet.'''
......@@ -114,6 +116,10 @@ class Config:
found = self.agendas[agenda]
found.blocks.append(Block(**kwargs))
@property
def commands(self):
return self._commands
@property
def style(self):
return self._style
......@@ -195,31 +201,31 @@ def _bind_preprocess(key):
def _create_default_blocks(cfg):
cfg.add_block(
'Summary',
title='<heading>== Next tasks ==</heading>',
title='Next tasks',
filter='status:pending',
format='{description}<sr left=" "><warning>{priority}</warning></sr><sr left=" "><info>{tags}</info></sr>', # pylint: disable=line-too-long
items='description,priority:warning:,tags:info:',
sort='priority-,urgency-')
cfg.add_block(
'Summary',
title='<heading>== Recently completed ==</heading>',
title='Recently completed',
filter='status:completed',
format='{description}',
items='description',
sort='end-',
limit=10)
cfg.add_block(
'Waiting',
title='<heading>== Waiting tasks ==</heading>',
title='Waiting tasks',
filter='-COMPETED +WAITING',
format='{description}')
items='description')
cfg.add_block(
'Scheduled',
title='<heading>== Scheduled tasks ==</heading>',
title='Scheduled tasks',
filter='-COMPLETED and (+DUE or +SCHEDULED)',
sort='due,scheduled',
format='{description}<sr left=" (sch: " right=")">{scheduled:%Y-%m-%d}</sr><sr left=" (due: " right=")">{due:%Y-%m-%d}</sr>') # pylint: disable=line-too-long
items='description,[scheduled::%Y-%m-%d:],(due::%Y-%m-%d:)')
def config_path():
......
# Copyright (C) 2020 Michał Góral.
#
# This file is part of TWC
#
# TWC is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TWC is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
HEADING_MARKER = ('[heading]', '')
NL = ('', '\n')
# Copyright (C) 2020 Michał Góral.
#
# This file is part of TWC
#
# TWC is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. TWC is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
# Keep this text width <80 characters
_HELP_TEXT = '''
TWC is an interactive interface for TaskWarrior: a task/todo management
application.
This is a built-in short reference. It is not a comprehensive documentation
(see manual under link below or docs directory if source tree).
Full documentation: https://mgoral.gitlab.io/twc
Source code: https://gitlab.com/mgoral/twc
Configured keys
---------------
{keys}
Configuration
-------------
TWC follows XDG Base Directory Specification for finding configuration files.
Typically it means that you should create ~/.config/twc/config.py configuration
file.
Configuration files are ordinary Python scripts which are loaded with exposed
variable "c", which should be used to configure all aspects of TWC.
config.py example:
# set some settings via c.set()
c.set('incsearch', False)
c.set('taskrc', '~/other_taskrc')
# change appearence of twc
c.set_style('comment', 'fg:lightblue bold')
# change default bindings
c.bind('r', 'refresh')
c.bind('d d', 'task.delete')
# Set up a single agenda with two blocks (TaskWarrior queries/filters).
# Agendas are created automatically when a block is assigned to any
# unexisting agenda with c.add_block(agenda='<name>').
displayed_items = 'priority:warning:,description,id:comment:,tags:info:'
c.add_block(
agenda='My Tasks',
title='Tasks with tag1 or tag2',
filter='-WAITING -BLOCKING -BLOCKED +PENDING (+tag1 or +tag2)',
items=displayed_items,
sort='urgency-,priority')
c.add_block(
agenda='My Tasks',
title='Inbox',
filter='( status:pending tags.is: )',
items=displayed_items)
'''
def _revert_alt(keys):
'''TWC automatically changes a-key to a sequence of esc + key, because
that's how shells work. We have to revert this, because it's internal
implementation detail.'''
if len(keys) > 1 and keys[0] == 'escape':
return 'a-{}'.format(''.join(keys[1:]))
return keys
def _formatted_keylist(commands):
listing = []
for cmd in sorted(commands):
bindings = ', '.join(''.join(_revert_alt(seq)) for seq in commands[cmd])
listing.append(' {:.<27} {}'.format(cmd + ' ', bindings))
return listing
def helptext(commands):
listing = _formatted_keylist(commands)
return _HELP_TEXT.format(keys='\n'.join(listing))
......@@ -15,138 +15,83 @@
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
'''Custom markup processor.
Each tag receives a list of formatted text tuples and modifies this list
in-place. That list is committed to processor's output markup when
appropriate.'''
import functools
from collections import deque
from html.parser import HTMLParser
import attr
from twc.utils import eprint
from twc.locale import tr
@attr.s
class _Any:
'''Generic handling of any markup element'''
tag = attr.ib() # used tag name
fg = attr.ib(None)
bg = attr.ib(None)
color = attr.ib(None) # alias for fg
def process(self, data):
for i, elem in enumerate(data):
fmt, text = elem
new = self._process(fmt, text)
data[i] = new
# this tries to mimick behaviour of prompt-toolkit's HTML() class
def _process(self, fmt, text):
style = []
if fmt:
style.append(fmt)
if self.fg or self.color or self.bg:
if self.fg:
style.append('fg:{}'.format(self.fg))
elif self.color:
style.append('fg:{}'.format(self.color))
if self.bg:
style.append('bg:{}'.format(self.bg))
else:
style.append('class:{}'.format(self.tag))
return (' '.join(style), text)
'''Custom markup processor.'''
import re
import attr
@attr.s
class _Sr:
'''Elements surround: <sr left=[ right=]>'''
left = attr.ib('')
right = attr.ib('')
__tagname__ = 'sr'
def process(self, data):
if len(data) == 1 and not data[0][0]:
# single element, with no previous formatting:
# <sr>text</sr>
text = '{0}{1}{2}'.format(self.left, data[0][1], self.right)
data[0] = ('', text)
elif data:
data.appendleft(('', self.left))
data.append(('', self.right))
_SEPS = re.compile(r'(,|\+)')
_FMT = re.compile(r'(\w[\w.]*(?::.*:)?)')
@attr.s
class _Ind:
'''Replaces inner text with an indicator: <ind value=A>{annotation}</ind>'''
value = attr.ib()
class Parser:
'''Parser of task format markup'''
_substs = attr.ib()
_markup = attr.ib(factory=list)
_nextsep = attr.ib(None)
__tagname__ = 'ind'
@property
def markup(self):
return self._markup
def process(self, data):
data_empty = all(not elem[1] for elem in data)
data.clear()
if not data_empty:
data.append(('', self.value))
def parse(self, text):
if not text:
return
tokens = _SEPS.split(text)
for token in tokens:
if token == ',':
self._nextsep = ' '
elif token == '+':
self._nextsep = None
else:
self._process_entry(token)
def _add(self, text, style=''):
if not text:
return
_tag_types = (_Sr, _Ind,)
_tag_handlers = {cls.__tagname__: cls for cls in _tag_types}
if style:
style = 'class:{}'.format(style)
if self._nextsep and self._markup:
self._markup.append(('', self._nextsep))
# TODO: Handle incorrect HTML (mgoral, 2019-04-24)
# pylint: disable=abstract-method
@attr.s
class Parser(HTMLParser):
'''Parser of task format markup'''
_tags = attr.ib(factory=deque)
_markup = attr.ib(factory=list)
self._markup.append((style, text))
def __attrs_post_init__(self):
super().__init__()
def _process_entry(self, text):
match = _FMT.search(text)
if not match:
return
@property
def markup(self):
return self._markup
name = match.group(1)
def handle_starttag(self, tag, attrs):
any_ctor = functools.partial(_Any, tag)
ctor = _tag_handlers.get(tag, any_ctor)
key, _, opts = match.group(1).partition(':')
style, _, spec = opts.rstrip(':').partition(':')
kwds = {}
kwds.update(attrs)
self._tags.append(ctor(**kwds))
if not spec:
name = '{{{}}}'.format(key)
else:
name = '{{{}:{}}}'.format(key, spec)
def handle_endtag(self, tag):
if not self._tags:
eprint(tr('Unexpected end tag: </{}>'.format(tag)))
try:
formatted = name.format_map(self._substs)
except KeyError:
return
ctor = _tag_handlers.get(tag, _Any)
last = self._tags[-1]
if isinstance(last, _Any) and last.tag == tag:
self._tags.pop()
elif isinstance(last, ctor):
self._tags.pop()
def handle_data(self, data):
formatted = ('', data)
if not self._tags:
self.markup.append(formatted)
else:
current = deque([formatted])
for t in reversed(self._tags):
t.process(current)
self._markup.extend(current)
if formatted:
mstart, mend = match.span(1)
processed = '{}{}{}'.format(text[:mstart], formatted, text[mend:])
self._add(processed, style)
def parse_html(text):
parser = Parser()
parser.feed(text)
def format_map(text, substitutions):
parser = Parser(substitutions)
parser.parse(text)
return parser.markup
def format_(text, **kw):
return format_map(text, kw)
......@@ -82,12 +82,8 @@ class Settings:
autohelp = setting(True)
# Status line formattings
statusleft = setting(
'<status.1><sr left=" " right=" ">{COMMAND}</sr></status.1>'
'<sr left=" " right=" "><text>{taskrc}</text></sr>')
statusright = setting(
'<status.2><sr left=" ID:" right=" ">{task.id}</sr></status.2>'
'<sr left=" " right=" ">{agenda.ppos}%</sr>')
statusleft = setting('COMMAND:status.1:,taskrc:text:')
statusright = setting('task.id:status.2:,agenda.ppos%')
# Enable incremental search
incsearch = setting(True)
......
......@@ -25,6 +25,19 @@ from prompt_toolkit.application.current import get_app
from prompt_toolkit.formatted_text import to_formatted_text
class CombinedDict:
def __init__(self, *dicts):
self._dicts = dicts
def __getitem__(self, key):
for dct in self._dicts:
try:
return dct[key]
except KeyError:
pass
raise KeyError(key)
def pprint(text):
'''Print some text in prompt_toolkit-friendly way.'''
if not text:
......@@ -97,3 +110,12 @@ def aprint(text, delay=0.42, style=''):
def cancel_all_async_tasks():
for task in asyncio.Task.all_tasks():
task.cancel()
def event_to_controller(fn):
def _new_fn(event):
controller = event.app.controller()
if not controller:
return None
return fn(controller)
return _new_fn
......@@ -20,3 +20,5 @@ from twc.widgets.commandline import CommandLine
from twc.widgets.tabs import Tabline
from twc.widgets.statusline import StatusLine
from twc.widgets.messagebox import MessageBox
from twc.widgets.text import TextView
from twc.widgets.urls import OpenUrls
......@@ -20,7 +20,6 @@ import concurrent.futures
import itertools
import re
import uuid
import webbrowser
import attr
from tasklib.backends import TaskWarriorException
......@@ -34,28 +33,30 @@ from prompt_toolkit.application import run_in_terminal
import twc.markup as markup
import twc.twutils as twutils
import twc.signals as signals
from twc.commands import event_to_controller
from twc.widgets.text import TextView
from twc.widgets.formatters import TaskFlags
from twc.widgets.urls import OpenUrls
from twc.locale import tr
from twc.utils import pprint, eprint, aprint
from twc.consts import HEADING_MARKER, NL
from twc.utils import pprint, eprint, aprint, event_to_controller, CombinedDict
from .completions import task_add, task_modify, annotations
_HEADING_MARKER = ('[heading]', '')
_NL = ('', '\n')
@attr.s
class _CacheEntry:
_text = attr.ib(None)
_task = attr.ib(None)
_fmt = attr.ib(None)
_indent = attr.ib('')
@property
def text(self):
if self._text:
return self._text
if self._task and self._fmt:
text = self._fmt.format_map(self._task)
self._text = markup.parse_html(text)
self._text = [('', self._indent)] if self._indent else []
cd = CombinedDict(TaskFlags(self._task.t), self._task)
self._text.extend(markup.format_map(self._fmt, cd))
return self._text
return ''
......@@ -69,150 +70,16 @@ class _CacheEntry:
self._text = None
class OpenUrls:
def __init__(self, task, cfg, controller):
self.cfg = cfg
self._pos = 0
self.choices = _extract_urls(task)
if not self.choices:
eprint(tr('No URL found in selected task.'))
return
if len(self.choices) == 1:
self.open(self.choices[0])
return
self.control = FormattedTextControl(
self._get_text_fragments,
get_cursor_position=lambda: Point(0, self.pos),
key_bindings=self.keys(),
focusable=True,
show_cursor=False)
self.window = Window(
content=self.control,
scroll_offsets=ScrollOffsets(top=1, bottom=1))
controller.push(self)
@classmethod
def open(cls, url):
pprint('Opening URL.')
webbrowser.open(url, new=2)
def keys(self):
kb = KeyBindings()
@self.cfg.command_handler('quit', kb)
@event_to_controller
def _(controller):
if controller.stack and controller.stack[-1] is self:
controller.pop()
@self.cfg.command_handler('scroll.begin', kb)
def _(event):
self.pos = 0
@self.cfg.command_handler('scroll.end', kb)
def _(event):
self.pos = len(self.choices) - 1
@self.cfg.command_handler('scroll.down', kb)
def _(event):
self.pos += 1
@self.cfg.command_handler('scroll.up', kb)
def _(event):
self.pos -= 1
@self.cfg.command_handler('activate', kb)
def _(event):
url = self.choices[self.pos]
self.open(url)
return kb
@property
def pos(self):
return self._pos
@pos.setter
def pos(self, val):
if 0 <= val < len(self.choices):
self._pos = val
def _get_text_fragments(self):
result = []
for i, url in enumerate(self.choices):
style = ''
if i == self.pos:
style = 'class:highlight'
ft = to_formatted_text(url, style=style)
result.extend(ft)
result.append(_NL)
return result
def __pt_container__(self):
return self.window
class TaskDetails:
class TaskDetails(TextView):
def __init__(self, task, tw, cfg):
self.task = task
self.tw = tw
self.cfg = cfg
result = twutils.execute_command(self.tw, 'info', flt=self.task)
self.text = result.verified_out()
self._pos = 0
self.control = FormattedTextControl(
self._get_text_fragments,
get_cursor_position=lambda: Point(0, self.pos),
key_bindings=self.keys(),
focusable=True,
show_cursor=False)
self.window = Window(
content=self.control,
scroll_offsets=ScrollOffsets(top=1, bottom=1),
wrap_lines=True)
super().__init__(result.verified_out(), cfg)
def keys(self):
kb = KeyBindings()
@self.cfg.command_handler('quit', kb)
@event_to_controller
def _(controller):
if controller.stack and controller.stack[-1] is self:
controller.pop()
@self.cfg.command_handler('scroll.down', kb)
def _(event):
info = self.window.render_info
height = info.window_height
vs = info.vertical_scroll
# substract 1 due to scroll offsets
self.pos = vs + height - 1
@self.cfg.command_handler('scroll.up', kb)
def _(event):
info = self.window.render_info
vs = info.vertical_scroll
# don't add 1 due to scroll offsets
self.pos = vs
@self.cfg.command_handler('scroll.begin', kb)
def _(event):
self.pos = 0
@self.cfg.command_handler('scroll.end', kb)
def _(event):
self.pos = len(self.text) - 1
kb = super().keys()
@self.cfg.command_handler('followurl', kb)
@event_to_controller
......@@ -221,26 +88,6 @@ class TaskDetails:
return kb
@property
def pos(self):
return self._pos
@pos.setter
def pos(self, val):
if 0 <= val < len(self.text):
self._pos = val
def _get_text_fragments(self):
result = []
for line in self.text:
ft = to_formatted_text(line)
result.extend(ft)
result.append(_NL)
return result
def __pt_container__(self):
return self.window
def extract(block, tw):
tasks = twutils.filter_tasks(block.filter, tw)
......@@ -256,20 +103,19 @@ def _process_tasks(tasks, fmt):
markups = []
for task in twutils.dfs(list(tasks.values())):
indent = ' ' * task.depth * 2
entry_fmt = indent + fmt
entry = _CacheEntry(task=task, fmt=entry_fmt)
entry = _CacheEntry(task=task, fmt=fmt, indent=indent)
markups.append(entry)
return markups
def _process_block(block, tw):
cache = []
heading = [_HEADING_MARKER] + markup.parse_html(block.title)
heading = [HEADING_MARKER] + \
markup.format_('== title:heading: ==', title=block.title)
cache.append(_CacheEntry(text=heading))
tasks = extract(block, tw)
task_cache = _process_tasks(tasks, block.format)
task_cache = _process_tasks(tasks, block.items)
cache.extend(task_cache)
return cache
......@@ -825,7 +671,7 @@ class AgendaView:
return (0 <= pos < len(self._cache)
and self._cache[pos]
and self._cache[pos].text
and self._cache[pos].text[0] == _HEADING_MARKER)
and self._cache[pos].text[0] == HEADING_MARKER)
def search(self, searched, forward=True, curr_line=True):
# curr_line includes current line in search: only adds/substracts
......@@ -898,7 +744,7 @@ class AgendaView:
# prompt_toolkit's cursor position, as it introduces inconsistency
# with self.pos. Cache doesn't have bare-newline entries.
if not task and self.is_heading(i):
result.append(_NL)
result.append(NL)
cpos_add += 1
if task and task['status'] in ('completed', 'deleted'):
......@@ -909,7 +755,7 @@ class AgendaView:
ft = to_formatted_text(ft, style='class:highlight')
result.extend(ft)
result.append(_NL)
result.append(NL)
return result
......@@ -936,11 +782,3 @@ def _extract_uuid(stdout):
if not _is_uuid(match.group(1)):
return None
return match.group(1)
def _extract_urls(task):
expr = re.compile(r'(https?://[^\s]+)')
urls = expr.findall(task['description'])
for ann in task.t['annotations']:
urls.extend(expr.findall(ann['description']))
return urls
......@@ -28,7 +28,7 @@ from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.history import InMemoryHistory
import twc.signals as signals
from twc.commands import event_to_controller
from twc.utils import event_to_controller
class CommandHistory(InMemoryHistory):
......
# Copyright (C) 2020 Michał Góral.
#
# This file is part of TWC
#
# TWC is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TWC is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
import re
import attr
from twc.utils import eprint
from twc.locale import tr
@attr.s
class TaskFlags:
@attr.s
class _FlagInfo:
attrname = attr.ib()
char = attr.ib()
_task = attr.ib()
_FLAGMAP = {
'%a': _FlagInfo('annotations', 'A'),
'%d': _FlagInfo('due', 'D'),
'%s': _FlagInfo('scheduled', 'S'),
'%t': _FlagInfo('tags', 'T'),
}
def __getitem__(self, name):
# A hack to avoid passing flags wrapped in a one element dict
if name != 'flags':
raise KeyError(name)
return self
def __format__(self, spec):
if not self._task:
return ''
if not spec:
spec = '%s%d%a'
return re.sub(r'(%[a-z])', self._flag, spec)
def _flag(self, matchobj):
flag = matchobj.group(1)
info = self._FLAGMAP.get(flag)
if info is None:
eprint(tr('Invalid flag format: {}'.format(flag)))
return ''
if self._task[info.attrname]:
return info.char
return ''
class _TaskAttrGetter:
def __init__(self, task, default=''):
self._t = task
self._default = ''
def __getattr__(self, name):
if name.startswith('_'):
return self.__dict__[name]
if not self._t:
return self._default
return self._t[name]
@attr.s
class AgendaStatus:
_pos = attr.ib(0) # highlighted item's position
size = attr.ib(0) # size of current agenda
@property
def pos(self):
return self._pos
@pos.setter
def pos(self, new):
self._pos = new + 1
@property
def ppos(self):
if self.size < 1:
return 0
return 100 * self.pos // self.size
@attr.s
class Statuses:
taskrc = attr.ib('') # path to used taskrc
command = attr.ib('') # current command
_task = attr.ib(None) # highlighted task
_agenda = attr.ib(factory=AgendaStatus) # current agenda status
@property
def task(self):
return _TaskAttrGetter(self._task)
@task.setter
def task(self, task):
self._task = task
@property
def agenda(self):
return self._agenda
@agenda.setter
def agenda(self, new):
self._agenda.pos = new.pos
self._agenda.size = new.size
# TODO: Maybe this should be available via task.flags?
# (in _TaskAttrGetter?) (mg, 2020-03-27)
@property
def flags(self):
if self._task:
return TaskFlags(self._task.t)
return TaskFlags(None)
@property
def COMMAND(self):
return self.command.upper()
def __getitem__(self, name):
return getattr(self, name)
......@@ -15,7 +15,6 @@
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
import attr
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.containers import (
Window,
......@@ -25,72 +24,7 @@ from prompt_toolkit.layout.containers import (
)
import twc.markup as markup
class _TaskAttrGetter:
def __init__(self, task, default=''):
self._t = task
self._default = ''
def __getattr__(self, name):
if name.startswith('_'):
return self.__dict__[name]
if not self._t:
return self._default
return self._t[name]
@attr.s
class AgendaStatus:
_pos = attr.ib(0) # highlighted item's position
size = attr.ib(0) # size of current agenda
@property
def pos(self):
return self._pos
@pos.setter
def pos(self, new):
self._pos = new + 1
@property
def ppos(self):
if self.size < 1:
return 0
return 100 * self.pos // self.size
@attr.s
class Statuses:
taskrc = attr.ib('') # path to used taskrc
command = attr.ib('') # current command
_task = attr.ib(None) # highlighted task
_agenda = attr.ib(factory=AgendaStatus) # current agenda status
@property
def task(self):
return _TaskAttrGetter(self._task)
@task.setter
def task(self, task):
self._task = task
@property
def agenda(self):
return self._agenda
@agenda.setter
def agenda(self, new):
self._agenda.pos = new.pos
self._agenda.size = new.size
@property
def COMMAND(self):
return self.command.upper()
def __getitem__(self, name):
return getattr(self, name)
from twc.widgets.formatters import Statuses
class StatusLine:
......@@ -105,12 +39,12 @@ class StatusLine:
self.infos.taskrc = self.tw.taskrc_location
self.left = FormattedTextControl(
lambda: self.format(self.cfg.settings.statusleft),
lambda: self.items(self.cfg.settings.statusleft),
focusable=False,
show_cursor=False)
self.right = FormattedTextControl(
lambda: self.format(self.cfg.settings.statusright),
lambda: self.items(self.cfg.settings.statusright),
focusable=False,
show_cursor=False)
......@@ -142,12 +76,11 @@ class StatusLine:
commandline.command_name_changed.connect(self._command_changed)
def format(self, fmt):
def items(self, fmt):
if not self.enabled:
return []
html = fmt.format_map(self.infos)
return markup.parse_html(html)
return markup.format_map(fmt, self.infos)
def _agenda_changed(self, _, agendaview):
self.infos.agenda = agendaview
......
# Copyright (C) 2020 Michał Góral.
#
# This file is part of TWC
#
# TWC is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TWC is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
from prompt_toolkit.layout.screen import Point
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.containers import Window, ScrollOffsets
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.formatted_text import to_formatted_text
from twc.widgets.urls import OpenUrls
from twc.utils import event_to_controller
from twc.consts import NL
class TextView:
def __init__(self, text, cfg):
self.text = text
self.cfg = cfg
self._pos = 0
self.control = FormattedTextControl(
self._get_text_fragments,
get_cursor_position=lambda: Point(0, self.pos),
key_bindings=self.keys(),
focusable=True,
show_cursor=False)
self.window = Window(
content=self.control,
scroll_offsets=ScrollOffsets(top=1, bottom=1),
wrap_lines=True)
def keys(self):
kb = KeyBindings()
@self.cfg.command_handler('quit', kb)
@event_to_controller
def _(controller):
if controller.stack and controller.stack[-1] is self:
controller.pop()
@self.cfg.command_handler('scroll.down', kb)
def _(event):
info = self.window.render_info
height = info.window_height
vs = info.vertical_scroll
# substract 1 due to scroll offsets
self.pos = vs + height - 1
@self.cfg.command_handler('scroll.up', kb)
def _(event):
info = self.window.render_info
vs = info.vertical_scroll
# don't add 1 due to scroll offsets
self.pos = vs
@self.cfg.command_handler('scroll.begin', kb)
def _(event):
self.pos = 0
@self.cfg.command_handler('scroll.end', kb)
def _(event):
self.pos = len(self.text) - 1
@self.cfg.command_handler('followurl', kb)
@event_to_controller
def _(controller):
OpenUrls(self.text, self.cfg, controller)
return kb
@property
def pos(self):
return self._pos
@pos.setter
def pos(self, val):
if 0 <= val < len(self.text):
self._pos = val
def _get_text_fragments(self):
result = []
for line in self.text:
ft = to_formatted_text(line)
result.extend(ft)
result.append(NL)
return result
def __pt_container__(self):
return self.window
# Copyright (C) 2020 Michał Góral.
#
# This file is part of TWC
#
# TWC is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# TWC is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with TWC. If not, see <http://www.gnu.org/licenses/>.
import re
import webbrowser
from prompt_toolkit.layout.screen import Point
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.containers import Window, ScrollOffsets
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.formatted_text import to_formatted_text
from twc.consts import NL
from twc.utils import pprint, eprint, event_to_controller
from twc.locale import tr
from twc.task import Task
class OpenUrls:
def __init__(self, element, cfg, controller):
self.cfg = cfg
self._pos = 0
self.choices = _extract_urls(element)
if not self.choices:
eprint(tr('No URL found.'))
return
if len(self.choices) == 1:
self.open(self.choices[0])
return
self.control = FormattedTextControl(
self._get_text_fragments,
get_cursor_position=lambda: Point(0, self.pos),
key_bindings=self.keys(),
focusable=True,
show_cursor=False)
self.window = Window(
content=self.control,
scroll_offsets=ScrollOffsets(top=1, bottom=1))
controller.push(self)
@classmethod
def open(cls, url):
pprint('Opening URL.')
webbrowser.open(url, new=2)
def keys(self):
kb = KeyBindings()
@self.cfg.command_handler('quit', kb)
@event_to_controller
def _(controller):
if controller.stack and controller.stack[-1] is self:
controller.pop()
@self.cfg.command_handler('scroll.begin', kb)
def _(event):
self.pos = 0
@self.cfg.command_handler('scroll.end', kb)
def _(event):
self.pos = len(self.choices) - 1
@self.cfg.command_handler('scroll.down', kb)
def _(event):
self.pos += 1
@self.cfg.command_handler('scroll.up', kb)
def _(event):
self.pos -= 1
@self.cfg.command_handler('activate', kb)
def _(event):
url = self.choices[self.pos]
self.open(url)
return kb
@property
def pos(self):
return self._pos
@pos.setter
def pos(self, val):
if 0 <= val < len(self.choices):
self._pos = val
def _get_text_fragments(self):
result = []
for i, url in enumerate(self.choices):
style = ''
if i == self.pos:
style = 'class:highlight'
ft = to_formatted_text(url, style=style)
result.extend(ft)
result.append(NL)
return result
def __pt_container__(self):
return self.window
def _extract_urls(lhs):
expr = re.compile(r'(https?://[^\s]+)')
if isinstance(lhs, Task):
urls = expr.findall(lhs['description'])
for ann in lhs.t['annotations']:
urls.extend(expr.findall(ann['description']))
elif isinstance(lhs, list):
urls = []
for elem in lhs:
urls.extend(expr.findall(elem))
elif isinstance(lhs, str):
urls = expr.findall(lhs)
else:
urls = []
return urls