Commit 981d1af2 authored by Bram Schoenmakers's avatar Bram Schoenmakers

Merge pull request #101 from mruwek/column-ui-keymap

Introduce keymap for column_mode
parents a2a77716 b7a99d42
[column_keymap]
up = up
<Left> = prev_column
<Esc>d = delete_column
......@@ -140,5 +140,31 @@ 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)
self.assertEqual(keymap['<Left>'], 'prev_column')
self.assertNotIn('<Lef', keystates)
self.assertEqual(keymap['<Esc>d'], 'delete_column')
self.assertNotIn('<Esc', keystates)
self.assertIn('<Esc>', keystates)
if __name__ == '__main__':
unittest.main()
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2014 - 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program 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.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
import unittest
from test.topydo_testcase import TopydoTest
from topydo.lib.Utils import translate_key_to_config
class UtilsTest(TopydoTest):
def test_key_to_cfg(self):
ctrl_s = translate_key_to_config('ctrl s')
meta_d = translate_key_to_config('meta d')
esc = translate_key_to_config('esc')
f4 = translate_key_to_config('f4')
self.assertEqual(ctrl_s, '<C-s>')
self.assertEqual(meta_d, '<M-d>')
self.assertEqual(esc, '<Esc>')
self.assertEqual(f4, '<F4>')
if __name__ == '__main__':
unittest.main()
......@@ -63,3 +63,30 @@ 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
0 = first_column
$ = last_column
h = prev_column
l = next_column
A = append_column
I = insert_column
E = edit_column
D = delete_column
Y = copy_column
L = swap_left
R = swap_right
<Left> = prev_column
<Right> = next_column
......@@ -16,8 +16,12 @@
import configparser
import os
import re
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 +113,40 @@ 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',
'0': 'first_column',
'$': 'last_column',
'h': 'prev_column',
'l': 'next_column',
'A': 'append_column',
'I': 'insert_column',
'E': 'edit_column',
'D': 'delete_column',
'Y': 'copy_column',
'L': 'swap_left',
'R': 'swap_right',
'<Left>': 'prev_column',
'<Right>': 'next_column',
},
}
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 +344,29 @@ 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
combo_as_list = re.split('(<[A-Z].+?>|.)', combo)[1::2]
if len(combo_as_list) > 1:
keystates |= set(accumulate(combo_as_list[:-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.
......
......@@ -94,3 +94,18 @@ def get_terminal_size(p_getter=None):
get_terminal_size.getter = inner
return get_terminal_size.getter()
def translate_key_to_config(p_key):
"""
Translates urwid key event to form understandable by topydo config parser.
"""
if len(p_key) > 1:
key = p_key.capitalize()
if key.startswith('Ctrl') or key.startswith('Meta'):
key = key[0] + '-' + key[5:]
key = '<' + key + '>'
else:
key = p_key
return key
......@@ -16,6 +16,7 @@
import urwid
class ConsoleWidget(urwid.LineBox):
def __init__(self, p_text=""):
urwid.register_signal(ConsoleWidget, ['close'])
......@@ -29,7 +30,10 @@ class ConsoleWidget(urwid.LineBox):
if p_key == 'enter' or p_key == 'q' or p_key == 'esc':
urwid.emit_signal(self, 'close')
# don't return the key, 'enter', 'escape' or 'q' are your only escape.
# don't return the key, 'enter', 'escape', 'q' or ':' are your only
# escape. ':' will reenter to the cmdline.
elif p_key == ':':
urwid.emit_signal(self, 'close', True)
def render(self, p_size, focus):
"""
......
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program 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.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
import urwid
class KeystateWidget(urwid.Text):
def __init__(self):
super().__init__('', align='right')
def selectable(self):
return False
......@@ -24,6 +24,7 @@ from topydo.cli.CLIApplicationBase import CLIApplicationBase
from topydo.Commands import get_subcommand
from topydo.ui.CommandLineWidget import CommandLineWidget
from topydo.ui.ConsoleWidget import ConsoleWidget
from topydo.ui.KeystateWidget import KeystateWidget
from topydo.ui.TodoListWidget import TodoListWidget
from topydo.ui.ViewWidget import ViewWidget
from topydo.ui.ColumnLayout import columns
......@@ -37,6 +38,7 @@ from topydo.lib import TodoList
COLUMN_WIDTH = 40
class UIView(View):
"""
A subclass of view holding user input data that constructed the view (i.e.
......@@ -51,6 +53,7 @@ _EDIT_COLUMN = 2
_COPY_COLUMN = 3
_INSERT_COLUMN = 4
class MainPile(urwid.Pile):
"""
This subclass of Pile doesn't change focus on cursor up/down / mouse press
......@@ -82,6 +85,7 @@ class MainPile(urwid.Pile):
if self._command_map[key] not in ('cursor up', 'cursor down'):
return key
class UIApplication(CLIApplicationBase):
def __init__(self):
super().__init__()
......@@ -93,6 +97,13 @@ class UIApplication(CLIApplicationBase):
self.columns = urwid.Columns([], dividechars=0, min_width=COLUMN_WIDTH)
self.commandline = CommandLineWidget('topydo> ')
self.keystate_widget = KeystateWidget()
self.status_line = urwid.Columns([
('weight', 1, urwid.Filler(self.commandline)),
])
self.keymap = config().column_keymap()
self._alarm = None
# console widget
self.console = ConsoleWidget()
......@@ -102,8 +113,10 @@ class UIApplication(CLIApplicationBase):
urwid.connect_signal(self.commandline, 'execute_command',
self._execute_handler)
def hide_console():
def hide_console(p_focus_commandline=False):
self._console_visible = False
if p_focus_commandline:
self._focus_commandline()
urwid.connect_signal(self.console, 'close', hide_console)
# view widget
......@@ -120,7 +133,7 @@ class UIApplication(CLIApplicationBase):
self.mainwindow = MainPile([
('weight', 1, self.columns),
(1, urwid.Filler(self.commandline)),
(1, self.status_line),
])
urwid.connect_signal(self.mainwindow, 'blur_console', hide_console)
......@@ -244,22 +257,25 @@ class UIApplication(CLIApplicationBase):
self.column_mode = _COPY_COLUMN
self._viewwidget_visible = True
def _column_action_handler(self, p_action):
dispatch = {
'first_column': self._focus_first_column,
'last_column': self._focus_last_column,
'prev_column': self._focus_previous_column,
'next_column': self._focus_next_column,
'append_column': self._append_column,
'insert_column': self._insert_column,
'edit_column': self._edit_column,
'delete_column': self._delete_column,
'copy_column': self._copy_column,
'swap_left': self._swap_column_left,
'swap_right': self._swap_column_right,
}
dispatch[p_action]()
def _handle_input(self, p_input):
dispatch = {
':': self._focus_commandline,
'0': self._focus_first_column,
'$': self._focus_last_column,
'left': self._focus_previous_column,
'h': self._focus_previous_column,
'right': self._focus_next_column,
'l': self._focus_next_column,
'A': self._append_column,
'I': self._insert_column,
'E': self._edit_column,
'D': self._delete_column,
'Y': self._copy_column,
'L': self._swap_column_left,
'R': self._swap_column_right,
}
try:
......@@ -307,11 +323,16 @@ 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)
urwid.connect_signal(todolist, 'column_action', self._column_action_handler)
urwid.connect_signal(todolist, 'show_keystate', self._print_keystate)
options = self.columns.options(
width_type='given',
......@@ -329,6 +350,19 @@ class UIApplication(CLIApplicationBase):
self.columns.focus_position = p_pos
self._blur_commandline()
def _print_keystate(self, p_keystate):
self.keystate_widget.set_text(p_keystate)
self._keystate_visible = len(p_keystate) > 0
def _set_alarm(self, p_callback):
""" Sets alarm to execute p_action specified in 0.5 sec. """
self._alarm = self.mainloop.set_alarm_in(0.5, p_callback)
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:
......@@ -358,6 +392,23 @@ class UIApplication(CLIApplicationBase):
elif p_enabled == False and self._console_visible:
self.console.clear()
del contents[2]
self.mainwindow.focus_position = 0
@property
def _keystate_visible(self):
contents = self.status_line.contents
return len(contents) == 2 and isinstance(contents[1][0].original_widget,
KeystateWidget)
@_keystate_visible.setter
def _keystate_visible(self, p_enabled):
contents = self.status_line.contents
if p_enabled and len(contents) == 1:
contents.append((urwid.Filler(self.keystate_widget),
('weight', 1, True)))
elif not p_enabled and self._keystate_visible:
del contents[1]
@property
def _viewwidget_visible(self):
......
......@@ -17,15 +17,27 @@
import urwid
from topydo.ui.TodoWidget import TodoWidget
from topydo.lib.Utils import translate_key_to_config
def get_execute_signal(p_prefix):
if p_prefix == 'cmdv':
signal = 'execute_command'
else:
signal = 'execute_command_silent'
return signal
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 +55,14 @@ 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',
'column_action',
'show_keystate',
])
@property
def view(self):
......@@ -100,133 +119,212 @@ class TodoListWidget(urwid.LineBox):
# deals with pending focus changes.
self.listbox.calculate_visible(p_size)
@property
def keystate(self):
return self._keystate
@keystate.setter
def keystate(self, p_keystate):
self._keystate = p_keystate
keystate_to_show = p_keystate if p_keystate else ''
urwid.emit_signal(self, 'show_keystate', keystate_to_show)
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')
requires_further_input = ['postpone', 'postpone_s', 'pri']
# 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 += translate_key_to_config(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
if action not in requires_further_input:
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
else:
urwid.emit_signal(self, 'show_keystate',
self.keystate + self._pp_offset)
else:
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, p_execute_signal):
"""
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.
p_execute_signal is the signal name passed to the main loop. It should
be one of 'execute_command' or 'execute_command_silent'.
"""
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, p_execute_signal, 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 p_action_str.startswith(('cmd ', 'cmdv ')):
prefix, cmd = p_action_str.split(' ', 1)
execute_signal = get_execute_signal(prefix)
if self.keystate == 'ps':
cmd_str = 'postpone -s {t_id} {pattern}'.format(t_id='{}', pattern=pattern)
if '{}' in cmd:
self._execute_on_selected(cmd, execute_signal)
else:
urwid.emit_signal(self, execute_signal, 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',
'first_column', 'last_column', 'prev_column', 'next_column',
'append_column', 'insert_column', 'edit_column', 'delete_column',
'copy_column', swap_right', 'swap_left', 'postpone', 'postpone_s' and
'pri'.
"""
self._command_on_selected('del {}')
column_actions = ['first_column',
'last_column',
'prev_column',
'next_column',
'append_column',
'insert_column',
'edit_column',
'delete_column',
'copy_column',
'swap_left',
'swap_right',
]
if p_action_str in column_actions:
urwid.emit_signal(self, 'column_action', p_action_str)
elif 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 {}')
def generate_callback():
def callback(*args):
self.resolve_action(p_action, p_size)
self.keystate = None
def _pri_selected_item(self, p_priority):
"""
Sets the priority of the highlighted todo item with value from
p_priority.
return callback
urwid.emit_signal(self, 'add_pending_action', generate_callback())
def _postpone_selected(self, p_pattern, p_mode):
"""
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