Update TableView (old uncommitted work).
authorRadek Brich <radek.brich@devl.cz>
Wed, 20 Aug 2014 14:48:16 +0200
changeset 100 3b2df86d8f94
parent 97 0c2e0c09ba5c
child 101 079ced01f875
Update TableView (old uncommitted work).
demo_tableview.py
tests/test_tablemodel.py
tuikit.conf
tuikit.conf.example
tuikit/tableview.py
--- a/demo_tableview.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/demo_tableview.py	Wed Aug 20 14:48:16 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':
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_tablemodel.py	Wed Aug 20 14:48:16 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	Fri Mar 28 19:58:59 2014 +0100
+++ /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 Aug 20 14:48:16 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/tableview.py	Fri Mar 28 19:58:59 2014 +0100
+++ b/tuikit/tableview.py	Wed Aug 20 14:48:16 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: