Commit 2ece2812 authored by Jacek Sowiński's avatar Jacek Sowiński

Configurable keymap

User can now specify key-shortcuts in main topydo config file under
'column_keymap' section in form of:

`<SHORTCUT> = <ACTION>`

Two main types of action are supported:
- built-in (one of: 'home', 'end', 'up', 'down', 'postpone', 'postpone_s'
  and 'pri')
- topydo commands (aliases included). Prefixed with 'cmd'. Commands to
  call on selected todo item should contain '{}' placeholder to mark its
  place in final command call in similiar fashion as in aliases
  definitions.

postpone, postpone_s and pri shortcuts are sort of prefixes for
arguments for respective topydo commands triggered on selected todo item:
- `postpone<COUNT><PERIOD>` will translate to `cmd postpone {}
  <COUNT><PERIOD>`. postpone_s will do the same but with '-s' flag
  added.
- `pri<PRIORITY>` will translate to `cmd pri {} <PRIORITY>`

Default config as an example:

```ini
[column_keymap]
gg = home
G = end
j = down
k = up
d = cmd del {}
e = cmd edit {}
u = cmd revert
x = cmd do {}
pp = postpone
ps = postpone_s
pr = pri
```

- pp23d will postpone selected item by 23 days
- ps1m will postpone selected item (threshold date included) by 1 month
- prz will set priority of selected item to (Z)
parent 78f641b2
[column_keymap]
up = up
......@@ -140,5 +140,24 @@ class ConfigTest(TopydoTest):
self.assertEqual(config("test/data/ConfigTest5.conf").link_color(),
config().defaults["colorscheme"]["link_color"])
def test_config24(self):
""" column_keymap test. """
keymap, keystates = config("test/data/ConfigTest6.conf").column_keymap()
self.assertEqual(keymap['pp'], 'postpone')
self.assertEqual(keymap['ps'], 'postpone_s')
self.assertEqual(keymap['pr'], 'pri')
self.assertEqual(keymap['pra'], 'cmd pri {} a')
self.assertIn('p', keystates)
self.assertIn('g', keystates)
self.assertIn('pp', keystates)
self.assertIn('ps', keystates)
self.assertIn('pr', keystates)
self.assertEqual(keymap['up'], 'up')
self.assertIn('u', keystates)
if __name__ == '__main__':
unittest.main()
......@@ -63,3 +63,17 @@ append_parent_contexts = 0
;listcon = lscon
;listcontext = lscon
;listcontexts = lscon
[column_keymap]
; Keymap configuration for column-mode
gg = home
G = end
j = down
k = up
d = cmd del {}
e = cmd edit {}
u = cmd revert
x = cmd do {}
pp = postpone
ps = postpone_s
pr = pri
......@@ -18,6 +18,9 @@ import configparser
import os
import shlex
from itertools import accumulate
from string import ascii_lowercase
def home_config_path(p_filename):
return os.path.join(os.path.expanduser('~'), p_filename)
......@@ -109,11 +112,27 @@ class _Config:
'listcontext': 'lscon',
'listcontexts': 'lscon',
},
'column_keymap': {
'gg': 'home',
'G': 'end',
'j': 'down',
'k': 'up',
'd': 'cmd del {}',
'e': 'cmd edit {}',
'u': 'cmd revert',
'x': 'cmd do {}',
'pp': 'postpone',
'ps': 'postpone_s',
'pr': 'pri'
},
}
self.config = {}
self.cp = configparser.RawConfigParser()
# allow uppercase config keys
self.cp.optionxform = lambda option: option
for section in self.defaults:
self.cp.add_section(section)
......@@ -311,6 +330,28 @@ class _Config:
""" Returns the list format used by `ls` """
return self.cp.get('ls', 'list_format')
def column_keymap(self):
""" Returns keymap and keystates used in column mode """
keystates = set()
shortcuts = self.cp.items('column_keymap')
keymap_dict = dict(shortcuts)
for combo, action in shortcuts:
# add all possible prefixes to keystates
if len(combo) > 1:
keystates |= set(accumulate(combo[:-1]))
if action in ['pri', 'postpone', 'postpone_s']:
keystates.add(combo)
if action == 'pri':
for c in ascii_lowercase:
keymap_dict[combo + c] = 'cmd pri {} ' + c
return (keymap_dict, keystates)
def config(p_path=None, p_overrides=None):
"""
Retrieve the config instance.
......
......@@ -92,6 +92,9 @@ class UIApplication(CLIApplicationBase):
self.columns = urwid.Columns([], dividechars=0, min_width=COLUMN_WIDTH)
self.commandline = CommandLineWidget('topydo> ')
self.keymap = config().column_keymap()
self._alarm = None
# console widget
self.console = ConsoleWidget()
get_terminal_size(self._console_width)
......@@ -290,11 +293,14 @@ class UIApplication(CLIApplicationBase):
before that position.
"""
todolist = TodoListWidget(p_view, p_view.data['title'])
todolist = TodoListWidget(p_view, p_view.data['title'], self.keymap)
no_output = lambda _: None
urwid.connect_signal(todolist, 'execute_command',
urwid.connect_signal(todolist, 'execute_command_silent',
lambda cmd: self._execute_handler(cmd, no_output))
urwid.connect_signal(todolist, 'execute_command', self._execute_handler)
urwid.connect_signal(todolist, 'refresh', self.mainloop.screen.clear)
urwid.connect_signal(todolist, 'add_pending_action', self._set_alarm)
urwid.connect_signal(todolist, 'remove_pending_action', self._remove_alarm)
options = self.columns.options(
width_type='given',
......@@ -312,6 +318,22 @@ class UIApplication(CLIApplicationBase):
self.columns.focus_position = p_pos
self._blur_commandline()
def _set_alarm(self, p_action):
"""
Sets alarm to execute p_action specified in 0.5 sec.
Handle for this alarm is stored as _alarm attribute.
p_action must be an object with 'execute' method.
"""
self._alarm = self.mainloop.set_alarm_in(0.5, p_action.execute)
def _remove_alarm(self):
"""
Removes pending action alarm stored in _alarm attribute.
"""
self.mainloop.remove_alarm(self._alarm)
self._alarm = None
def _swap_column_left(self):
pos = self.columns.focus_position
if pos > 0:
......
......@@ -18,14 +18,37 @@ import urwid
from topydo.ui.TodoWidget import TodoWidget
class PendingAction(object):
"""
Object class for storing TodoListWidget action waiting for execution.
"""
def __init__(self, p_tlw, p_action_str, p_size):
self.todolist = p_tlw
self.action_str = p_action_str
self.size = p_size
def execute(self, p_loop, p_user_data=None):
"""
Executes action stored in action_str attribute from within p_loop
(typically urwid.MainLoop).
Since this method is primarily used as callback for
urwid.MainLoop.set_alarm_in it has to accept 3rd parameter: p_user_data.
"""
self.todolist.resolve_action(self.action_str, self.size)
self.todolist.keystate = None
class TodoListWidget(urwid.LineBox):
def __init__(self, p_view, p_title):
def __init__(self, p_view, p_title, p_keymap):
self._view = None
self.keymap = p_keymap
# store a state for multi-key shortcuts (e.g. 'gg')
self.keystate = None
# store offset length for postpone command (e.g. '3' for 'p3w')
self._pp_offset = ''
self._pp_offset = None
self._title_widget = urwid.Text(p_title, align='center')
......@@ -43,7 +66,12 @@ class TodoListWidget(urwid.LineBox):
super().__init__(pile)
urwid.register_signal(TodoListWidget, ['execute_command', 'refresh'])
urwid.register_signal(TodoListWidget, ['execute_command_silent',
'execute_command',
'refresh',
'add_pending_action',
'remove_pending_action',
])
@property
def view(self):
......@@ -101,132 +129,169 @@ class TodoListWidget(urwid.LineBox):
self.listbox.calculate_visible(p_size)
def keypress(self, p_size, p_key):
# first check whether 'g' was pressed previously
if self.keystate == 'g':
if p_key == 'g':
self._scroll_to_top(p_size)
urwid.emit_signal(self, 'remove_pending_action')
# make sure to accept normal shortcuts again
self.keystate = None
return
elif self.keystate in ['p', 'ps']:
if p_key not in ['d', 'w', 'm', 'y']:
if p_key.isdigit():
self._pp_offset += p_key
elif self.keystate == 'p' and p_key == 's':
self.keystate = 'ps'
else:
self._pp_offset = ''
self.keystate = None
keymap, keystates = self.keymap
shortcut = self.keystate or ''
shortcut += p_key
try:
action = keymap[shortcut]
except KeyError:
action = None
if action:
if shortcut in keystates:
# Supplied key-shortcut matches keystate and action. Save the
# keystate in case user will hit another key and add an action
# waiting for execution if user won't type anything further.
self.keystate = shortcut
self._add_pending_action(action, p_size)
else:
self._postpone_selected_item(p_key)
self._pp_offset = ''
# Only action is matched. Handle it and reset keystate.
self.resolve_action(action, p_size)
self.keystate = None
return
elif self.keystate == 'r':
if p_key.isalpha():
self._pri_selected_item(p_key)
self.keystate = None
return
if p_key == 'x':
self._complete_selected_item()
elif p_key == 'p':
self.keystate = 'p'
elif p_key == 'd':
self._remove_selected_item()
elif p_key == 'e':
self._edit_selected_item()
# force screen redraw after editing
urwid.emit_signal(self, 'refresh')
elif p_key == 'r':
self.keystate = 'r'
elif p_key == 'u':
urwid.emit_signal(self, 'execute_command', "revert")
elif p_key == 'j':
self.listbox.keypress(p_size, 'down')
elif p_key == 'k':
self.listbox.keypress(p_size, 'up')
elif p_key == 'home':
self._scroll_to_top(p_size)
elif p_key == 'G' or p_key == 'end':
self._scroll_to_bottom(p_size)
elif p_key == 'g':
self.keystate = 'g'
else:
return self.listbox.keypress(p_size, p_key)
if shortcut in keystates:
self.keystate = shortcut
else:
try:
# Check whether current keystate matches built-in 'postpone'
# action.
mode = keymap[self.keystate]
if mode in ['postpone', 'postpone_s']:
if self._postpone_selected(p_key, mode) is not None:
self.keystate = None
return
except KeyError:
if not self.keystate:
# Single key that is not described in keymap config.
return self.listbox.keypress(p_size, p_key)
self.keystate = None
return
def mouse_event(self, p_size, p_event, p_button, p_column, p_row, p_focus):
if p_event == 'mouse press':
if p_button == 4: # up
self.listbox.keypress(p_size, 'up')
return
elif p_button == 5: #down:
elif p_button == 5: # down:
self.listbox.keypress(p_size, 'down')
return
return super().mouse_event(p_size, p_event, p_button, p_column, p_row, p_focus) # pylint: disable=E1102
return super().mouse_event(p_size, # pylint: disable=E1102
p_event,
p_button,
p_column,
p_row,
p_focus)
def selectable(self):
return True
def _command_on_selected(self, p_cmd_str):
def _execute_on_selected(self, p_cmd_str):
"""
Executes command specified by p_cmd_str on selected todo item.
p_cmd_str should be string with one replacement field ('{}') which will
be substituted by id of selected todo item.
p_cmd_str should be a string with one replacement field ('{}') which
will be substituted by id of the selected todo item.
"""
try:
todo = self.listbox.focus.todo
todo_id = str(self.view.todolist.number(todo))
urwid.emit_signal(self, 'execute_command', p_cmd_str.format(todo_id))
urwid.emit_signal(self,
'execute_command_silent',
p_cmd_str.format(todo_id))
# force screen redraw after editing
if p_cmd_str.startswith('edit'):
urwid.emit_signal(self, 'refresh')
except AttributeError:
# No todo item selected
pass
def _complete_selected_item(self):
def resolve_action(self, p_action_str, p_size=None):
"""
Marks the highlighted todo item as complete.
"""
self._command_on_selected('do {}')
Checks whether action specified in p_action_str is "built-in" or
contains topydo command (i.e. starts with 'cmd') and forwards it to
proper executing methods.
def _postpone_selected_item(self, p_pattern):
"""
Postpones highlighted todo item by p_pattern with optional offset from
_pp_offset attribute.
p_size should be specified for some of the builtin actions like 'up' or
'home' as they can interact with urwid.ListBox.keypress or
urwid.ListBox.calculate_visible.
"""
if self._pp_offset == '':
self._pp_offset = '1'
pattern = self._pp_offset + p_pattern
if self.keystate == 'ps':
cmd_str = 'postpone -s {t_id} {pattern}'.format(t_id='{}', pattern=pattern)
if p_action_str.startswith('cmd '):
# cut 'cmd' word from command string
cmd = p_action_str[4:]
if '{}' in cmd:
self._execute_on_selected(cmd)
else:
urwid.emit_signal(self, 'execute_command', cmd)
else:
cmd_str = 'postpone {t_id} {pattern}'.format(t_id='{}', pattern=pattern)
self._command_on_selected(cmd_str)
self.execute_builtin_action(p_action_str, p_size)
def _remove_selected_item(self):
def execute_builtin_action(self, p_action_str, p_size=None):
"""
Removes the highlighted todo item.
Executes built-in action specified in p_action_str.
Currently supported actions are: 'up', 'down', 'home', 'end',
'postpone', 'postpone_s' and 'pri'.
"""
self._command_on_selected('del {}')
if p_action_str in ['up', 'down']:
self.listbox.keypress(p_size, p_action_str)
elif p_action_str == 'home':
self._scroll_to_top(p_size)
elif p_action_str == 'end':
self._scroll_to_bottom(p_size)
elif p_action_str in ['postpone', 'postpone_s']:
pass
elif p_action_str == 'pri':
pass
def _edit_selected_item(self):
def _add_pending_action(self, p_action, p_size):
"""
Opens the highlighted todo item in $EDITOR for editing.
Creates action waiting for execution and forwards it to the mainloop.
"""
self._command_on_selected('edit {}')
urwid.emit_signal(self, 'add_pending_action', PendingAction(self,
p_action,
p_size))
def _pri_selected_item(self, p_priority):
def _postpone_selected(self, p_pattern, p_mode):
"""
Sets the priority of the highlighted todo item with value from
p_priority.
"""
cmd_str = 'pri {t_id} {priority}'.format(t_id='{}', priority=p_priority)
Postpones selected todo item by <COUNT><PERIOD>.
Returns True after 'postpone' command is called (i.e. p_pattern is valid
<PERIOD>), False when p_pattern is invalid and None if p_pattern is
digit (i.e. part of <COUNT>).
self._command_on_selected(cmd_str)
p_pattern accepts digit (<COUNT>) or one of the <PERIOD> letters:
'd'(ay), 'w'(eek), 'm'(onth), 'y'(ear). If digit is specified, it is
appended to _pp_offset attribute. If p_pattern contains one of the
<PERIOD> letters, 'postpone' command is forwarded to execution with
value of _pp_offset attribute used as <COUNT>. If _pp_offset is None,
<COUNT> is set to 1.
p_mode should be one of 'postpone_s' or 'postpone'. It decides whether
'postpone' command should be called with or without '-s' flag.
"""
if p_pattern.isdigit():
if not self._pp_offset:
self._pp_offset = ''
self._pp_offset += p_pattern
result = None
else:
if p_pattern in ['d', 'w', 'm', 'y']:
offset = self._pp_offset or '1'
if p_mode == 'postpone':
pp_cmd = 'cmd postpone {} '
else:
pp_cmd = 'cmd postpone -s {} '
pp_cmd += offset + p_pattern
self.resolve_action(pp_cmd)
result = True
self._pp_offset = None
result = False
return result
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment