Update keypress propagation. Allow focus change by tab key. Add log property to Widget for smart logging.
authorRadek Brich <radek.brich@devl.cz>
Wed, 03 Sep 2014 19:08:21 +0200
changeset 109 105b1affc3c2
parent 97 0c2e0c09ba5c
child 110 cf3d49cdd6e2
Update keypress propagation. Allow focus change by tab key. Add log property to Widget for smart logging.
demos/03_application.py
tuikit/core/container.py
tuikit/core/signal.py
tuikit/core/theme.py
tuikit/core/widget.py
tuikit/core/window.py
tuikit/driver/curses.py
tuikit/widgets/button.py
tuikit/widgets/textbox.py
tuikit/widgets/textfield.py
--- a/demos/03_application.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/demos/03_application.py	Wed Sep 03 19:08:21 2014 +0200
@@ -10,15 +10,26 @@
 label = Label('Hello there!')
 label.pos.update(20, 10)
 
-button = Button()
-button.pos.update(20, 20)
+button1 = Button()
+button1.pos.update(20, 20)
+button2 = Button()
+button2.pos.update(30, 20)
 
 field = TextField('text field')
 field.pos.update(20, 30)
 
 app = Application()
 app.root_window.add(label)
-app.root_window.add(button)
+app.root_window.add(button1)
+app.root_window.add(button2)
 app.root_window.add(field)
-app.root_window.focus_child = field
+app.root_window.focus_widget = field
+
+def on_keypress(keyname, char, mod):
+    if keyname == 'escape':
+        app.stop()
+
+app.window_manager.sig_keypress.connect(on_keypress)
+
 app.start()
+
--- a/tuikit/core/container.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/core/container.py	Wed Sep 03 19:08:21 2014 +0200
@@ -14,19 +14,22 @@
     def __init__(self, layout_class=FixedLayout):
         Widget.__init__(self)
         #: List of child widgets.
-        self.children = []
-        self.focus_child = None
+        self._widgets = []
+        #: Widget with keyboard focus
+        self.focus_widget = None
+        #: If True, tab cycles inside container
+        self.trap_focus = False
         self.layout = layout_class()
 
     def add(self, widget):
         """Add widget into container."""
-        self.children.append(widget)
+        self._widgets.append(widget)
         widget.parent = self
         widget.window = self.window
         widget.set_theme(self.theme)
         self.layout.add(widget)
-        if self.focus_child is None:
-            self.focus_child = widget
+        if self.focus_widget is None and widget.can_focus():
+            self.focus_widget = widget
 
     def resize(self, w, h):
         Widget.resize(self, w, h)
@@ -35,7 +38,7 @@
     def draw(self, buffer):
         """Draw child widgets."""
         Widget.draw(self, buffer)
-        for child in self.children:
+        for child in self._widgets:
             with buffer.moved_origin(child.x, child.y):
                 with buffer.clip(buffer.origin.x, buffer.origin.y,
                                  child.width, child.height):
@@ -43,21 +46,75 @@
 
     def set_theme(self, theme):
         Widget.set_theme(self, theme)
-        for child in self.children:
+        for child in self._widgets:
             child.set_theme(theme)
 
     @property
     def cursor(self):
-        if self.focus_child:
-            cursor = self.focus_child.cursor
+        if self.focus_widget:
+            cursor = self.focus_widget.cursor
             if cursor is not None:
-                return cursor.moved(*self.focus_child.pos)
+                return cursor.moved(*self.focus_widget.pos)
         else:
             if self._cursor is not None:
                 return Point(self._cursor)
 
     ## input events ##
 
-    def keypress(self, keyname, char, mod=0):
-        if self.focus_child:
-            self.focus_child.keypress(keyname, char, mod)
+    def keypress(self, keyname, char, mod):
+        # First, handle the keypress event to focused child widget
+        if self.focus_widget is not None:
+            if self.focus_widget.keypress(keyname, char, mod):
+                return True
+        # Next, handle default key behaviour by Container
+        if keyname == 'tab':
+            return self.focus_next(-1 if 'shift' in mod else 1)
+        # Finally, handle default keys by Widget
+        # and send keypress signal
+        if Widget.keypress(self, keyname, char, mod):
+            return True
+
+    ## focus ##
+
+    def focus_next(self, step=1):
+        """Focus next child.
+
+        Sets focus to next child, if there is one
+        which can be focused. Cycles from last child
+        to first when needed. Return value depends on
+        this cycling:
+
+         * False means there wasn't any child to focus
+           before end of list. Focus was either not changed
+           or first child was focused.
+
+         * True when focus is set to next child in normal
+           way or when self.trap_focus is set.
+
+        Return value is supposed to be returned from keypress
+        event - in that case, True stops event propagation.
+
+        """
+        if self.focus_widget is None:
+            idx_current = 0
+        else:
+            idx_current = self._widgets.index(self.focus_widget)
+        idx_new = idx_current
+        cycled = False
+        while True:
+            idx_new += step
+            if idx_new >= len(self._widgets):
+                idx_new = 0
+                cycled = True
+            if idx_new < 0:  # for focus_previous
+                idx_new = len(self._widgets) - 1
+                cycled = True
+            if idx_current == idx_new:
+                return False
+            if self._widgets[idx_new].can_focus():
+                self.focus_widget = self._widgets[idx_new]
+                return self.trap_focus or not cycled
+
+    def focus_previous(self):
+        """Focus previous child."""
+        self.focus_next(-1)
--- a/tuikit/core/signal.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/core/signal.py	Wed Sep 03 19:08:21 2014 +0200
@@ -15,13 +15,19 @@
 
     """
 
-    def __init__(self):
+    def __init__(self, allow_stop=False):
         self._handlers = []
+        #: Allow one of the handlers to stop processing signal
+        #: The handler should return True value,
+        #: then other handlers will not be called
+        self.allow_stop = allow_stop
 
     def __call__(self, *args, **kwargs):
         """Emit the signal to all connected handlers."""
         for handler in self._handlers:
-            handler(*args, **kwargs)
+            res = handler(*args, **kwargs)
+            if self.allow_stop and res:
+                return True
 
     def connect(self, handler):
         if not handler in self._handlers:
--- a/tuikit/core/theme.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/core/theme.py	Wed Sep 03 19:08:21 2014 +0200
@@ -6,6 +6,7 @@
     """Default color style"""
 
     normal = 'lightgray'
+    active = 'black on cyan'
     button = 'black on lightgray'
     button_active = 'black on cyan'
 
--- a/tuikit/core/widget.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/core/widget.py	Wed Sep 03 19:08:21 2014 +0200
@@ -1,5 +1,6 @@
 from tuikit.core.coords import Point, Size
 from tuikit.core.theme import default_theme
+from tuikit.core.signal import Signal
 
 import logging
 
@@ -12,7 +13,6 @@
 
     def __init__(self):
         self._num_instances += 1
-        self._log = logging.getLogger(__name__)
 
         #: Widget name is used for logging etc. Not visible anywhere.
         self.name = '{}{}'.format(
@@ -44,6 +44,13 @@
         #: None means no cursor (hidden).
         self._cursor = None
 
+        #: Hidden widget does not affect layout.
+        self.hidden = False
+        #: Allow keyboard focus for this widget.
+        self.allow_focus = False
+
+        self.sig_keypress = Signal(allow_stop=True)
+
     ## position and size ##
 
     @property
@@ -73,8 +80,8 @@
 
     def draw(self, buffer):
         """Draw self into buffer."""
-        self._log.debug('%s draw into %r at %s (exposed %s)',
-                        self.name, buffer, buffer.origin, self.exposed(buffer))
+        self.log.debug('Draw into %r at %s (exposed %s)',
+                       buffer, buffer.origin, self.exposed(buffer))
 
     def set_theme(self, theme):
         self.theme = theme
@@ -99,8 +106,19 @@
     ## input events ##
 
     def keypress(self, keyname, char, mod):
-        self._log.debug('%s keypress(%r, %r, %r)',
-                        self.name, keyname, char, mod)
+        """Keypress event handler.
+
+        Override to accept keyboard input.
+
+        Returns True if event was consumed.
+
+        Call this implementation from inherited classes
+        if it does not consume the event.
+
+        """
+        if self.sig_keypress(keyname, char, mod):
+            return True
+        self.log.debug('Not consumed: keypress(%r, %r, %r)', keyname, char, mod)
 
     ## timeouts ##
 
@@ -115,3 +133,20 @@
 
         """
         self.parent.remove_timeout(self, callback, *args)
+
+    ## focus ##
+
+    def can_focus(self):
+        return not self.hidden and self.allow_focus
+
+    def has_focus(self):
+        if self.parent is None:
+            return True
+        return (self.parent.has_focus()
+            and self.parent.focus_widget == self)
+
+    ## utilities ##
+
+    @property
+    def log(self):
+        return logging.getLogger('tuikit.' + self.name)
--- a/tuikit/core/window.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/core/window.py	Wed Sep 03 19:08:21 2014 +0200
@@ -17,6 +17,8 @@
         """New buffer for the window will be created unless given existing
         `buffer` as parameter."""
         Container.__init__(self)
+        self.allow_focus = True
+        self.trap_focus = True
         self._buffer = None
         self.buffer = buffer or Buffer()
 
@@ -57,8 +59,16 @@
         Container.__init__(self)
         self.timer = timer
 
+    def draw(self, buffer):
+        Container.draw(self, buffer)
+        self.log.debug('%s has focus.', self.get_focused_widget().name)
+
     def resize(self, w, h):
-        self.children[0].resize(w, h)
+        self._widgets[0].resize(w, h)
+
+    def keypress(self, keyname, char, mod):
+        self.log.debug('keypress(%r, %r, %r)', keyname, char, mod)
+        return Container.keypress(self, keyname, char, mod)
 
     def handle_event(self, event_name, *args):
         """Handle input event to managed windows."""
@@ -67,3 +77,12 @@
             handler(*args)
         else:
             raise Exception('Unknown event: %r %r' % (event_name, args))
+
+    def get_focused_widget(self):
+        """Traverse the widget hierarchy to bottom
+        and return actually focused Widget."""
+        node = self
+        while isinstance(node, Container) and node.focus_widget:
+            node = node.focus_widget
+        return node
+
--- a/tuikit/driver/curses.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/driver/curses.py	Wed Sep 03 19:08:21 2014 +0200
@@ -43,6 +43,7 @@
         (0x44,              1,      'left'          ),
         (0x46,              1,      'end'           ),  # xterm
         (0x48,              1,      'home'          ),  # xterm
+        (0x5a,              1,      'shift+tab'     ),  # xterm
         (0x5b, 0x41,        1,      'f1'            ),  # linux
         (0x5b, 0x42,        1,      'f2'            ),  # linux
         (0x5b, 0x43,        1,      'f3'            ),  # linux
@@ -79,7 +80,7 @@
 
     def __init__(self):
         Driver.__init__(self)
-        self._log = logging.getLogger('tuikit')
+        self._log = logging.getLogger(__name__)
         self.stdscr = None
         self.cursor = None
         self.colors = {}     # maps names to curses attributes
@@ -378,7 +379,7 @@
             keyname = match[-1]
 
         if match is None:
-            self.log.debug('Unknown control sequence: %s',
+            self._log.debug('Unknown control sequence: %s',
                            ','.join(['0x%x' % x for x in consumed]))
             return [('keypress', 'Unknown', None, set())]
 
@@ -460,7 +461,7 @@
             if len(codes) == 0:
                 # no match -> unknown code
                 seq = ','.join(['0x%x' % x for x in debug_seq])
-                self.log.debug('Unknown control sequence: %s', seq)
+                self._log.debug('Unknown control sequence: %s', seq)
                 return [('keypress', 'Unknown:' + seq, None, set())]
             elif len(codes) == 1:
                 # one match -> we got the winner
@@ -483,7 +484,7 @@
         if len(matching_codes) == 0:
             # no match -> unknown code
             seq = ','.join(['0x%x' % x for x in debug_seq])
-            self.log.debug('Unknown control sequence: %s', seq)
+            self._log.debug('Unknown control sequence: %s', seq)
             return [('keypress', 'Unknown:' + seq, None, set())]
 
         if len(matching_codes) > 1:
@@ -502,6 +503,14 @@
             if mod_bits & 1<<bit:
                 mod_set.add(name)
 
+        # parse keynames in form "shift+tab"
+        if '+' in keyname:
+            parts = keyname.split('+')
+            for mod in parts[:-1]:
+                assert(mod in ('shift', 'alt', 'ctrl', 'meta'))
+                mod_set.add(mod)
+            keyname = parts[-1]
+
         return [('keypress', keyname, None, mod_set)]
 
 
--- a/tuikit/widgets/button.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/widgets/button.py	Wed Sep 03 19:08:21 2014 +0200
@@ -9,6 +9,7 @@
     def __init__(self, label='btn'):
         """Create button with given label, size according to label."""
         Widget.__init__(self)
+        self.allow_focus = True
 
         #: Button label.
         self._label = ''
@@ -21,10 +22,8 @@
         #: Padding between prefix/suffix and label
         self.padding = 1
 
-        self.allow_focus = True
-
         self.color = 'default'
-        self.color_highlighted = 'default on red'
+        self.color_active = 'default on red'
         self.highlight = False
 
         self.sig_clicked = Signal()
@@ -45,11 +44,11 @@
     def set_theme(self, theme):
         Widget.set_theme(self, theme)
         self.color = theme.button
-        self.color_highlighted = theme.button_active
+        self.color_active = theme.button_active
 
     def _get_color(self):
-        if self.highlight: # or self.has_focus():
-            return self.color_highlighted
+        if self.has_focus():
+            return self.color_active
         return self.color
 
     def draw(self, buffer):
--- a/tuikit/widgets/textbox.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/widgets/textbox.py	Wed Sep 03 19:08:21 2014 +0200
@@ -12,13 +12,12 @@
 
     def __init__(self, text=''):
         Widget.__init__(self)
+        self.allow_focus = True
 
         # Text content, splitted as lines
         self._lines = []
         self.text = text
 
-        self.allow_focus = True
-
         # Cursor position is same as spot.
         # This variable rememberes horizontal position
         # for the case when cursor moves to shorter line.
--- a/tuikit/widgets/textfield.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/widgets/textfield.py	Wed Sep 03 19:08:21 2014 +0200
@@ -7,10 +7,9 @@
 
     def __init__(self, value=''):
         Widget.__init__(self)
+        self.allow_focus = True
         self.sizereq.update(10, 1)
 
-        self.allow_focus = True
-
         self.code = locale.getpreferredencoding()
         if not isinstance(value, str):
             value = str(value, self.code)
@@ -25,11 +24,9 @@
     def resize(self, w, h):
         self.tw = self.width - 2
 
-    def set_theme(self, theme):
-        self.color = theme.normal
-
     def draw(self, buffer):
-        with buffer.attr(self.color):
+        color = self.theme.active if self.has_focus() else self.theme.normal
+        with buffer.attr(color):
             # draw value
             val = self.value + ' ' * self.tw         # add spaces to fill rest of field
             val = val[self.ofs : self.ofs + self.tw]  # cut value - begin from ofs, limit to tw chars
@@ -49,8 +46,7 @@
             self._cursor = (1 + self.curspos - self.ofs, 0)
 
     def keypress(self, keyname, char, mod=0):
-        Widget.keypress(self, keyname, char, mod)
-        accepted = True
+        consumed = True
         if keyname == 'left':
             self.move_left()
         elif keyname == 'right':
@@ -62,16 +58,17 @@
         elif keyname == 'delete':
             self.del_char()
         else:
-            accepted = False
+            consumed = False
 
         if char:
             self.add_char(char)
             self.move_right()
-            accepted = True
+            consumed = True
 
-        #if accepted:
+        if consumed:
             #self.redraw()
-        return accepted
+            return True
+        Widget.keypress(self, keyname, char, mod)
 
     def move_left(self):
         if self.curspos - self.ofs > 1 or (self.ofs == 0 and self.curspos == 1):