changeset 84:9f683df69a4c

shortcut keys are now managed in separate module, with a class checking for conflicts
author Goffi <goffi@goffi.org>
date Thu, 04 Sep 2014 16:50:12 +0200
parents 12b5b1435e17
children e0c8274f9b1c
files urwid_satext/keys.py urwid_satext/sat_widgets.py
diffstat 2 files changed, 172 insertions(+), 25 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/urwid_satext/keys.py	Thu Sep 04 16:50:12 2014 +0200
@@ -0,0 +1,149 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Urwid SàT extensions
+# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""This module manage action <==> key mapping and can be extended to add new actions"""
+
+
+class ConflictError(Exception):
+    pass
+
+
+class ActionMap(dict):
+    """Object which manage mapping betwwen actions and keys"""
+
+    def __init__(self, source_dict=None):
+        """ Initialise the map
+
+        @param source_dict: dictionary-like object with actions to import
+        """
+        self._namespaces_actions = {} # key = namespace, values (set) = actions
+        self._close_namespaces = tuple()
+        self._alway_check_namespaces = None
+        if source_dict is not None:
+            self.update(source_dict)
+
+    def __setitem__(self, action, shortcut):
+        """set an action avoiding conflicts
+
+        @param action (str,tuple): either an action (str) or a (namespace, action) tuple. action without namespace will not be checked by cbeck_namespaces(). namespace can also be a tuple itself, the action will the be assigned to several namespaces.
+        @param shortcut (str): key shortcut for this action
+        @raise: ConflictError if the action already exists
+        """
+        if isinstance(action, tuple):
+            namespaces, action = action
+            if not isinstance(namespaces, tuple):
+                namespaces = (namespaces,)
+            for namespace in namespaces:
+                namespace_map = self._namespaces_actions.setdefault(namespace.lower(), set())
+                namespace_map.add(action)
+
+        if action in self:
+            raise ConflictError("The action [{}] already exists".format(action))
+        return super(ActionMap, self).__setitem__(action, shortcut.lower())
+
+    def __delitem__(self, action):
+        # we don't want to delete actions
+        raise NotImplementedError
+
+    def update(self, dict_like):
+        """Update actions with an other dictionary
+
+        @param dict_like: dictionary like object to update actions
+        @raise: ConflictError if at least one of the new actions already exists
+        """
+        if not isinstance(dict_like, dict):
+            raise ValueError("only dictionary subclasses are accepted for update")
+        conflict = dict_like.viewkeys() & self.viewkeys()
+        if conflict:
+            raise ConflictError("The actions [{}] already exists".format(','.join(conflict)))
+        for action, shortcut in dict_like.iteritems():
+            self[action] = shortcut
+
+    def set_close_namespaces(self, close_namespaces, always_check=None):
+        """Set namespaces where conflicting shortcut should not happen
+
+        used by check_namespaces to see if the same shortcut is not used in two close namespaces (e.g. 'tab' used in edit_bar and globally)
+        @param close_namespaces (tuple of tuples): tuple indicating namespace where shortcut should not conflict. e.g.: (('global', 'edit'), ('confirm', 'popup', 'global')) indicate that shortcut in 'global' and 'edit' should not be the same, nor the ones between 'confirm', 'popup' and 'global'.
+        @param always_check (tuple): if not None, these namespaces will be close to every other ones (useful for global namespace)
+        """
+        assert isinstance(close_namespaces, tuple)
+        if always_check is not None:
+            assert isinstance(always_check, tuple)
+        self._close_namespaces = close_namespaces
+        self._alway_check_namespaces = always_check
+
+    def check_namespaces(self):
+        """Check that shortcuts are not conflicting in close namespaces"""
+        # we first check each namespace individually
+        checked = set()
+
+        def check_namespaces(namespaces):
+            # for each namespace which save keys used
+            # if 1 key is used several times, we raise
+            # a ConflictError
+            set_shortcuts = {}
+
+            to_check = set(namespaces + self._alway_check_namespaces)
+
+            for namespace in to_check:
+                checked.add(namespace)
+                for action in self._namespaces_actions[namespace]:
+                    shortcut = self[action]
+                    if shortcut in set_shortcuts:
+                        set_namespace = set_shortcuts[shortcut]
+                        if set_namespace == namespace:
+                            msg = 'shortcut [{}] is not unique in namespace "{}"'.format(shortcut, namespace)
+                        else:
+                            msg = 'shortcut [{}] is used both in namespaces "{}" and "{}"'.format(shortcut, set_namespace, namespace)
+                        raise ConflictError(msg)
+                    set_shortcuts[shortcut] = namespace
+
+        # we first check close namespaces
+        for close_namespaces in self._close_namespaces:
+            check_namespaces(close_namespaces)
+
+        # then the remaining ones
+        for namespace in set(self._namespaces_actions.keys()).difference(checked):
+            check_namespaces((namespace,))
+
+
+keys = {
+        ("edit", "EDIT_HOME"): 'ctrl a',
+        ("edit", "EDIT_END"): 'ctrl e',
+        ("edit", "EDIT_DELETE_TO_END"): 'ctrl k',
+        ("edit", "EDIT_DELETE_LAST_WORD"): 'ctrl w',
+        ("edit", "EDIT_ENTER"): 'enter',
+        ("edit", "EDIT_COMPLETE"): 'shift tab',
+        (("edit", "modal"), "MODAL_ESCAPE"): 'esc',
+        ("selectable", "TEXT_SELECT"): ' ',
+        ("selectable", "TEXT_SELECT2"): 'enter',
+        ("menu_box", "MENU_BOX_UP"): 'up',
+        ("menu_box", "MENU_BOX_LEFT"): 'left',
+        ("menu_box", "MENU_BOX_RIGHT"): 'right',
+        ("menu", "MENU_DOWN"): 'down',
+        ("menu", "MENU_UP"): 'up',
+        ("menu_roller", "MENU_ROLLER_UP"): 'up',
+        ("menu_roller", "MENU_ROLLER_DOWN"): 'down',
+        ("menu_roller", "MENU_ROLLER_RIGHT"): 'right',
+        ("columns_roller", "COLUMNS_ROLLER_LEFT"): 'left',
+        ("columns_roller", "COLUMNS_ROLLER_RIGHT"): 'right',
+        ("focus", "FOCUS_SWITCH"): 'tab',
+       }
+
+action_key_map = ActionMap(keys)
--- a/urwid_satext/sat_widgets.py	Wed Sep 03 17:42:07 2014 +0200
+++ b/urwid_satext/sat_widgets.py	Thu Sep 04 16:50:12 2014 +0200
@@ -23,6 +23,7 @@
 utf8decode = lambda s: encodings.codecs.utf_8_decode(s)[0]
 
 from urwid.util import is_mouse_press #XXX: is_mouse_press is not included in urwid in 1.0.0
+from .keys import action_key_map as a_key
 
 
 class AdvancedEdit(urwid.Edit):
@@ -50,21 +51,21 @@
 
     def keypress(self, size, key):
         #TODO: insert mode is not managed yet
-        if key == 'ctrl a':
+        if key == a_key['EDIT_HOME']:
             key = 'home'
-        elif key == 'ctrl e':
+        elif key == a_key['EDIT_END']:
             key = 'end'
-        elif key == 'ctrl k':
+        elif key == a_key['EDIT_DELETE_TO_END']:
             self._delete_highlighted()
             self.set_edit_text(self.edit_text[:self.edit_pos])
-        elif key == 'ctrl w':
+        elif key == a_key['EDIT_DELETE_LAST_WORD']:
             before = self.edit_text[:self.edit_pos]
             pos = before.rstrip().rfind(" ")+1
             self.set_edit_text(before[:pos] + self.edit_text[self.edit_pos:])
             self.set_edit_pos(pos)
-        elif key == 'enter':
+        elif key == a_key['EDIT_ENTER']:
             self._emit('click')
-        elif key == 'shift tab':
+        elif key == a_key['EDIT_COMPLETE']:
             try:
                 before = self.edit_text[:self.edit_pos]
                 if self.completion_data:
@@ -154,7 +155,7 @@
         super(ModalEdit, self).setCompletionMethod(lambda text,data: callback(text, data, self._mode))
 
     def keypress(self, size, key):
-        if key == 'esc':
+        if key == a_key['MODAL_ESCAPE']:
             self.mode = "NORMAL"
             return
         if self._mode == 'NORMAL' and key in self._modes:
@@ -262,7 +263,7 @@
         return True
 
     def keypress(self, size, key):
-        if key==' ' or key=='enter':
+        if key in (a_key['TEXT_SELECT'], a_key['TEXT_SELECT2']):
             self.setState(not self.__selected)
         else:
             return key
@@ -695,10 +696,10 @@
         return self.selected
 
     def keypress(self, size, key):
-        if key=='up':
+        if key==a_key['MENU_BOX_UP']:
             if self.listBox.get_focus()[1] == 0:
                 self.parent.keypress(size, key)
-        elif key=='left' or key=='right':
+        elif key in (a_key['MENU_BOX_LEFT'], a_key['MENU_BOX_RIGHT']):
             self.parent.keypress(size,'up')
             self.parent.keypress(size,key)
         return super(MenuBox,self).keypress(size,key)
@@ -756,9 +757,9 @@
         self.loop.widget = urwid.Overlay(urwid.AttrMap(menu_box,'menubar'),self.save_bottom,('fixed left', columns),max_len+2,('fixed top',1),None)
 
     def keypress(self, size, key):
-        if key == 'down':
+        if key == a_key['MENU_DOWN']:
             key = 'enter'
-        elif key == 'up':
+        elif key == a_key['MENU_UP']:
             if  self.save_bottom:
                 self.loop.widget = self.save_bottom
                 self.save_bottom = None
@@ -801,7 +802,7 @@
                 callback = menu_item[1]
                 break
         if callback:
-            self.keypress(None,'up')
+            self.keypress(None, a_key['MENU_UP'])
             callback((category, item))
 
     def onCategoryClick(self, button):
@@ -838,17 +839,17 @@
         self.columns.contents[1] = (current_menu, ('weight', 1, False))
 
     def keypress(self, size, key):
-        if key=='up':
+        if key==a_key['MENU_ROLLER_UP']:
             if self.columns.get_focus_column()==0 and self.selected > 0:
                 self.selected -= 1
                 self._showSelected()
                 return
-        elif key=='down':
+        elif key==a_key['MENU_ROLLER_DOWN']:
             if self.columns.get_focus_column()==0 and self.selected < len(self.name_list)-1:
                 self.selected += 1
                 self._showSelected()
                 return
-        elif key=='right':
+        elif key==a_key['MENU_ROLLER_RIGHT']:
             if self.columns.get_focus_column()==0 and \
                 (isinstance(self.columns.contents[1][0], urwid.Text) or \
                 self.menus[self.name_list[self.selected]].getMenuSize()==0):
@@ -975,12 +976,12 @@
             return False
 
     def keypress(self, size, key):
-        if key=='left':
+        if key==a_key['COLUMNS_ROLLER_LEFT']:
             if self.focus_column>0:
                 self.focus_column-=1
                 self._invalidate()
                 return
-        if key=='right':
+        if key==a_key['COLUMNS_ROLLER_RIGHT']:
             if self.focus_column<len(self.widget_list)-1:
                 self.focus_column+=1
                 self._invalidate()
@@ -1038,10 +1039,10 @@
         if is_mouse_press(event) and button == 1:
             _prev,_next,start_wid,end_wid,cols_left = self.__calculate_limits(size)
             if x==0 and _prev:
-                self.keypress(size,'left')
+                self.keypress(size, a_key['COLUMNS_ROLLER_LEFT'])
                 return True
             if x==maxcol-1 and _next:
-                self.keypress(size,'right')
+                self.keypress(size, a_key['COLUMNS_ROLLER_RIGHT'])
                 return True
 
             current_pos = 1 if _prev else 0
@@ -1063,7 +1064,7 @@
 
     def render(self, size, focus=False):
         if not self.widget_list:
-            return SolidCanvas(" ", size[0], 1)
+            return urwid.SolidCanvas(" ", size[0], 1)
 
         _prev,_next,start_wid,end_wid,cols_left = self.__calculate_limits(size)
 
@@ -1171,7 +1172,7 @@
         if not ret:
             return
 
-        if key == 'tab':
+        if key == a_key['FOCUS_SWITCH']:
             try:
                 self.focus_position -= 1
             except IndexError:
@@ -1192,9 +1193,6 @@
         urwid.WidgetWrap.__init__(self, self.__frame)
 
     def keypress(self, size, key):
-        if key=='tab':
-            self._w.keypress(size,key)
-            return
         return self._w.keypress(size,key)
 
     def _buttonClicked(self, button, invisible=False):