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): ...@@ -140,5 +140,24 @@ class ConfigTest(TopydoTest):
self.assertEqual(config("test/data/ConfigTest5.conf").link_color(), self.assertEqual(config("test/data/ConfigTest5.conf").link_color(),
config().defaults["colorscheme"]["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__': if __name__ == '__main__':
unittest.main() unittest.main()
...@@ -63,3 +63,17 @@ append_parent_contexts = 0 ...@@ -63,3 +63,17 @@ append_parent_contexts = 0
;listcon = lscon ;listcon = lscon
;listcontext = lscon ;listcontext = lscon
;listcontexts = 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 ...@@ -18,6 +18,9 @@ import configparser
import os import os
import shlex import shlex
from itertools import accumulate
from string import ascii_lowercase
def home_config_path(p_filename): def home_config_path(p_filename):
return os.path.join(os.path.expanduser('~'), p_filename) return os.path.join(os.path.expanduser('~'), p_filename)
...@@ -109,11 +112,27 @@ class _Config: ...@@ -109,11 +112,27 @@ class _Config:
'listcontext': 'lscon', 'listcontext': 'lscon',
'listcontexts': '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.config = {}
self.cp = configparser.RawConfigParser() self.cp = configparser.RawConfigParser()
# allow uppercase config keys
self.cp.optionxform = lambda option: option
for section in self.defaults: for section in self.defaults:
self.cp.add_section(section) self.cp.add_section(section)
...@@ -311,6 +330,28 @@ class _Config: ...@@ -311,6 +330,28 @@ class _Config:
""" Returns the list format used by `ls` """ """ Returns the list format used by `ls` """
return self.cp.get('ls', 'list_format') 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): def config(p_path=None, p_overrides=None):
""" """
Retrieve the config instance. Retrieve the config instance.
......
...@@ -92,6 +92,9 @@ class UIApplication(CLIApplicationBase): ...@@ -92,6 +92,9 @@ class UIApplication(CLIApplicationBase):
self.columns = urwid.Columns([], dividechars=0, min_width=COLUMN_WIDTH) self.columns = urwid.Columns([], dividechars=0, min_width=COLUMN_WIDTH)
self.commandline = CommandLineWidget('topydo> ') self.commandline = CommandLineWidget('topydo> ')
self.keymap = config().column_keymap()
self._alarm = None
# console widget # console widget
self.console = ConsoleWidget() self.console = ConsoleWidget()
get_terminal_size(self._console_width) get_terminal_size(self._console_width)
...@@ -290,11 +293,14 @@ class UIApplication(CLIApplicationBase): ...@@ -290,11 +293,14 @@ class UIApplication(CLIApplicationBase):
before that position. 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 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)) 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, '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( options = self.columns.options(
width_type='given', width_type='given',
...@@ -312,6 +318,22 @@ class UIApplication(CLIApplicationBase): ...@@ -312,6 +318,22 @@ class UIApplication(CLIApplicationBase):
self.columns.focus_position = p_pos self.columns.focus_position = p_pos
self._blur_commandline() 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): def _swap_column_left(self):
pos = self.columns.focus_position pos = self.columns.focus_position
if pos > 0: if pos > 0:
......
...@@ -18,14 +18,37 @@ import urwid ...@@ -18,14 +18,37 @@ import urwid
from topydo.ui.TodoWidget import TodoWidget 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): class TodoListWidget(urwid.LineBox):
def __init__(self, p_view, p_title): def __init__(self, p_view, p_title, p_keymap):
self._view = None self._view = None
self.keymap = p_keymap
# store a state for multi-key shortcuts (e.g. 'gg') # store a state for multi-key shortcuts (e.g. 'gg')
self.keystate = None self.keystate = None
# store offset length for postpone command (e.g. '3' for 'p3w') # 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') self._title_widget = urwid.Text(p_title, align='center')
...@@ -43,7 +66,12 @@ class TodoListWidget(urwid.LineBox): ...@@ -43,7 +66,12 @@ class TodoListWidget(urwid.LineBox):
super().__init__(pile) 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 @property
def view(self): def view(self):
...@@ -101,132 +129,169 @@ class TodoListWidget(urwid.LineBox): ...@@ -101,132 +129,169 @@ class TodoListWidget(urwid.LineBox):
self.listbox.calculate_visible(p_size) self.listbox.calculate_visible(p_size)
def keypress(self, p_size, p_key): def keypress(self, p_size, p_key):
# first check whether 'g' was pressed previously urwid.emit_signal(self, 'remove_pending_action')
if self.keystate == 'g':
if p_key == 'g':
self._scroll_to_top(p_size)
# make sure to accept normal shortcuts again keymap, keystates = self.keymap
self.keystate = None
return shortcut = self.keystate or ''
elif self.keystate in ['p', 'ps']: shortcut += p_key
if p_key not in ['d', 'w', 'm', 'y']:
if p_key.isdigit(): try:
self._pp_offset += p_key action = keymap[shortcut]
elif self.keystate == 'p' and p_key == 's': except KeyError:
self.keystate = 'ps' action = None
else:
self._pp_offset = '' if action:
self.keystate = None 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: else:
self._postpone_selected_item(p_key) # Only action is matched. Handle it and reset keystate.
self._pp_offset = '' self.resolve_action(action, p_size)
self.keystate = None self.keystate = None
return
elif self.keystate == 'r':
if p_key.isalpha():
self._pri_selected_item(p_key)
self.keystate = None
return 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: 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): def mouse_event(self, p_size, p_event, p_button, p_column, p_row, p_focus):
if p_event == 'mouse press': if p_event == 'mouse press':
if p_button == 4: # up if p_button == 4: # up
self.listbox.keypress(p_size, 'up') self.listbox.keypress(p_size, 'up')
return return
elif p_button == 5: #down: elif p_button == 5: # down:
self.listbox.keypress(p_size, 'down') self.listbox.keypress(p_size, 'down')
return 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): def selectable(self):
return True 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. Executes command specified by p_cmd_str on selected todo item.
p_cmd_str should be string with one replacement field ('{}') which will p_cmd_str should be a string with one replacement field ('{}') which
be substituted by id of selected todo item. will be substituted by id of the selected todo item.
""" """
try: try:
todo = self.listbox.focus.todo todo = self.listbox.focus.todo
todo_id = str(self.view.todolist.number(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: except AttributeError:
# No todo item selected # No todo item selected
pass pass
def _complete_selected_item(self): def resolve_action(self, p_action_str, p_size=None):
""" """
Marks the highlighted todo item as complete. Checks whether action specified in p_action_str is "built-in" or
""" contains topydo command (i.e. starts with 'cmd') and forwards it to
self._command_on_selected('do {}') proper executing methods.
def _postpone_selected_item(self, p_pattern): p_size should be specified for some of the builtin actions like 'up' or
""" 'home' as they can interact with urwid.ListBox.keypress or
Postpones highlighted todo item by p_pattern with optional offset from urwid.ListBox.calculate_visible.
_pp_offset attribute.
""" """
if self._pp_offset == '': if p_action_str.startswith('cmd '):
self._pp_offset = '1' # cut 'cmd' word from command string
cmd = p_action_str[4:]
pattern = self._pp_offset + p_pattern if '{}' in cmd:
self._execute_on_selected(cmd)
if self.keystate == 'ps': else:
cmd_str = 'postpone -s {t_id} {pattern}'.format(t_id='{}', pattern=pattern) urwid.emit_signal(self, 'execute_command', cmd)
else: else:
cmd_str = 'postpone {t_id} {pattern}'.format(t_id='{}', pattern=pattern) self.execute_builtin_action(p_action_str, p_size)
self._command_on_selected(cmd_str)
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 Postpones selected todo item by <COUNT><PERIOD>.
p_priority.
""" Returns True after 'postpone' command is called (i.e. p_pattern is valid
cmd_str = 'pri {t_id} {priority}'.format(t_id='{}', priority=p_priority) <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