Virgil Dupras avatar Virgil Dupras committed a135c58 Merge

Merged heads.

Comments (0)

Files changed (4)

 # which should be included with this package. The terms are also available at 
 # http://www.hardcoded.net/licenses/bsd_license
 
-from collections import namedtuple
+from collections import MutableSequence, namedtuple
 
-class Table(list):
+# We used to directly subclass list, but it caused problems at some point with deepcopy
+
+# Adding and removing footer here and there might seem (and is) hackish, but it's much simpler than
+# the alternative, which is to override magic methods and adjust the results. When we do that, there
+# the slice stuff that we have to implement and it gets quite complex.
+# Moreover, the most frequent operation on a table is __getitem__, and making checks to know whether
+# the key is a header or footer at each call would make that operation, which is the most used,
+# slower.
+class Table(MutableSequence):
     def __init__(self):
-        list.__init__(self)
+        self._rows = []
         self._selected_indexes = []
+        self._header = None
+        self._footer = None
     
     def __delitem__(self, key):
-        list.__delitem__(self, key)
+        self._rows.__delitem__(key)
+        if self._header is not None and ((not self) or (self[0] is not self._header)):
+            self._header = None
+        if self._footer is not None and ((not self) or (self[-1] is not self._footer)):
+            self._footer = None
         self._check_selection_range()
     
-    # this method is deprecated, but when subclassing list, we *have* to override it.
-    def __delslice__(self, i, j):
-        self.__delitem__(slice(i, j))
-    
     def __eq__(self, other):
         # object doesn't have __eq__ and __cmp__ doesn't work
         return self is other
     
+    def __getitem__(self, key):
+        return self._rows.__getitem__(key)
+    
     def __hash__(self):
         return object.__hash__(self)
     
+    def __len__(self):
+        return len(self._rows)
+    
+    def __setitem__(self, key, value):
+        self._rows.__setitem__(key, value)
+    
     def _check_selection_range(self):
         if not self:
             self._selected_indexes = []
-        had_selection = bool(self._selected_indexes)
+        if not self._selected_indexes:
+            return
         self._selected_indexes = [index for index in self._selected_indexes if index < len(self)]
-        if had_selection and not self._selected_indexes:
-            self._selected_indexes.append(len(self) - 1)
+        if not self._selected_indexes:
+            self._selected_indexes = [len(self) - 1]
+    
+    def append(self, item):
+        if self._footer is not None:
+            self._rows.insert(-1, item)
+        else:
+            self._rows.append(item)
+    
+    def insert(self, index, item):
+        if (self._header is not None) and (index == 0):
+            index = 1
+        if (self._footer is not None) and (index >= len(self)):
+            index = len(self) - 1
+        self._rows.insert(index, item)
     
     def remove(self, row):
-        list.remove(self, row)
+        if row is self._header:
+            self._header = None
+        if row is self._footer:
+            self._footer = None
+        self._rows.remove(row)
         self._check_selection_range()
     
     def sort_by(self, column_name, desc=False):
+        if self._header is not None:
+            self._rows.pop(0)
+        if self._footer is not None:
+            self._rows.pop()
         key = lambda row: row.sort_key_for_column(column_name)
-        self.sort(key=key, reverse=desc)
+        self._rows.sort(key=key, reverse=desc)
+        if self._header is not None:
+            self._rows.insert(0, self._header)
+        if self._footer is not None:
+            self._rows.append(self._footer)
+    
+    #--- Properties
+    @property
+    def footer(self):
+        return self._footer
+    
+    @footer.setter
+    def footer(self, value):
+        if self._footer is not None:
+            self._rows.pop()
+        if value is not None:
+            self._rows.append(value)
+        self._footer = value
+    
+    @property
+    def header(self):
+        return self._header
+    
+    @header.setter
+    def header(self, value):
+        if self._header is not None:
+            self._rows.pop(0)
+        if value is not None:
+            self._rows.insert(0, value)
+        self._header = value
+    
+    @property
+    def row_count(self):
+        result = len(self)
+        if self._footer is not None:
+            result -= 1
+        if self._header is not None:
+            result -= 1
+        return result
+    
+    @property
+    def rows(self):
+        start = None
+        end = None
+        if self._footer is not None:
+            end = -1
+        if self._header is not None:
+            start = 1
+        return self[start:end]
     
     @property
     def selected_row(self):
     
     #--- Virtual
     def _do_add(self):
-        # Creates a new row, adds it in the table and returns it.
-        return None
+        # Creates a new row, adds it in the table and returns (row, insert_index)
+        raise NotImplementedError()
     
     def _do_delete(self):
         # Delete the selected rows
     def _is_edited_new(self):
         return False
     
+    def _restore_selection(self, previous_selection):
+        if not self.selected_indexes:
+            if previous_selection:
+                self.select(previous_selection)
+            else:
+                self.select([len(self) - 1])
+    
     def _update_selection(self):
         # Takes the table's selection and does appropriates updates on the Document's side.
         pass
         self.view.stop_editing()
         if self.edited is not None:
             self.save_edits()
-        row = self._do_add()
-        assert row is not None
+        row, insert_index = self._do_add()
+        self.insert(insert_index, row)
+        self.select([insert_index])
         self.edited = row
         self.view.refresh()
         self.view.start_editing()
             return
         self.view.stop_editing()
         if self._is_edited_new():
+            previous_selection = self.selected_indexes
             self.remove(self.edited)
+            self._restore_selection(previous_selection)
             self._update_selection()
         else:
             self.edited.load()
     
     def refresh(self):
         self.cancel_edits()
-        selected_indexes = self.selected_indexes
+        previous_selection = self.selected_indexes
         del self[:]
         self._fill()
         sd = self._sort_descriptor
         if sd is not None:
             Table.sort_by(self, column_name=sd.column, desc=sd.desc)
-        if not self.selected_indexes:
-            if not selected_indexes:
-                selected_indexes = [len(self) - 1]
-            self.select(selected_indexes)
+        self._restore_selection(previous_selection)
     
     def save_edits(self):
         if self.edited is None:
     def sort_by(self, column_name, desc=False):
         Table.sort_by(self, column_name=column_name, desc=desc)
         self._sort_descriptor = SortDescriptor(column_name, desc)
+        self._update_selection()
         self.view.refresh()
     
 
     
     def sort_key_for_column(self, column_name):
         # Most of the time, the adequate sort key for a column is the column name with '_' prepended
-        # to it. This member usually corresponds to the unformated version of the column.
+        # to it. This member usually corresponds to the unformated version of the column. If it's
+        # not there, we try the column_name without underscores
         # Of course, override for exceptions.
-        value = getattr(self, '_' + column_name)
-        if isinstance(value, basestring):
-            value = sort_string(value)
-        elif isinstance(value, Amount):
-            value = value.value
-        return value
+        try:
+            return getattr(self, '_' + column_name)
+        except AttributeError:
+            return getattr(self, column_name)
     
     #--- Public
     def can_edit_cell(self, column_name):

tests/table_test.py

+# Created By: Virgil Dupras
+# Created On: 2008-08-12
+# Copyright 2010 Hardcoded Software (http://www.hardcoded.net)
+# 
+# This software is licensed under the "HS" License as described in the "LICENSE" file, 
+# which should be included with this package. The terms are also available at 
+# http://www.hardcoded.net/licenses/hs_license
+
+from nose.tools import eq_
+
+from ..table import Table, GUITable, Row
+
+class TableView(object):
+    def refresh(self):
+        pass
+    
+    def start_editing(self):
+        pass
+    
+    def stop_editing(self):
+        pass
+    
+
+class TestRow(Row):
+    def __init__(self, table, index, is_new=False):
+        Row.__init__(self, table)
+        self.is_new = is_new
+        self._index = index
+    
+    def load(self):
+        pass
+    
+    def save(self):
+        self.is_new = False
+    
+    @property
+    def index(self):
+        return self._index
+    
+
+class TestGUITable(GUITable):
+    def __init__(self, rowcount):
+        GUITable.__init__(self)
+        self.view = TableView()
+        self.rowcount = rowcount
+        self.updated_rows = None
+    
+    def _do_add(self):
+        return TestRow(self, len(self), is_new=True), len(self)
+    
+    def _is_edited_new(self):
+        return self.edited is not None and self.edited.is_new
+    
+    def _fill(self):
+        for i in range(self.rowcount):
+            self.append(TestRow(self, i))
+    
+    def _update_selection(self):
+        self.updated_rows = self.selected_rows[:]
+    
+
+def table_with_footer():
+    table = Table()
+    table.append(TestRow(table, 0))
+    footer = TestRow(table, 1)
+    table.footer = footer
+    return table, footer
+
+def table_with_header():
+    table = Table()
+    table.append(TestRow(table, 1))
+    header = TestRow(table, 0)
+    table.header = header
+    return table, header
+
+#--- Tests
+def test_in():
+    # When a table is in a list, doing "in list" with another instance returns false, even if
+    # they're the same as lists.
+    table = Table()
+    some_list = [table]
+    assert Table() not in some_list
+
+def test_footer_del_all():
+    # Removing all rows doesn't crash when doing the footer check.
+    table, footer = table_with_footer()
+    del table[:]
+    assert table.footer is None
+
+def test_footer_del_row():
+    # Removing the footer row sets it to None
+    table, footer = table_with_footer()
+    del table[-1]
+    assert table.footer is None
+    eq_(len(table), 1)
+
+def test_footer_is_appened_to_table():
+    # A footer is appended at the table's bottom
+    table, footer = table_with_footer()
+    eq_(len(table), 2)
+    assert table[1] is footer
+
+def test_footer_remove():
+    # remove() on footer sets it to None
+    table, footer = table_with_footer()
+    table.remove(footer)
+    assert table.footer is None
+
+def test_footer_replaces_old_footer():
+    table, footer = table_with_footer()
+    other = Row(table)
+    table.footer = other
+    assert table.footer is other
+    eq_(len(table), 2)
+    assert table[1] is other
+
+def test_footer_rows_and_row_count():
+    # rows() and row_count() ignore footer.
+    table, footer = table_with_footer()
+    eq_(table.row_count, 1)
+    eq_(table.rows, table[:-1])
+
+def test_footer_setting_to_none_removes_old_one():
+    table, footer = table_with_footer()
+    table.footer = None
+    assert table.footer is None
+    eq_(len(table), 1)
+
+def test_footer_stays_there_on_append():
+    # Appending another row puts it above the footer
+    table, footer = table_with_footer()
+    table.append(Row(table))
+    eq_(len(table), 3)
+    assert table[2] is footer
+
+def test_footer_stays_there_on_insert():
+    # Inserting another row puts it above the footer
+    table, footer = table_with_footer()
+    table.insert(3, Row(table))
+    eq_(len(table), 3)
+    assert table[2] is footer
+
+def test_header_del_all():
+    # Removing all rows doesn't crash when doing the header check.
+    table, header = table_with_header()
+    del table[:]
+    assert table.header is None
+
+def test_header_del_row():
+    # Removing the header row sets it to None
+    table, header = table_with_header()
+    del table[0]
+    assert table.header is None
+    eq_(len(table), 1)
+
+def test_header_is_inserted_in_table():
+    # A header is inserted at the table's top
+    table, header = table_with_header()
+    eq_(len(table), 2)
+    assert table[0] is header
+
+def test_header_remove():
+    # remove() on header sets it to None
+    table, header = table_with_header()
+    table.remove(header)
+    assert table.header is None
+
+def test_header_replaces_old_header():
+    table, header = table_with_header()
+    other = Row(table)
+    table.header = other
+    assert table.header is other
+    eq_(len(table), 2)
+    assert table[0] is other
+
+def test_header_rows_and_row_count():
+    # rows() and row_count() ignore header.
+    table, header = table_with_header()
+    eq_(table.row_count, 1)
+    eq_(table.rows, table[1:])
+
+def test_header_setting_to_none_removes_old_one():
+    table, header = table_with_header()
+    table.header = None
+    assert table.header is None
+    eq_(len(table), 1)
+
+def test_header_stays_there_on_insert():
+    # Inserting another row at the top puts it below the header
+    table, header = table_with_header()
+    table.insert(0, Row(table))
+    eq_(len(table), 3)
+    assert table[0] is header
+
+def test_restore_selection():
+    # By default, after a refresh, selection goes on the last row
+    table = TestGUITable(10)
+    table.refresh()
+    eq_(table.selected_indexes, [9])
+
+def test_restore_selection_after_cancel_edits():
+    # _restore_selection() is called after cancel_edits(). Previously, only _update_selection would
+    # be called.
+    class MyTable(TestGUITable):
+        def _restore_selection(self, previous_selection):
+            self.selected_indexes = [6]
+    
+    table = MyTable(10)
+    table.refresh()
+    table.add()
+    table.cancel_edits()
+    eq_(table.selected_indexes, [6])
+
+def test_restore_selection_with_previous_selection():
+    # By default, we try to restore the selection that was there before a refresh
+    table = TestGUITable(10)
+    table.refresh()
+    table.selected_indexes = [2, 4]
+    table.refresh()
+    eq_(table.selected_indexes, [2, 4])
+
+def test_restore_selection_custom():
+    # After a _fill() called, the virtual _restore_selection() is called so that it's possible for a
+    # GUITable subclass to customize its post-refresh selection behavior.
+    class MyTable(TestGUITable):
+        def _restore_selection(self, previous_selection):
+            self.selected_indexes = [6]
+        
+    table = MyTable(10)
+    table.refresh()
+    eq_(table.selected_indexes, [6])
+
+def test_sort_table_also_tries_attributes_without_underscores():
+    # When determining a sort key, after having unsuccessfully tried the attribute with the,
+    # underscore, try the one without one.
+    table = Table()
+    row1 = Row(table)
+    row1._foo = 'a' # underscored attr must be checked first
+    row1.foo = 'b'
+    row1.bar = 'c'
+    row2 = Row(table)
+    row2._foo = 'b'
+    row2.foo = 'a'
+    row2.bar = 'b'
+    table.append(row1)
+    table.append(row2)
+    table.sort_by('foo')
+    assert table[0] is row1
+    assert table[1] is row2
+    table.sort_by('bar')
+    assert table[0] is row2
+    assert table[1] is row1
+
+def test_sort_table_updates_selection():
+    table = TestGUITable(10)
+    table.refresh()
+    table.select([2, 4])
+    table.sort_by('index', desc=True)
+    # Now, the updated rows should be 7 and 5
+    eq_(len(table.updated_rows), 2)
+    r1, r2 = table.updated_rows
+    eq_(r1.index, 7)
+    eq_(r2.index, 5)
+
+def test_sort_table_with_footer():
+    # Sorting a table with a footer keeps it at the bottom
+    table, footer = table_with_footer()
+    table.sort_by('index', desc=True)
+    assert table[-1] is footer
+
+def test_sort_table_with_header():
+    # Sorting a table with a header keeps it at the top
+    table, header = table_with_header()
+    table.sort_by('index', desc=True)
+    assert table[0] is header

tests/tree_test.py

     r = t.findall(lambda n: n.name.startswith('sub'))
     eq_(set(r), set([t[0][0], t[0][1]]))
 
+def test_findall_dont_include_self():
+    # When calling findall with include_self=False, the node itself is never evaluated.
+    t = tree_with_some_nodes()
+    del t._name # so that if the predicate is called on `t`, we crash
+    r = t.findall(lambda n: not n.name.startswith('sub'), include_self=False) # no crash
+    eq_(set(r), set([t[0], t[1], t[2]]))
+
+def test_find_dont_include_self():
+    # When calling find with include_self=False, the node itself is never evaluated.
+    t = tree_with_some_nodes()
+    del t._name # so that if the predicate is called on `t`, we crash
+    r = t.find(lambda n: not n.name.startswith('sub'), include_self=False) # no crash
+    assert r is t[0]
+
 def test_find_none():
     # when find() yields no result, return None
     t = Tree()
     def clear(self):
         del self[:]
     
-    def find(self, predicate):
+    def find(self, predicate, include_self=True):
         try:
-            return next(self.findall(predicate))
+            return next(self.findall(predicate, include_self=include_self))
         except StopIteration:
             return None
     
-    def findall(self, predicate):
-        if predicate(self):
+    def findall(self, predicate, include_self=True):
+        if include_self and predicate(self):
             yield self
         for child in self:
-            for found in child.findall(predicate):
+            for found in child.findall(predicate, include_self=True):
                 yield found
     
     def get_node(self, index_path):
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.