Merge.
authorRadek Brich <radek.brich@devl.cz>
Wed, 03 Sep 2014 21:56:20 +0200
changeset 113 6796adfdc7eb
parent 112 ce2e67e7bbb8 (current diff)
parent 108 11dac45bfba4 (diff)
child 114 26c02bd94bd9
Merge. Due to my schizophrenia, I've accidentally forked my own code. The other set of changes were found in another computer.
tuikit.conf
tuikit/core/container.py
tuikit/core/widget.py
tuikit/core/window.py
tuikit/driver/curses.py
tuikit/driver/cursesw.py
tuikit/widgets/textbox.py
tuikit/widgets/textfield.py
--- a/.hgignore	Wed Sep 03 19:17:04 2014 +0200
+++ b/.hgignore	Wed Sep 03 21:56:20 2014 +0200
@@ -1,5 +1,6 @@
 .*~$
 ^tuikit/.*\.pyc$
+^tuikit\.conf$
 ^docs/_build
 tuikit\.log
 ^build
@@ -11,5 +12,6 @@
 ^(.*/)?\.pydevproject$
 ^\.settings
 ^\.idea/
+^\.ackrc$
 .*\.appstats
 __pycache__
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/DOC	Wed Sep 03 21:56:20 2014 +0200
@@ -0,0 +1,25 @@
+LayoutManager
+  VerticalLayout
+  HorizontalLayout
+  TileLayout
+
+
+events:
+
+  draw()
+    - caller
+  on_draw()
+    - core handler, for overloading
+  connect('draw', my_draw)
+    - additional handler
+
+
+Focus
+-----
+
+ * only one node in hierarchy can have focus
+ * all parent containers have focus, so they can relay events to child
+ * top container has always focus
+
+ * grab_focus() on any node will clean old focus and set focus to this child
+ * global shortcuts can be handled in keypress handler of top widget
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/INSPIRATION	Wed Sep 03 21:56:20 2014 +0200
@@ -0,0 +1,35 @@
+Wikipedia:
+  http://en.wikipedia.org/wiki/Event_loop
+
+Python/Gnome:
+  http://developer.gnome.org/gnome-devel-demos/stable/tutorial.py.html.en
+
+PyQt4 class reference:
+  http://www.riverbankcomputing.co.uk/static/Docs/PyQt4/html/classes.html
+  http://www.riverbankcomputing.co.uk/static/Docs/PyQt4/html/qevent.html
+
+PyQt4 tutorial:
+  http://zetcode.com/tutorials/pyqt4/
+  http://zetcode.com/tutorials/pyqt4/layoutmanagement/
+
+Pyglet:
+  http://www.pyglet.org/doc/api/pyglet.event.EventDispatcher-class.html
+
+GTK:
+  http://developer.gnome.org/gtk3/stable/
+  http://python-gtk-3-tutorial.readthedocs.org/en/latest/layout.html
+
+Anchor Layout in QML:
+  http://harmattan-dev.nokia.com/docs/library/html/qt4/qml-anchor-layout.html
+
+PDCurses:
+  http://pdcurses.sourceforge.net/doc/PDCurses.txt
+
+XTerm Control Sequences
+  http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
+
+Kivy
+  http://kivy.org/
+
+Urwid
+  http://urwid.org/
--- a/demo_tableview.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/demo_tableview.py	Wed Sep 03 21:56:20 2014 +0200
@@ -6,6 +6,7 @@
 
 from tuikit import Application
 from tuikit.tableview import TableView, TableModel
+from tuikit.scrollview import ScrollView
 
 
 class MyApplication(Application):
@@ -13,20 +14,23 @@
         Application.__init__(self)
         self.top.add_handler('keypress', self.on_top_keypress)
 
-        data = []
-        for y in range(100):
-            row = [str(y+1)]
-            for x in range(10):
-                row.append('r{}:c{}'.format(y, x))
-            data.append(row)
-        model = TableModel(data)
+        model = TableModel()
+        model.set_num_headers(1, 1)
+        for col in range(10):
+            model.insert_column(col)
+            model.set_column_header(col, 0, 'col'+str(col+1))
+        for row in range(100):
+            model.insert_row(row)
+            model.set_row_header(row, 0, 'row'+str(row+1))
+            for col in range(10):
+                model.set_item(row, col, 'r{}:c{}'.format(row+1, col+1))
 
         view = TableView(model)
-        view.addcolumn(header=True, expand=False, sizereq=5)
-        for x in range(10):
-            view.addcolumn(title='head'+str(x))
 
-        self.top.add(view, halign='fill', valign='fill')
+        scroll = ScrollView()
+        scroll.add(view)
+
+        self.top.add(scroll, halign='fill', valign='fill')
 
     def on_top_keypress(self, ev):
         if ev.keyname == 'escape':
--- a/demos/04_texteditor.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/demos/04_texteditor.py	Wed Sep 03 21:56:20 2014 +0200
@@ -8,6 +8,7 @@
 
 
 class MyApplication(Application):
+
     def __init__(self):
         Application.__init__(self)
         #self.top.add_handler('keypress', self.on_top_keypress)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/terminology.rst	Wed Sep 03 21:56:20 2014 +0200
@@ -0,0 +1,18 @@
+Terminology
+===========
+
+
+Buffer
+------
+
+Buffer is rectangular offscreen area of known size.
+
+It can be drawn on the screen or into another buffer.
+
+
+Widget
+------
+
+Basic element of the application.
+
+* Can draw itself in given buffer on given position.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_tablemodel.py	Wed Sep 03 21:56:20 2014 +0200
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+import sys
+sys.path.insert(0, '..')
+
+from tuikit.tableview import *
+import unittest
+
+
+class TestTableView(unittest.TestCase):
+    def setUp(self):
+        self.abs_model = AbstractTableModel()
+        self.model = TableModel()
+
+    def test_initial(self):
+        self._test_empty_model(self.abs_model)
+        self._test_empty_model(self.model)
+
+    def test_one(self):
+        self._insert_data()
+        self._fill_data()
+        self._insert_headers()
+        self._test_empty_headers()
+        self._fill_headers()
+        self._remove_data()
+        self._clear_data()
+
+    def test_two(self):
+        self._insert_headers()
+        self._insert_data()
+        self._test_empty_headers()
+        self._fill_headers()
+        self._fill_data()
+
+    def _test_empty_model(self, model):
+        self.assertEqual(model.row_count(), 0)
+        self.assertEqual(model.column_count(), 0)
+        self.assertRaises(IndexError, model.get_row, 0)
+        self.assertRaises(IndexError, model.get_column, 0)
+        self.assertRaises(IndexError, model.get_item, 0, 0)
+
+    def _insert_data(self):
+        for _i in range(3):
+            self.model.insert_row(0)
+            self.model.insert_column(0)
+        self.assertEqual(self.model.row_count(), 3)
+        self.assertEqual(self.model.column_count(), 3)
+
+    def _fill_data(self):
+        for row in range(3):
+            for col in range(3):
+                self.model.set_item(row, col, row*3 + col+1)
+        self.assertEqual(self.model.get_row(0), [1,2,3])
+        self.assertEqual(self.model.get_row(1), [4,5,6])
+        self.assertEqual(self.model.get_row(2), [7,8,9])
+        self.assertEqual(self.model.get_column(0), [1,4,7])
+
+    def _insert_headers(self):
+        self.model.set_num_headers(2, 2)
+        self.assertEqual(self.model.row_header_count(), 2)
+        self.assertEqual(self.model.column_header_count(), 2)
+
+    def _test_empty_headers(self):
+        self.assertEqual(self.model.get_row_header(0, 0), None)
+        self.assertEqual(self.model.get_row_header(2, 1), None)
+        self.assertEqual(self.model.get_column_header(0, 0), None)
+        self.assertEqual(self.model.get_column_header(2, 1), None)
+
+    def _fill_headers(self):
+        for i in range(3):
+            for header in range(2):
+                self.model.set_column_header(i, header, header*3 + i + 1)
+                self.model.set_row_header(i, header, header*3 + i + 1)
+        self.assertEqual(self.model.get_column_header(1, 0), 2)
+        self.assertEqual(self.model.get_column_header(1, 1), 5)
+        self.assertEqual(self.model.get_row_header(1, 0), 2)
+        self.assertEqual(self.model.get_row_header(1, 1), 5)
+
+    def _remove_data(self):
+        self.model.remove_row(1)
+        self.assertEqual(self.model.get_row(0), [1,2,3])
+        self.assertEqual(self.model.get_row(1), [7,8,9])
+        self.assertEqual(self.model.get_column(0), [1,7])
+        self.model.remove_column(1)
+        self.assertEqual(self.model.get_row(0), [1,3])
+        self.assertEqual(self.model.get_row(1), [7,9])
+        self.assertEqual(self.model.get_column(1), [3,9])
+        self.assertRaises(IndexError, self.model.get_row, 2)
+        self.assertRaises(IndexError, self.model.get_column, 2)
+
+    def _clear_data(self):
+        self.model.clear()
+
+
+if __name__ == '__main__':
+    unittest.main()
+
--- a/tuikit.conf	Wed Sep 03 19:17:04 2014 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-# rendering driver: curses | sdl (default: curses)
-#driver='sdl'
-
-# log level: info | debug (default: info)
-#log_level='debug'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tuikit.conf.example	Wed Sep 03 21:56:20 2014 +0200
@@ -0,0 +1,5 @@
+# rendering driver: curses | sdl (default: curses)
+#driver='sdl'
+
+# log level: info | debug (default: info)
+#log_level='debug'
--- a/tuikit/core/buffer.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/core/buffer.py	Wed Sep 03 21:56:20 2014 +0200
@@ -184,7 +184,7 @@
     @property
     def size(self):
         """Width and height of buffer, in characters."""
-        return self._size.readonly()
+        return self._size.immutable()
 
     def resize(self, w, h):
         """Resize buffer."""
--- a/tuikit/core/container.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/core/container.py	Wed Sep 03 21:56:20 2014 +0200
@@ -1,5 +1,5 @@
 from tuikit.core.widget import Widget
-from tuikit.core.coords import Point
+from tuikit.core.coords import Point, Rect
 from tuikit.layouts.fixed import FixedLayout
 
 
@@ -17,6 +17,8 @@
         self._widgets = []
         #: Widget with keyboard focus
         self.focus_widget = None
+        #: Widget on last mouse position
+        self.mouse_widget = None
         #: If True, tab cycles inside container
         self.trap_focus = False
         self.layout = layout_class()
@@ -51,11 +53,21 @@
 
     @property
     def cursor(self):
+        """Return cursor coordinates or None if cursor is not set
+        or is set outside of widget boundaries.
+
+        If this container has child with focus, return its cursor position instead.
+
+        """
         if self.focus_widget:
             cursor = self.focus_widget.cursor
-            return cursor.moved(*self.focus_widget.pos)
+            if not cursor:
+                return None
+            cursor = cursor.moved(*self.focus_widget.pos)
         else:
-            return self._cursor
+            cursor = self._cursor.immutable()
+        if cursor in Rect._make((0, 0), self._size):
+            return cursor
 
     @property
     def cursor_visible(self):
@@ -79,6 +91,22 @@
         if Widget.keypress_event(self, ev):
             return True
 
+    def mousedown(self, button, pos):
+        self.mouse_child = None
+        for child in reversed(self.children):
+            if pos in child.boundaries:
+                child.mousedown(button, pos - child.pos)
+                self.mouse_child = child
+
+    def mouseup(self, button, pos):
+        if self.mouse_child:
+            self.mouse_child.mouseup(button, pos - self.mouse_child.pos)
+
+    def mousemove(self, button, pos, relpos):
+        if self.mouse_child:
+            self.mouse_child.mousemove(button,
+                pos - self.mouse_child.pos, relpos)
+
     ## focus ##
 
     def focus_next(self, step=1):
--- a/tuikit/core/coords.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/core/coords.py	Wed Sep 03 21:56:20 2014 +0200
@@ -1,9 +1,12 @@
+
 class Point:
 
     """Point in cartesian space.
 
     Implements attribute access (.x, .y) and list-like access([0],[1]).
 
+    This is heavy-weight mutable object. See also ImmutablePoint.
+
     """
 
     def __init__(self, *args, **kwargs):
@@ -11,12 +14,6 @@
         self.y = 0
         self.update(*args, **kwargs)
 
-    def __getitem__(self, key):
-        return (self.x, self.y)[key]
-
-    def __repr__(self):
-        return 'Point(x={0.x},y={0.y})'.format(self)
-
     def move(self, relx, rely):
         self.x += relx
         self.y += rely
@@ -46,6 +43,79 @@
             else:
                 raise ValueError('Bad keyword arg: %r' % key)
 
+    # sequence interface
+
+    def __len__(self):
+        return 2
+
+    def __getitem__(self, key):
+        return (self.x, self.y)[key]
+
+    # point arithmetics
+
+    def __add__(self, other):
+        return Point(self.x + other[0], self.y + other[1])
+
+    def __sub__(self, other):
+        return Point(self.x - other[0], self.y - other[1])
+
+    def __eq__(self, other):
+        """Comparison operator.
+
+        Point can be compared to any sequence of at least two elements:
+
+        >>> p = Point(1, 2)
+        >>> p == Point(1, 2)
+        True
+        >>> p == (1, 2)
+        True
+        >>> p == (0, 0)
+        False
+        >>> p == None
+        False
+
+        """
+        try:
+            return self.x == other[0] and self.y == other[1]
+        except (TypeError, IndexError):
+            return False
+
+    # string representation
+
+    def __repr__(self):
+        return '{0.__class__.__name__}(x={0.x},y={0.y})'.format(self)
+
+    def immutable(self):
+        return ImmutablePoint(*self)
+
+
+class ImmutablePoint:
+
+    """Point class without write access."""
+
+    __slots__ = ('_x', '_y')
+
+    def __init__(self, x, y):
+        self._x = x
+        self._y = y
+
+    @property
+    def x(self):
+        return self._x
+
+    @property
+    def y(self):
+        return self._y
+
+    def moved(self, relx, rely):
+        return ImmutablePoint(self.x + relx, self.y + rely)
+
+    def __getitem__(self, key):
+        return (self.x, self.y)[key]
+
+    def __repr__(self):
+        return '{0.__class__.__name__}(x={0.x}, y={0.y})'.format(self)
+
 
 class Size:
 
@@ -53,6 +123,8 @@
 
     Implements attribute access (.w, .h) and list-like access([0],[1]).
 
+    This is heavy-weight mutable object. See also ImmutableSize.
+
     """
 
     def __init__(self, *args, **kwargs):
@@ -64,7 +136,7 @@
         return (self.w, self.h)[key]
 
     def __repr__(self):
-        return 'Size(w={0.w},h={0.h})'.format(self)
+        return '{0.__class__.__name__}(w={0.w},h={0.h})'.format(self)
 
     def update(self, *args, **kwargs):
         """Update size.
@@ -88,30 +160,40 @@
             else:
                 raise ValueError('Bad keyword arg: %r' % key)
 
-    def readonly(self):
-        return ReadonlySize(self)
+    def immutable(self):
+        return ImmutableSize(*self)
 
 
-class ReadonlySize:
+class ImmutableSize:
+
+    """Size class without write access.
 
-    """Wrapper for Size which makes it read-only."""
+    When using reference to (mutable) Size class, properties are not fixed.
+    They can still be changed by original owner and these changes will become
+    visible to other references of the object. ImmutableSize should be used
+    when this is not intended.
 
-    def __init__(self, size):
-        self._size = size
+    """
+
+    __slots__ = ('_w', '_h')
+
+    def __init__(self, w, h):
+        self._w = w
+        self._h = h
 
     @property
     def w(self):
-        return self._size.w
+        return self._w
 
     @property
     def h(self):
-        return self._size.h
+        return self._h
 
     def __getitem__(self, key):
-        return self._size[key]
+        return (self.w, self.h)[key]
 
     def __repr__(self):
-        return 'ReadonlySize(w={0.w},h={0.h})'.format(self._size)
+        return '{0.__class__.__name__}(w={0.w}, h={0.h})'.format(self)
 
 
 class Rect:
@@ -124,8 +206,20 @@
         self.w = w
         self.h = h
 
+    @classmethod
+    def _make(cls, origin, size):
+        """Make new Rect instance with origin and size as specified.
+
+        `origin` should be Point or pair of coordinates,
+        `size` should be Size or pair of integers
+
+        """
+        x, y = origin
+        w, h = size
+        return Rect(x, y, w, h)
+
     def __repr__(self):
-        return 'Rect(x={0.x},y={0.y},w={0.w},h={0.h})'.format(self)
+        return '{0.__class__.__name__}(x={0.x},y={0.y},w={0.w},h={0.h})'.format(self)
 
     def __contains__(self, point):
         """Test if point is positioned inside rectangle.
--- a/tuikit/core/widget.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/core/widget.py	Wed Sep 03 21:56:20 2014 +0200
@@ -1,4 +1,4 @@
-from tuikit.core.coords import Point, Size
+from tuikit.core.coords import Point, Size, Rect
 from tuikit.core.theme import default_theme
 from tuikit.core.signal import Signal
 
@@ -15,9 +15,7 @@
         self._num_instances += 1
 
         #: Widget name is used for logging etc. Not visible anywhere.
-        self.name = '{}{}'.format(
-            self.__class__.__name__,
-            self._num_instances)
+        self.name = '%s#%s' % (self.__class__.__name__, self._num_instances)
 
         #: Parent Widget
         self.parent = None
@@ -40,8 +38,9 @@
         self.sizemax = Size(None, None)
 
         #: Cursor is position where text input will occur.
+        #: The cursor coordinates are relative to widget.
         self._cursor = Point()
-        #: Cursor is displayed on screen only if the widget is focused.
+        #: Cursor is displayed on screen only when the widget is focused.
         self._cursor_visible = False
 
         #: Hidden widget does not affect layout.
@@ -71,11 +70,15 @@
 
     @property
     def size(self):
-        return self._size.readonly()
+        return self._size.immutable()
 
     def resize(self, w, h):
         self._size.update(w, h)
 
+    @property
+    def boundaries(self):
+        return Rect._make(self.pos, self._size)
+
     ## drawing, looks ##
 
     def draw(self, buffer):
@@ -100,7 +103,13 @@
 
     @property
     def cursor(self):
-        return self._cursor
+        """Return cursor coordinates.
+        
+        Returns None if cursor is set outside of widget boundaries.
+        
+        """
+        if self._cursor in Rect._make((0, 0), self._size):
+            return self._cursor
 
     @property
     def cursor_visible(self):
@@ -126,6 +135,18 @@
             return True
         self.log.debug('Not consumed: %s', ev)
 
+    def mousedown(self, button, pos):
+        self._log.debug('mousedown(btn=%r, pos=%r)',
+                        button, pos)
+
+    def mouseup(self, button, pos):
+        self._log.debug('mouseup(btn=%r, pos=%r)',
+                        button, pos)
+
+    def mousemove(self, button, pos, relpos):
+        self._log.debug('mousemove(btn=%r, pos=%r, relpos=%r)',
+                        button, pos, relpos)
+
     ## timeouts ##
 
     def add_timeout(self, delay, callback, *args):
@@ -155,4 +176,9 @@
 
     @property
     def log(self):
-        return logging.getLogger('tuikit.' + self.name)
+        """Logger for widget debugging.
+        
+        Logger name contains full module name, class name and instance number.
+        
+        """
+        return logging.getLogger('%s.%s' % (self.__module__, self.name))
--- a/tuikit/core/window.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/core/window.py	Wed Sep 03 21:56:20 2014 +0200
@@ -64,6 +64,7 @@
         self.log.debug('%s has focus.', self.get_focused_widget().name)
 
     def resize(self, w, h):
+        Container.resize(self, w, h)
         self._widgets[0].resize(w, h)
 
     def keypress_event(self, ev):
@@ -72,6 +73,7 @@
 
     def handle_event(self, event):
         """Handle input event to managed windows."""
+        self.log.debug('Handle event: %r', event)
         handler = getattr(self, event.name + '_event', None)
         if handler:
             handler(event)
--- a/tuikit/driver/curses.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/driver/curses.py	Wed Sep 03 21:56:20 2014 +0200
@@ -3,6 +3,7 @@
 import logging
 
 from tuikit.driver.driver import Driver
+from tuikit.core.coords import Point
 
 
 class CursesDriver(Driver):
@@ -88,7 +89,7 @@
         self.colorstack = [] # pushcolor/popcolor puts or gets attributes from this
         self.inputqueue = []
         self.mbtnstack = []
-        self._mouse_last_pos = (None, None)
+        self._mouse_last_pos = None  # Point
         self._mouse_last_bstate = None
 
     ## initialization, finalization ##
@@ -280,15 +281,15 @@
         except curses.error:
             return []
 
+        pos = Point(x, y)
         out = []
 
         if bstate & curses.REPORT_MOUSE_POSITION:
-            if self._mouse_last_pos != (x, y):
-                if self._mouse_last_pos[0] is not None:
-                    relx = x - (self._mouse_last_pos[0] or 0)
-                    rely = y - (self._mouse_last_pos[1] or 0)
-                    out += [('mousemove', 0, x, y, relx, rely)]
-                self._mouse_last_pos = (x, y)
+            if self._mouse_last_pos != pos:
+                if self._mouse_last_pos:
+                    relpos = pos - self._mouse_last_pos
+                    out += [('mousemove', 0, pos, relpos)]
+                self._mouse_last_pos = pos
 
         # we are interested only in changes, not buttons already pressed before event
         if self._mouse_last_bstate is not None:
@@ -300,21 +301,21 @@
             self._mouse_last_bstate = bstate
 
         if bstate & curses.BUTTON1_PRESSED:
-            out += [('mousedown', 1, x, y)]
+            out += [('mousedown', 1, pos)]
         if bstate & curses.BUTTON2_PRESSED:
-            out += [('mousedown', 2, x, y)]
+            out += [('mousedown', 2, pos)]
         if bstate & curses.BUTTON3_PRESSED:
-            out += [('mousedown', 3, x, y)]
+            out += [('mousedown', 3, pos)]
         if bstate & curses.BUTTON1_RELEASED:
-            out += [('mouseup', 1, x, y)]
+            out += [('mouseup', 1, pos)]
         if bstate & curses.BUTTON2_RELEASED:
-            out += [('mouseup', 2, x, y)]
+            out += [('mouseup', 2, pos)]
         if bstate & curses.BUTTON3_RELEASED:
-            out += [('mouseup', 3, x, y)]
+            out += [('mouseup', 3, pos)]
 
         # reset last pos when pressed/released
         if len(out) > 0 and out[-1][0] in ('mousedown', 'mouseup'):
-            self._mouse_last_pos = (None, None)
+            self._mouse_last_pos = None
 
         return out
 
@@ -395,6 +396,7 @@
         t = self._inputqueue_get_wait()
         x = self._inputqueue_get_wait() - 0x21
         y = self._inputqueue_get_wait() - 0x21
+        pos = Point(x, y)
 
         out = []
 
@@ -403,25 +405,24 @@
             btn = t - 0x1f
             if not btn in self.mbtnstack:
                 self.mbtnstack.append(btn)
-                self._mouse_last_pos = (None, None)
-                out += [('mousedown', btn, x, y)]
+                self._mouse_last_pos = None
+                out += [('mousedown', btn, pos)]
             else:
                 # mouse move
-                if self._mouse_last_pos != (x, y):
-                    if self._mouse_last_pos[0] is not None:
-                        relx = x - self._mouse_last_pos[0]
-                        rely = y - self._mouse_last_pos[1]
-                        out += [('mousemove', btn, x, y, relx, rely)]
-                    self._mouse_last_pos = (x, y)
+                if self._mouse_last_pos != pos:
+                    if self._mouse_last_pos:
+                        relpos = pos - self._mouse_last_pos
+                        out += [('mousemove', btn, pos, relpos)]
+                    self._mouse_last_pos = pos
         elif t == 0x23:
             # button release
             btn = self.mbtnstack.pop()
             self._mouse_last_pos = (None, None)
-            out += [('mouseup', btn, x, y)]
+            out += [('mouseup', btn, pos)]
         elif t in (0x60, 0x61):
             # wheel up, down
             btn = 4 + t - 0x60
-            out += [('mousewheel', btn, x, y)]
+            out += [('mousewheel', btn, pos)]
         else:
             raise Exception('Unknown mouse event: %x' % t)
 
--- a/tuikit/driver/cursesw.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/driver/cursesw.py	Wed Sep 03 21:56:20 2014 +0200
@@ -4,6 +4,7 @@
 
 from tuikit.driver.driver import Driver
 from tuikit.core.events import ResizeEvent, KeypressEvent
+from tuikit.core.coords import Point
 
 
 class CursesWDriver(Driver):
@@ -65,7 +66,7 @@
         self.cursor = None
         self.colors = {}     # maps names to curses attributes
         self.colorpairs = {} # maps tuple (fg,bg) to curses color_pair
-        self._mouse_last_pos = (None, None)
+        self._mouse_last_pos = None  # Point
         self._mouse_last_bstate = None
 
     ## initialization, finalization ##
@@ -205,13 +206,13 @@
         except curses.error:
             return out
 
+        pos = Point(x, y)
         if bstate & curses.REPORT_MOUSE_POSITION:
-            if self._mouse_last_pos != (x, y):
-                if self._mouse_last_pos[0] is not None:
-                    relx = x - (self._mouse_last_pos[0] or 0)
-                    rely = y - (self._mouse_last_pos[1] or 0)
-                    out += [('mousemove', 0, x, y, relx, rely)]
-                self._mouse_last_pos = (x, y)
+            if self._mouse_last_pos != pos:
+                if self._mouse_last_pos:
+                    relpos = pos - self._mouse_last_pos
+                    out += [('mousemove', 0, pos, relpos)]
+                self._mouse_last_pos = pos
 
         # we are interested only in changes, not buttons already pressed before event
         if self._mouse_last_bstate is not None:
@@ -223,21 +224,21 @@
             self._mouse_last_bstate = bstate
 
         if bstate & curses.BUTTON1_PRESSED:
-            out += [('mousedown', 1, x, y)]
+            out += [('mousedown', 1, pos)]
         if bstate & curses.BUTTON2_PRESSED:
-            out += [('mousedown', 2, x, y)]
+            out += [('mousedown', 2, pos)]
         if bstate & curses.BUTTON3_PRESSED:
-            out += [('mousedown', 3, x, y)]
+            out += [('mousedown', 3, pos)]
         if bstate & curses.BUTTON1_RELEASED:
-            out += [('mouseup', 1, x, y)]
+            out += [('mouseup', 1, pos)]
         if bstate & curses.BUTTON2_RELEASED:
-            out += [('mouseup', 2, x, y)]
+            out += [('mouseup', 2, pos)]
         if bstate & curses.BUTTON3_RELEASED:
-            out += [('mouseup', 3, x, y)]
+            out += [('mouseup', 3, pos)]
 
         # reset last pos when pressed/released
         if len(out) > 0 and out[-1][0] in ('mousedown', 'mouseup'):
-            self._mouse_last_pos = (None, None)
+            self._mouse_last_pos = None
 
         return out
 
--- a/tuikit/tableview.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/tableview.py	Wed Sep 03 21:56:20 2014 +0200
@@ -1,179 +1,269 @@
 # -*- coding: utf-8 -*-
 
-import math
-import logging
-
 from tuikit.events import Event, Emitter
 from tuikit.widget import Widget
 from tuikit.common import Coords
 
 
-class TableModel(Emitter):
-    def __init__(self, list_of_lists):
+class AbstractTableModel(Emitter):
+    def __init__(self):
         self.add_events('change', Event)
-        self.data = list_of_lists
+
+    ## accessing data
+
+    def get_row(self, row_idx):
+        """Get row by index.
+
+        Row is list of items of any type.
+
+        """
+        raise IndexError()
+
+    def get_column(self, column_idx):
+        """Get column by index (list of items)."""
+        raise IndexError()
+
+    def get_item(self, row_idx, column_idx):
+        """Get item by row and column index."""
+        return self.get_row(row_idx)[column_idx]
+
+    def get_row_header(self, row_idx, header_idx):
+        raise IndexError()
+
+    def get_column_header(self, column_idx, header_idx):
+        raise IndexError()
+
+    ## filling data
+
+    def set_item(self, row_idx, column_idx, value):
+        """Set item by row and column index."""
+        self.get_row(row_idx)[column_idx] = value
+        self.emit('change')
+
+    def set_row_header(self, row_idx, header_idx, value):
+        raise NotImplementedError()
 
-    def getcount(self):
-        '''Get number of rows.'''
-        return len(self.data)
+    def set_column_header(self, column_idx, header_idx, value):
+        raise NotImplementedError()
+
+    ## reading sizes
+
+    def row_count(self):
+        """Get number of rows."""
+        return 0
+
+    def column_count(self):
+        """Get number of columns."""
+        return 0
+
+    def row_header_count(self):
+        """Get number of row headers (header columns on left side of table)"""
+        return 0
+
+    def column_header_count(self):
+        """Get number of column headers (header rows on top of table)"""
+        return 0
 
-    def getrows(self, begin, end):
-        '''Get rows from begin to end, including begin, excluding end.'''
-        return self.data[begin:end]
+    ## changing size
+
+    def insert_row(self, row_idx):
+        raise NotImplementedError()
+
+    def remove_row(self, row_idx):
+        raise NotImplementedError()
 
-    def update(self, row, col, val):
-        self.data[row][col] = val
+    def insert_column(self, column_idx):
+        raise NotImplementedError()
+
+    def remove_column(self, column_idx):
+        raise NotImplementedError()
+
+    def set_num_headers(self, row_header_count, column_header_count):
+        """Set number of rows headers and column headers."""
+        raise NotImplementedError()
+
+    def clear(self):
+        raise NotImplementedError()
 
 
-class Column:
-
-    '''Columns description.'''
+class TableModel(AbstractTableModel):
+    def __init__(self):
+        AbstractTableModel.__init__(self)
+        # list of lists of items
+        self._num_cols = 0
+        self._num_col_headers = 0
+        self._num_row_headers = 0
+        self._rows = []
+        self._col_header_rows = []
+        self._row_header_rows = []
 
-    def __init__(self, title='', header=False, expand=True, sizereq=1,
-        readonly=False, maxlength=None):
-        '''Create column with default values.'''
+    ## accessing data
+
+    def get_row(self, row_idx):
+        """Get row by index."""
+        return self._rows[row_idx]
+
+    def get_column(self, column_idx):
+        """Get column by index (list of items)."""
+        if column_idx >= self._num_cols or column_idx < 0:
+            raise IndexError()
+        return [row[column_idx] for row in self._rows]
 
-        self.title = title
-        '''Title is displayed in heading before first row.'''
+    def get_row_header(self, row_idx, header_idx):
+        return self._row_header_rows[row_idx][header_idx]
+
+    def get_column_header(self, column_idx, header_idx):
+        return self._col_header_rows[header_idx][column_idx]
+
+    ## filling data
+
+    def set_row_header(self, row_idx, header_idx, value):
+        self._row_header_rows[row_idx][header_idx] = value
+        self.emit('change')
 
-        self.header = header
-        '''Header column is highlighted, values in this column cannot be edited.'''
+    def set_column_header(self, column_idx, header_idx, value):
+        self._col_header_rows[header_idx][column_idx] = value
+        self.emit('change')
+
+    ## reading sizes
 
-        self.expand = expand
-        '''If true, this column will autoresize to consume any free space.'''
+    def row_count(self):
+        return len(self._rows)
+
+    def column_count(self):
+        return self._num_cols
 
-        self.sizereq = sizereq
-        '''Size request. Meaning depends on value of expand:
+    def row_header_count(self):
+        return self._num_row_headers
+
+    def column_header_count(self):
+        return self._num_col_headers
+
+    ## changing size
 
-        When false, sizereq is number of characters.
-        When true, sizereq is relative size ratio.
+    def insert_row(self, row_idx):
+        row = [None] * self._num_cols
+        self._rows.insert(row_idx, row)
+        self._row_header_rows.insert(row_idx, [None] * self._num_row_headers)
+        self.emit('change')
 
-        '''
+    def remove_row(self, row_idx):
+        del self._rows[row_idx]
+        del self._row_header_rows[row_idx]
+        self.emit('change')
 
-        self.size = None
-        '''Computed size of column.'''
+    def insert_column(self, column_idx):
+        for row in self._col_header_rows:
+            row.insert(column_idx, None)
+        for row in self._rows:
+            row.insert(column_idx, None)
+        self._num_cols += 1
+        self.emit('change')
 
-        self.index = None
-        '''Computed index.'''
+    def remove_column(self, column_idx):
+        for row in self._col_header_rows:
+            del row[column_idx]
+        for row in self._rows:
+            del row[column_idx]
+        self._num_cols -= 1
+        self.emit('change')
 
-        self.readonly = readonly
-        '''If not readonly, values in this column can be changed by user.'''
+    def set_num_headers(self, row_header_count, column_header_count):
+        """Set number of rows headers and column headers."""
+        self._num_col_headers = column_header_count
+        self._num_row_headers = row_header_count
+        self._col_header_rows = []
+        for _i in range(self._num_col_headers):
+            self._col_header_rows.append([None] * self._num_cols)
+        for row in range(len(self._row_header_rows)):
+            self._row_header_rows[row] = [None] * self._num_row_headers
+        self.emit('change')
 
-        self.maxlength = maxlength
-        '''Maximum length of value (for EditField).'''
+    def clear(self):
+        self._rows = []
+        self._row_header_rows = []
+        self._num_cols = 0
+        self.set_num_headers(0, 0)
 
 
 class TableView(Widget):
     def __init__(self, model=None):
         Widget.__init__(self)
         self._default_size.update(20, 20)
+        self.allow_focus = True
 
-        self.allow_focus = True
+        # widths of colums, including header columns
+        self.column_sizes = []
+
+        self.spacing = 1
+
+        #: Active cell (cursor)
+        self.acell = Coords()
 
         # model
         self._model = None
-        self.setmodel(model)
-
-        self.columns = []
-        self.spacing = 1
-        self.rowcount = 0
-        self.headsize = 1
+        self.model = model
 
-        self.offset = Coords()
-        #: Active cell (cursor)
-        self.acell = Coords()
+        self.add_events('scroll', Event)
 
-        self.add_events(
-            'scroll', Event,
-            'areasize', Event)
-
-    def getmodel(self):
+    @property
+    def model(self):
         return self._model
 
-    def setmodel(self, value):
+    @model.setter
+    def model(self, value):
         if self._model:
             self._model.remove_handler('change', self.redraw)
         self._model = value
         if self._model:
             self._model.add_handler('change', self.redraw)
-
-    model = property(getmodel, setmodel)
-
-    def addcolumn(self, *args, **kwargs):
-        for col in args:
-            self.columns.append(col)
-        if len(args) == 0:
-            col = Column(**kwargs)
-            self.columns.append(col)
+        self._update_sizes()
 
-    def compute_column_sizes(self):
-        total_space = self.width - self.spacing * len(self.columns)
-        no_expand_cols = [col for col in self.columns if not col.expand]
-        no_expand_size = sum([col.sizereq for col in no_expand_cols])
-        expand_cols = [col for col in self.columns if col.expand]
-        expand_num = len(expand_cols)
-        expand_size = total_space - no_expand_size
-
-        # compute size of cols without expand
-        for col in no_expand_cols:
-            col.size = col.sizereq
+    def _update_sizes(self):
+        for _i in range(self._model.row_header_count()):
+            self.column_sizes.append(6)
+        for _i in range(self._model.column_count()):
+            self.column_sizes.append(8)
+        width = sum(self.column_sizes) + self.spacing * (len(self.column_sizes) - 1)
+        height = self._model.row_count() + self._model.column_header_count()
+        self._default_size.update(w=width, h=height)
 
-        # compute size of cols with expand
-        if no_expand_size > total_space + expand_num:
-            for col in expand_cols:
-                col.size = 1
-        else:
-            total_req = sum([col.sizereq for col in expand_cols])
-            remaining_space = 0.
-            for col in expand_cols:
-                frac, intp = math.modf(expand_size * col.sizereq / total_req)
-                col.size = int(intp)
-                remaining_space += frac
-                if remaining_space > 0.99:
-                    remaining_space -= 1.
-                    col.size += 1
+    def _draw_column_headers(self, driver, x, y):
+        for col_idx, col_size in enumerate(self.column_sizes[self._model.row_header_count():]):
+            for header_idx in range(self._model.column_header_count()):
+                title = str(self._model.get_column_header(col_idx, header_idx))
+                driver.puts(x, y + header_idx, title[:col_size])
+            x += col_size + self.spacing
 
-        # compute indexes
-        idx = 0
-        for col in self.columns:
-            if not col.header:
-                col.index = idx
-                idx += 1
+    def _draw_row_headers(self, driver, x, y):
+        for row_idx in range(self._model.row_count()):
+            hx = x
+            for header_idx in range(self._model.row_header_count()):
+                col_size = self.column_sizes[header_idx]
+                title = str(self._model.get_row_header(row_idx, header_idx))
+                driver.puts(hx, y + row_idx, title[:col_size])
+                hx += self.spacing + col_size
 
-    def draw_head(self, screen, x, y):
-        screen.pushcolor('strong')
-        for col in self.columns:
-            screen.puts(x, y, col.title[:col.size])
-            x += col.size + self.spacing
-        screen.popcolor()
-
-    def draw_row(self, screen, x, y, row, highlight):
-        for col, data in zip(self.columns, row):
-            if col.header:
-                screen.pushcolor('strong')
-            elif col.index in highlight:
-                screen.pushcolor('active')
-            else:
-                screen.pushcolor('normal')
-            screen.puts(x, y, data[:col.size])
-            screen.popcolor()
-            x += col.size + self.spacing
+    def _draw_data(self, driver, x, y):
+        for col_idx, col_size in enumerate(self.column_sizes[self._model.row_header_count():]):
+            for row_idx in range(self._model.row_count()):
+                title = str(self._model.get_item(row_idx, col_idx))
+                driver.puts(x, y + row_idx, title[:col_size])
+            x += col_size + self.spacing
 
     def on_draw(self, ev):
+        # compute basic sizes
+        row_header_count = self._model.row_header_count()
+        row_header_skip = sum(self.column_sizes[:row_header_count]) + self.spacing * row_header_count
+        column_header_skip = self._model.column_header_count()
+        # draw
         ev.driver.pushcolor('normal')
         ev.driver.fill_clip()
-        self.rowcount = self.model.getcount()
-        numrows = min(self.rowcount - self.offset.y, self.height - self.headsize)
-        rows = self.model.getrows(self.offset.y, self.offset.y + numrows)
-        self.compute_column_sizes()
-        self.draw_head(ev.driver, ev.x, ev.y)
-        y = ev.y + self.headsize
-        for row in rows:
-            highlight = []
-            if self.offset.y + rows.index(row) == self.acell.y:
-                highlight.append(self.acell.x)
-            self.draw_row(ev.driver, ev.x, y, row, highlight)
-            y += 1
+        ev.driver.pushcolor('strong')
+        self._draw_column_headers(ev.driver, ev.x + row_header_skip, ev.y)
+        self._draw_row_headers(ev.driver, ev.x, ev.y + column_header_skip)
+        ev.driver.popcolor()
+        self._draw_data(ev.driver, ev.x + row_header_skip, ev.y + column_header_skip)
         ev.driver.popcolor()
 
     def on_keypress(self, ev):
@@ -189,48 +279,39 @@
             self.redraw()
             return True
 
-    def set_yofs(self, yofs):
-        if yofs > self.rowcount - (self.height - self.headsize):
-            yofs = self.rowcount - (self.height - self.headsize)
-        if yofs < 0:
-            yofs = 0
-        self.offset.y = yofs
-        self.emit('scroll')
-
     def move_up(self):
         if self.acell.y > 0:
             self.acell.y -= 1
-            if self.acell.y < self.offset.y:
-                self.set_yofs(self.acell.y)
+            self.spot.y = self.acell.y + self._model.column_header_count()
             return True
         return False
 
     def move_down(self):
-        log=logging.getLogger('tuikit')
-        log.debug('height %d', self.height)
-        if self.acell.y < self.rowcount - 1:
+        if self.acell.y < self._model.row_count() - 1:
             self.acell.y += 1
-            if self.acell.y > self.offset.y + (self.height - self.headsize - 1):
-                self.set_yofs(self.acell.y - (self.height - self.headsize - 1))
+            self.spot.y = self.acell.y + self._model.column_header_count()
             return True
         return False
 
     def move_pageup(self):
-        if self.acell.y >= self.height - self.headsize - 1:
-            self.acell.y -= self.height - self.headsize - 1
-            self.set_yofs(self.offset.y - (self.height - self.headsize - 1))
+        view_height = self.view_height - self._model.column_header_count()
+        if self.acell.y >= view_height - 1:
+            self.emit('scrollreq', - (view_height - 1))
+            self.acell.y -= view_height - 1
+            self.spot.y = self.acell.y + self._model.column_header_count()
         else:
             self.acell.y = 0
-            self.set_yofs(0)
-
+            self.spot.y = self._model.column_header_count()
 
     def move_pagedown(self):
-        if self.acell.y <= self.rowcount - (self.height - self.headsize - 1):
-            self.acell.y += self.height - self.headsize - 1
-            self.set_yofs(self.offset.y + (self.height - self.headsize - 1))
+        view_height = self.view_height - self._model.column_header_count()
+        if self.acell.y <= self._model.row_count() - (view_height - 1):
+            self.emit('scrollreq', (self.view_height - 1))
+            self.acell.y += view_height - 1
+            self.spot.y = self.acell.y + self._model.column_header_count()
         else:
-            self.acell.y = self.rowcount - 1
-            self.set_yofs(self.acell.y)
+            self.acell.y = self._model.row_count() - 1
+            self.spot.y = self.acell.y + self._model.column_header_count()
 
     def move_left(self):
         if self.acell.x > 0:
--- a/tuikit/widgets/textbox.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/widgets/textbox.py	Wed Sep 03 21:56:20 2014 +0200
@@ -15,7 +15,7 @@
         self.text = text
 
         self._cursor_visible = True
-        # This variable rememberes horizontal cursor position
+        # This variable remembers horizontal cursor position
         # for the case when cursor moves to shorter line.
         self.cursor_column = 0
         # selection - line and column of selection start
--- a/tuikit/widgets/textfield.py	Wed Sep 03 19:17:04 2014 +0200
+++ b/tuikit/widgets/textfield.py	Wed Sep 03 21:56:20 2014 +0200
@@ -18,13 +18,16 @@
         self.maxlen = None   # unlimited
 
         self.tw = 0       # real width of text field (minus space for arrows)
-        self.curspos = len(value)      # position of cursor in value
+        self.curspos = 0  # position of cursor in value
         self.ofs = 0      # position of value beginning on screen
 
         self._cursor_visible = True
+        self.move_end()
 
     def resize(self, w, h):
         self.tw = self.width - 2
+        if self.curspos >= self.tw:
+            self.ofs = self.curspos - self.tw
 
     def draw(self, buffer):
         color = self.theme.active if self.has_focus() else self.theme.normal
@@ -48,25 +51,22 @@
             self._cursor.update(1 + self.curspos - self.ofs, 0)
 
     def keypress_event(self, ev):
-        consumed = True
-        if ev.keyname == 'left':
-            self.move_left()
-        elif ev.keyname == 'right':
-            self.move_right()
-        elif ev.keyname == 'backspace':
-            if self.curspos > 0:
-                self.move_left()
-                self.del_char()
-        elif ev.keyname == 'delete':
-            self.del_char()
-        else:
-            consumed = False
-
+        map_keyname_to_func = {
+            'left':       self.move_left,
+            'right':      self.move_right,
+            'home':       self.move_home,
+            'end':        self.move_end,
+            'backspace':  self.move_left_and_del,
+            'delete':     self.del_char,
+        }
+        consumed = False
+        if ev.keyname in map_keyname_to_func:
+            map_keyname_to_func[ev.keyname]()
+            consumed = True
         if not ev.keyname and ev.char:
             self.add_char(ev.char)
             self.move_right()
             consumed = True
-
         if consumed:
             #self.redraw()
             return True
@@ -84,8 +84,8 @@
 
     def move_right(self):
         if self.curspos < len(self.value):
-            if self.curspos - self.ofs < self.tw - 2 \
-            or (self.curspos - self.ofs == self.tw - 2 and self.curspos == len(self.value)-1):
+            if self.curspos - self.ofs < self.tw - 1 \
+            or (self.curspos - self.ofs == self.tw - 1 and self.curspos == len(self.value)-1):
                 # move cursor
                 self.curspos += 1
             else:
@@ -93,9 +93,22 @@
                 self.curspos += 1
                 self.ofs += 1
 
+    def move_home(self):
+        self.curspos = 0
+        self.ofs = 0
+
+    def move_end(self):
+        self.curspos = len(self.value)
+        if self.curspos >= self.tw:
+            self.ofs = self.curspos - self.tw
+
     def add_char(self, c):
         self.value = self.value[:self.curspos] + c + self.value[self.curspos:]
 
     def del_char(self):
         self.value = self.value[:self.curspos] + self.value[self.curspos+1:]
 
+    def move_left_and_del(self):
+        if self.curspos > 0:
+            self.move_left()
+            self.del_char()