diff --git a/docs/source/data_view.rst b/docs/source/data_view.rst new file mode 100644 index 000000000..5f2781b98 --- /dev/null +++ b/docs/source/data_view.rst @@ -0,0 +1,231 @@ +Pyface DataViews +================= + +The Pyface DataView API allows visualization of hierarchical and +non-hierarchical tabular data. + +Indexing +-------- + +The DataView API has a consistent way of indexing that uses tuples of integers +to represent the rows and columns, as illustrated below: + +.. figure:: images/data_view_indices.png + :scale: 50 + :alt: an illustration of data view indices + + How DataView Indexing Works. + +A row index corresponds to a list of integer indexes at each level of the +hierarchy, so the empty tuple ``()`` represents the root of the hierarchy, +the tuples ``(0,)`` and ``(1,)`` give the two child rows of the root, while +``(0, 1)`` is the second child row of the first child of the root, and so on. + +Column indices follow a similar pattern, but only have the root and one level +of child indices. + +When interpreting these values, the root row ``()`` corresponds to the +*column* headers, the root column ``()`` corresponds to the *row* headers. +The root row and column indices together refer to the cell in the top-left +corner. + + +Index Managers +-------------- + +These indices need to be converted to and from whatever system the backend +toolkit uses for indexing and tracking rows. This conversion is handled +by an |AbstractIndexManager| instance. Pyface provides two of these which +efficiently handle the two common cases: |TupleIndexManager| is designed to +handle general hierarchical models, but needs to cache mementos for all rows +with children (and on Wx, for all rows); the |IntIndexManager| can only handle +non-hierarchical tables, but does so without needing any additional memory +allocation. + +Unless you are creating a toolkit model or widget that uses the DataView +infrastructure it is sufficient to simply know to use the |IntIndexManager| +when you know that the data will always be a flat table, and |TupleIndexManager| +otherwise. + + +Data Models +----------- + +Data to be viewed needs to be exposed to the DataView infrastructure by +creating a data model for it. This is a class that implements the +interface of |AbstractDataModel| to display values from a dictionary. + +.. figure:: images/dict_data_model.png + :scale: 50 + :alt: an illustration of the DictDataModel + + The DictDataModel example. + +The basic traits for the model might look like this: + +.. literalinclude:: examples/dict_data_model.py + :start-at: class DictDataModel + :end-at: index_manager = + +The base |AbstractDataModel| class requires you to provide an index manager +so we use an |IntIndexManager| because the data is non-hierarchical for this +model. + +Data Structure +~~~~~~~~~~~~~~ + +The |get_column_count| method needs to be implemented to tell the toolkit +how many columns are in the data model. For the dict model, keys are +displayed in the row headers, so there is just one column displaying the +value: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def get_column_count + :end-at: return + +We can signal to the toolkit that certain rows can never have children +via the |can_have_children| method. The dict data model is +non-hierarchical, so the root has children but no other rows will ever +have children: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def can_have_children + :end-at: return + +We need to tell the toolkit how many child rows a particular row has, +which is done via the |get_row_count| method. In this example, only the +root has children, and the number of child rows of the root is the length +of the dictionary: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def get_row_count + :end-at: return 0 + +Data Values +~~~~~~~~~~~ + +The |get_value| method is used to return the raw value for each location. +To get the values of the dict data model, we need to determine from the row +and column index whether or not the cell is a column header and whether +it corresponds to the keys or the values. The code looks like this: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def get_value + :end-at: return value + +Conversion of values into data channels is done by providing a value type +for each cell. The |get_value_type| method provides an appropriate data +type for each item in the table. For this data model we have three value +types: the column headers, the keys and the values. + +.. literalinclude:: examples/dict_data_model.py + :start-at: #: The header data + :lines: 1-8 + +The default values of these traits are defined to be |TextValue| instances. +Users of the model can provide different value types when instantiating, +for example if the values are known to all be integers then |IntValue| +could be used instead for the ``value_type`` trait:: + + model = DictDataModel(value_type=IntValue()) + +The |get_value_type| method uses the indices to select the appropriate +value types: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def get_value_type + :end-at: return self.value_type + +Handling Updates +~~~~~~~~~~~~~~~~ + +The |AbstractDataModel| class expects that when the data changes, one of +two trait Events are fired. If a value is changed, or the value type is +updated, but the number of rows and columns is unaffected, then the +``values_changed`` trait should be fired with a tuple:: + + (start_row_index, start_column_index, end_row_index, end_column_index) + +If a major change has occurred, or if the size, shape or layout of the data +has changed, then the ``structure_changed`` event should be fired with a +simple ``True`` value. + +While it is possible that a data model could require users of the model to +manually fire these events (and for some opaque, non-traits data structures, +this may be necessary), where possible it makes sense to use trait observers +to automatically fire these events when a change occurs. + +For example, we want to listen for changes in the dictionary and its items. +It is simplest in this case to just indicate that the entire model needs +updating by firing the ``structure_changed`` event [#]_: + +.. literalinclude:: examples/dict_data_model.py + :start-at: @observe('data.items') + :end-at: self.structure_changed + +Changes to the value types also should fire update events, but usually +these are simply changes to the data, rather than changes to the structure +of the table. All value types have an updated event which is fired when +any state of the type changes. We can observe these, compute which +indices are affected, and fire the appropriate event. + +.. literalinclude:: examples/dict_data_model.py + :start-at: @observe('header_value_type.updated') + :lines: 1-11 + +Editing Values +~~~~~~~~~~~~~~ + +A model can flag values as being modifiable by implementing the +|can_set_value| function. The default implementation simply returns +``False`` for all items, but subclasses can override this to permit +modification of the values. For example, to allow modification of the +values of the dictionary, we could write: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def can_set_value + :end-at: return + +A corresponding |set_value| method is needed to actually perform the changes +to the underlying values. If for some reason it is impossible to set the +value (eg. an invalid value is supplied, or |set_value| is called with an +inappropriate row or column value, then a |DataViewSetError| should be +raised: + +.. literalinclude:: examples/dict_data_model.py + :start-at: def set_value + :end-at: raise + +Even though a data value may be modifiable at the data model level, the +value types also have the ability to control whether or not the value is +editable. For example, subclasses of |EditableValue|, such as |TextValue| +and |IntValue| have an ``is_editable`` trait that controls whether the +value should be editable in the view (presuming that the underlying value +can be set). Other value types can simply prevent editing by ensuring that +the |has_editor_value| method returns ``False``. + +.. rubric:: Footnotes + +.. [#] A more sophisticated implementation might try to work out + whether the total number of items has changed, and if not, the + location of the first and last changes in at least some of the + change events, and then fire ``values_changed``. For simplicty + we don't try to do that in this example. + + +.. |AbstractIndexManager| replace:: :py:class:`~pyface.data_view.index_manager.AbstractIndexManager` +.. |AbstractDataModel| replace:: :py:class:`~pyface.data_view.abstract_data_model.AbstractDataModel` +.. |DataViewSetError| replace:: :py:class:`~pyface.data_view.abstract_data_model.DataViewSetError` +.. |EditableValue| replace:: :py:class:`~pyface.data_view.value_types.editable_value.EditableValue` +.. |IntIndexManager| replace:: :py:class:`~pyface.data_view.index_manager.IntIndexManager` +.. |IntValue| replace:: :py:class:`~pyface.data_view.value_types.numeric_value.IntValue` +.. |TextValue| replace:: :py:class:`~pyface.data_view.value_types.text_value.TextValue` +.. |TupleIndexManager| replace:: :py:class:`~pyface.data_view.index_manager.TupleIndexManager` +.. |can_have_children| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.can_have_children` +.. |can_set_value| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.can_set_value` +.. |get_column_count| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.get_column_count` +.. |get_row_count| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.get_row_count` +.. |get_value| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.get_value` +.. |get_value_type| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.get_value` +.. |has_editor_value| replace:: :py:meth:`~pyface.data_view.abstract_value_type.AbstractValueType.has_editor_value` +.. |set_value| replace:: :py:meth:`~pyface.data_view.abstract_data_model.AbstractDataModel.set_value` diff --git a/docs/source/examples/data_model_indices.py b/docs/source/examples/data_model_indices.py new file mode 100644 index 000000000..29a1b1211 --- /dev/null +++ b/docs/source/examples/data_model_indices.py @@ -0,0 +1,78 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Instance, Int, List + +from pyface.data_view.abstract_data_model import AbstractDataModel +from pyface.data_view.index_manager import TupleIndexManager + + +class IndexDataModel(AbstractDataModel): + """ A data model that displays the indices of the cell. """ + + index_manager = Instance(TupleIndexManager, ()) + + shape = List(Int, [2, 3, 4]) + + def get_column_count(self): + return self.shape[-1] + + def can_have_children(self, row): + return len(row) < len(self.shape) - 1 + + def get_row_count(self, row): + if len(row) == len(self.shape) - 1: + return 0 + else: + return self.shape[len(row)] + + def get_value(self, row, column): + return "{} {}".format(row, column) + + def get_value_type(self, row, column): + return TextValue(is_editable=False) + + +if __name__ == '__main__': + from pyface.api import ApplicationWindow, GUI + from pyface.data_view.i_data_view_widget import IDataViewWidget + from pyface.data_view.data_view_widget import DataViewWidget + from pyface.data_view.value_types.api import TextValue + + + class MainWindow(ApplicationWindow): + """ The main application window. """ + + data_view = Instance(IDataViewWidget) + + def _create_contents(self, parent): + """ Creates the left hand side or top depending on the style. """ + + self.data_view = DataViewWidget( + parent=parent, + data_model=IndexDataModel(), + ) + self.data_view._create() + return self.data_view.control + + def destroy(self): + self.data_view.destroy() + super().destroy() + + + # Create the GUI (this does NOT start the GUI event loop). + gui = GUI() + + # Create and open the main window. + window = MainWindow() + window.open() + + # Start the GUI event loop! + gui.start_event_loop() diff --git a/docs/source/examples/dict_data_model.py b/docs/source/examples/dict_data_model.py new file mode 100644 index 000000000..66f3019fa --- /dev/null +++ b/docs/source/examples/dict_data_model.py @@ -0,0 +1,156 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import ComparisonMode, Dict, Instance, Str, observe + +from pyface.data_view.abstract_data_model import ( + AbstractDataModel, DataViewSetError +) +from pyface.data_view.abstract_value_type import AbstractValueType +from pyface.data_view.index_manager import IntIndexManager + + +class DictDataModel(AbstractDataModel): + """ A data model that provides data from a dictionary. """ + + #: The dictionary containing the data. + data = Dict(comparison_mode=ComparisonMode.identity) + + #: The index manager. Because the data is flat, we use the + #: IntIndexManager. + index_manager = Instance(IntIndexManager, ()) + + #: The text to display in the key column header. + keys_header = Str("Keys") + + #: The text to display in the values column header. + values_header = Str("Values") + + #: The header data channels. + header_value_type = Instance(AbstractValueType) + + #: The key column data channels. + key_value_type = Instance(AbstractValueType) + + #: The value column data channels. + value_type = Instance(AbstractValueType) + + def get_column_count(self): + return 1 + + def can_have_children(self, row): + return len(row) == 0 + + def get_row_count(self, row): + if len(row) == 0: + return len(self.data) + return 0 + + def get_value(self, row, column): + if len(row) == 0: + # this is a column header + if len(column) == 0: + # title of the row headers + return self.keys_header + else: + return self.values_header + else: + row_index = row[0] + key, value = list(self.data.items())[row_index] + if len(column) == 0: + # the is a row header, so get the key + return key + else: + return value + + def get_value_type(self, row, column): + if len(row) == 0: + return self.header_value_type + elif len(column) == 0: + return self.key_value_type + else: + return self.value_type + + def can_set_value(self, row, column): + return len(row) != 0 and len(column) != 0 + + def set_value(self, row, column, value): + if self.can_set_value(row, column): + row_index = row[0] + key = list(self.data)[row_index] + self.data[key] = value + else: + raise DataViewSetError() + + @observe('header_value_type.updated') + def header_values_updated(self, event): + self.values_changed = ([], [], [], [0]) + + @observe('key_value_type.updated') + def key_values_updated(self, event): + self.values_changed = ([0], [], [len(self.data) - 1], []) + + @observe('value_type.updated') + def values_updated(self, event): + self.values_changed = ([0], [0], [len(self.data) - 1], [0]) + + @observe('data.items') + def data_updated(self, event): + self.structure_changed = True + + def _header_value_type_default(self): + return TextValue(is_editable=False) + + def _key_value_type_default(self): + return TextValue(is_editable=False) + + def _value_type_default(self): + return TextValue(is_editable=False) + + +if __name__ == '__main__': + from pyface.api import ApplicationWindow, GUI + from pyface.data_view.i_data_view_widget import IDataViewWidget + from pyface.data_view.data_view_widget import DataViewWidget + from pyface.data_view.value_types.api import IntValue, TextValue + + + class MainWindow(ApplicationWindow): + """ The main application window. """ + + data_view = Instance(IDataViewWidget) + + def _create_contents(self, parent): + """ Creates the left hand side or top depending on the style. """ + + self.data_view = DataViewWidget( + parent=parent, + data_model=DictDataModel( + data={'one': 1, 'two': 2, 'three': 3}, + value_type=IntValue(), + ), + ) + self.data_view._create() + return self.data_view.control + + def destroy(self): + self.data_view.destroy() + super().destroy() + + + # Create the GUI (this does NOT start the GUI event loop). + gui = GUI() + + # Create and open the main window. + window = MainWindow() + window.open() + + # Start the GUI event loop! + gui.start_event_loop() diff --git a/docs/source/images/data_view_indices.png b/docs/source/images/data_view_indices.png new file mode 100644 index 000000000..b0b6ca95d Binary files /dev/null and b/docs/source/images/data_view_indices.png differ diff --git a/docs/source/images/dict_data_model.png b/docs/source/images/dict_data_model.png new file mode 100644 index 000000000..202cad349 Binary files /dev/null and b/docs/source/images/dict_data_model.png differ diff --git a/docs/source/submodules.rst b/docs/source/submodules.rst index 17c3d4888..b9e698805 100644 --- a/docs/source/submodules.rst +++ b/docs/source/submodules.rst @@ -5,5 +5,6 @@ Submodules .. toctree:: :maxdepth: 2 + Data View Fields Timers diff --git a/examples/data_view/array_example.py b/examples/data_view/array_example.py new file mode 100644 index 000000000..4d5c368b6 --- /dev/null +++ b/examples/data_view/array_example.py @@ -0,0 +1,59 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Array, Instance + +from pyface.api import ApplicationWindow, GUI +from pyface.data_view.data_models.array_data_model import ArrayDataModel +from pyface.data_view.i_data_view_widget import IDataViewWidget +from pyface.data_view.data_view_widget import DataViewWidget +from pyface.data_view.value_types.api import FloatValue + + +class MainWindow(ApplicationWindow): + """ The main application window. """ + + data = Array + + data_view = Instance(IDataViewWidget) + + def _create_contents(self, parent): + """ Creates the left hand side or top depending on the style. """ + + self.data_view = DataViewWidget( + parent=parent, + data_model=ArrayDataModel( + data=self.data, + value_type=FloatValue(), + ), + ) + self.data_view._create() + return self.data_view.control + + def _data_default(self): + import numpy + return numpy.random.uniform(size=(10000, 10, 10))*1000000 + + def destroy(self): + self.data_view.destroy() + super().destroy() + + +# Application entry point. +if __name__ == "__main__": + # Create the GUI (this does NOT start the GUI event loop). + gui = GUI() + + # Create and open the main window. + window = MainWindow() + window.open() + + # Start the GUI event loop! + gui.start_event_loop() diff --git a/examples/data_view/column_data_model.py b/examples/data_view/column_data_model.py new file mode 100644 index 000000000..151de5b8f --- /dev/null +++ b/examples/data_view/column_data_model.py @@ -0,0 +1,230 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from abc import abstractmethod + +from traits.api import ( + ABCHasStrictTraits, ComparisonMode, Event, HasTraits, Instance, + List, Str, observe +) +from traits.trait_base import xgetattr, xsetattr + +from pyface.data_view.abstract_data_model import AbstractDataModel +from pyface.data_view.abstract_value_type import AbstractValueType +from pyface.data_view.index_manager import TupleIndexManager +from pyface.data_view.value_types.api import TextValue + + +class AbstractRowInfo(ABCHasStrictTraits): + """ Configuration for a data row in a ColumnDataModel. + """ + + #: The text to display in the first column. + title = Str() + + #: The child rows of this row, if any. + rows = List( + Instance('AbstractRowInfo'), + comparison_mode=ComparisonMode.identity, + ) + + #: The value type of the data stored in this row. + title_type = Instance( + AbstractValueType, + factory=TextValue, + kw={'is_editable': False}, + ) + + #: The value type of the data stored in this row. + value_type = Instance(AbstractValueType) + + #: An event fired when the row or its children update. The payload is + #: a tuple of whether the title or value changed (or both), and the + #: row_index affected. + updated = Event + + def __iter__(self): + yield self + for row in self.rows: + yield from row + + @abstractmethod + def get_value(self, obj): + raise NotImplementedError() + + @abstractmethod + def can_set_value(self, obj): + raise NotImplementedError() + + def set_value(self, obj): + return + + @abstractmethod + def get_observable(self, obj): + raise NotImplementedError() + + # trait observers + + @observe('title,title_type.updated') + def title_updated(self, event): + self.updated = (self, 'title', []) + + @observe('value_type.updated') + def value_type_updated(self, event): + self.updated = (self, 'value', []) + + @observe('rows.items') + def rows_updated(self, event): + self.updated = (self, 'rows', []) + + @observe('rows:items:updated') + def row_item_updated(self, event): + row = event.object + row_info, part, row_index = event.new + row_index = [self.rows.index(row)] + row_index + self.updated = (row_info, part, row_index) + + +class HasTraitsRowInfo(AbstractRowInfo): + """ RowInfo that presents a named trait value. + """ + + #: The extended trait name of the trait holding the value. + value = Str() + + def get_value(self, obj): + return xgetattr(obj, self.value, None) + + def can_set_value(self, obj): + return self.value != '' + + def set_value(self, obj, value): + if not self.value: + return + xsetattr(obj, self.value, value) + + def get_observable(self): + return self.value + + @observe('value') + def value_type_updated(self, event): + self.updated = (self, 'value', []) + + +class DictRowInfo(AbstractRowInfo): + """ RowInfo that presents an item in a dictionary. + + The attribute ``value`` should reference a dictionary trait on a + has traits object. + """ + + #: The extended trait name of the dictionary holding the values. + value = Str() + + #: The key holding the value. + key = Str() + + def get_value(self, obj): + data = xgetattr(obj, self.value, None) + return data.get(self.key, None) + + def can_set_value(self, obj): + return self.value != '' + + def set_value(self, obj, value): + data = xgetattr(obj, self.value, None) + data[self.key] = value + + def get_observable(self): + return self.value + '.items' + + @observe('value,key') + def value_type_updated(self, event): + self.updated = (self, 'value', []) + + +class ColumnDataModel(AbstractDataModel): + """ A data model that presents a list of objects as columns. + """ + + #: A list of objects to display in columns. + data = List( + Instance(HasTraits), + comparison_mode=ComparisonMode.identity, + ) + + #: An object which describes how to map data for each row. + row_info = Instance(AbstractRowInfo) + + #: The index manager that helps convert toolkit indices to data view + #: indices. + index_manager = Instance(TupleIndexManager, args=()) + + def get_column_count(self): + return len(self.data) + + def can_have_children(self, row): + if len(row) == 0: + return True + row_info = self._row_info_object(row) + return len(row_info.rows) != 0 + + def get_row_count(self, row): + row_info = self._row_info_object(row) + return len(row_info.rows) + + def get_value(self, row, column): + row_info = self._row_info_object(row) + if len(column) == 0: + return row_info.title + obj = self.data[column[0]] + return row_info.get_value(obj) + + def can_set_value(self, row, column): + """ Whether the value in the indicated row and column can be set. + + This returns False for row headers, but True for all other values. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + + Returns + ------- + can_set_value : bool + Whether or not the value can be set. + """ + if len(column) == 0: + return False + else: + return True + + def set_value(self, row, column, value): + row_info = self._row_info_object(row) + if len(column) == 0: + raise DataViewSetError("Cannot set value for row header.") + obj = self.data[column[0]] + row_info.set_value(obj, value) + + def get_value_type(self, row, column): + row_info = self._row_info_object(row) + if len(column) == 0: + return row_info.title_type + else: + return row_info.value_type + + def _row_info_object(self, row): + row_info = self.row_info + for index in row: + row_info = row_info.rows[index] + return row_info diff --git a/examples/data_view/column_example.py b/examples/data_view/column_example.py new file mode 100644 index 000000000..059ab9d12 --- /dev/null +++ b/examples/data_view/column_example.py @@ -0,0 +1,191 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from functools import partial +from random import choice, randint + +from traits.api import HasStrictTraits, Instance, Int, Str, List + +from pyface.api import ApplicationWindow, GUI +from pyface.data_view.i_data_view_widget import IDataViewWidget +from pyface.data_view.data_view_widget import DataViewWidget +from pyface.data_view.value_types.api import IntValue, TextValue, no_value + +from column_data_model import ( + AbstractRowInfo, ColumnDataModel, HasTraitsRowInfo +) + + +class Address(HasStrictTraits): + + street = Str + + city = Str + + country = Str + + +class Person(HasStrictTraits): + + name = Str + + age = Int + + address = Instance(Address) + + +row_info = HasTraitsRowInfo( + title='People', + value='name', + value_type=TextValue(), + rows=[ + HasTraitsRowInfo( + title="Age", + value="age", + value_type=IntValue(minimum=0), + ), + HasTraitsRowInfo( + title="Address", + value_type=no_value, + value='address', + rows=[ + HasTraitsRowInfo( + title="Street", + value="address.street", + value_type=TextValue(), + ), + HasTraitsRowInfo( + title="City", + value="address.city", + value_type=TextValue(), + ), + HasTraitsRowInfo( + title="Country", + value="address.country", + value_type=TextValue(), + ), + ], + ), + ], +) + + +class MainWindow(ApplicationWindow): + """ The main application window. """ + + data = List(Instance(Person)) + + row_info = Instance(AbstractRowInfo) + + data_view = Instance(IDataViewWidget) + + def _create_contents(self, parent): + """ Creates the left hand side or top depending on the style. """ + + self.data_view = DataViewWidget( + parent=parent, + data_model=ColumnDataModel( + data=self.data, + row_info=self.row_info, + ), + ) + self.data_view._create() + return self.data_view.control + + def _data_default(self): + import numpy + return numpy.random.uniform(size=(100000, 10)) + + def destroy(self): + self.data_view.destroy() + super().destroy() + +male_names = [ + 'Michael', 'Edward', 'Timothy', 'James', 'George', 'Ralph', 'David', + 'Martin', 'Bryce', 'Richard', 'Eric', 'Travis', 'Robert', 'Bryan', + 'Alan', 'Harold', 'John', 'Stephen', 'Gael', 'Frederic', 'Eli', 'Scott', + 'Samuel', 'Alexander', 'Tobias', 'Sven', 'Peter', 'Albert', 'Thomas', + 'Horatio', 'Julius', 'Henry', 'Walter', 'Woodrow', 'Dylan', 'Elmer', +] + +female_names = [ + 'Leah', 'Jaya', 'Katrina', 'Vibha', 'Diane', 'Lisa', 'Jean', 'Alice', + 'Rebecca', 'Delia', 'Christine', 'Marie', 'Dorothy', 'Ellen', 'Victoria', + 'Elizabeth', 'Margaret', 'Joyce', 'Sally', 'Ethel', 'Esther', 'Suzanne', + 'Monica', 'Hortense', 'Samantha', 'Tabitha', 'Judith', 'Ariel', 'Helen', + 'Mary', 'Jane', 'Janet', 'Jennifer', 'Rita', 'Rena', 'Rianna', +] + +all_names = male_names + female_names +any_name = partial(choice, all_names) +age = partial(randint, 15, 72) + + +def family_name(): + return choice([ + 'Jones', 'Smith', 'Thompson', 'Hayes', 'Thomas', 'Boyle', "O'Reilly", + 'Lebowski', 'Lennon', 'Starr', 'McCartney', 'Harrison', 'Harrelson', + 'Steinbeck', 'Rand', 'Hemingway', 'Zhivago', 'Clemens', 'Heinlien', + 'Farmer', 'Niven', 'Van Vogt', 'Sturbridge', 'Washington', 'Adams', + 'Bush', 'Kennedy', 'Ford', 'Lincoln', 'Jackson', 'Johnson', + 'Eisenhower', 'Truman', 'Roosevelt', 'Wilson', 'Coolidge', 'Mack', + 'Moon', 'Monroe', 'Springsteen', 'Rigby', "O'Neil", 'Philips', + 'Clinton', 'Clapton', 'Santana', 'Midler', 'Flack', 'Conner', 'Bond', + 'Seinfeld', 'Costanza', 'Kramer', 'Falk', 'Moore', 'Cramdon', 'Baird', + 'Baer', 'Spears', 'Simmons', 'Roberts', 'Michaels', 'Stuart', + 'Montague', 'Miller', + ]) + + +def street(): + number = randint(11, 999) + text_1 = choice([ + 'Spring', 'Summer', 'Moonlight', 'Winding', 'Windy', 'Whispering', + 'Falling', 'Roaring', 'Hummingbird', 'Mockingbird', 'Bluebird', + 'Robin', 'Babbling', 'Cedar', 'Pine', 'Ash', 'Maple', 'Oak', 'Birch', + 'Cherry', 'Blossom', 'Rosewood', 'Apple', 'Peach', 'Blackberry', + 'Strawberry', 'Starlight', 'Wilderness', 'Dappled', 'Beaver', 'Acorn', + 'Pecan', 'Pheasant', 'Owl' + ]) + text_2 = choice([ + 'Way', 'Lane', 'Boulevard', 'Street', 'Drive', 'Circle', 'Avenue', + 'Trail', + ]) + return '%d %s %s' % (number, text_1, text_2) + + +city = partial(choice, ['Boston', 'Cambridge', ]) +country = partial(choice, ['USA', 'UK']) + +people = [ + Person( + name='%s %s' % (any_name(), family_name()), + age=age(), + address=Address( + street=street(), + city=city(), + country=country(), + ), + ) + for i in range(100) +] + + +# Application entry point. +if __name__ == "__main__": + # Create the GUI (this does NOT start the GUI event loop). + gui = GUI() + + # Create and open the main window. + window = MainWindow(data=people, row_info=row_info) + window.open() + + # Start the GUI event loop! + gui.start_event_loop() diff --git a/pyface/data_view/__init__.py b/pyface/data_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/data_view/abstract_data_model.py b/pyface/data_view/abstract_data_model.py new file mode 100644 index 000000000..9593dff4e --- /dev/null +++ b/pyface/data_view/abstract_data_model.py @@ -0,0 +1,277 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Provides an AbstractDataModel ABC for Pyface data models. + +This module provides an ABC for all data view data models. This specifies +the API that the data view widgets expect, and which the underlying +data is adapted to by the concrete implementations. Data models are intended +to be toolkit-independent, and be able to adapt any approximately tabular or +nested data structure to what the data view system expects. +""" +from abc import abstractmethod + +from traits.api import ABCHasStrictTraits, Event, Instance + +from .index_manager import AbstractIndexManager + + +class DataViewSetError(ValueError): + """ An exception raised when setting a value fails. """ + pass + + +class AbstractDataModel(ABCHasStrictTraits): + """ Abstract base class for Pyface data models. + + The data model API is intended to provide a common API for hierarchical + and tabular data. This class is concerned with the structure, type and + values provided by the data, but not with how the data is presented. + + Row and column indices are represented by sequences (usually lists) of + integers, specifying the index at each level of the hierarchy. The root + row and column are represented by empty lists. + + Subclasses need to implement the ``get_column_count``, + ``can_have_children`` and ``get_row_count`` methods to return the number + of columns in a particular row, as well as the hierarchical structure of + the rows. Appropriate observers should be set up on the underlaying data + so that the ``structure_changed`` event is fired when the values returned + by these methods would change. + + Subclasses also have to implement the ``get_value`` and ``get_value_type`` + methods. These expect a row and column index, with root values treated + specially: the root row corresponds to the values which will be displayed + in the column headers of the view, and the root column corresponds to the + values which will be displayed in the row headers of the view. + The ``get_value`` returns an arbitrary Python object corresponding to the + cell being viewed, and the ``get_value_type`` should return an instance of + an ``AbstractValueType`` that adapts the raw value to the data channels + that the data view expects (eg. text, color, icons, editable value, etc.). + Implementations should ensure that the ``values_changed`` event fires + whenever the data, or the way the data is presented, is updated. + + If the data is to be editable then the subclass should override the + ``set_value`` method. It should attempt to change the underlying data as a + side-effect or raise DataViewSetError on failure (for example, + setting an invalid value). If the underlying data structure cannot be + listened to internally (such as a numpy array or Pandas data frame), + ``set_value`` should also fire the ``values_changed`` event with + appropriate values. + + In the cases where the underlying data structure cannot be observed by + the usual traits mechanisms, the end-user of the code may be responsible + for ensuring that the ``structure_changed`` and ``values_changed`` events + are fired appropriately. + """ + + #: The index manager that helps convert toolkit indices to data view + #: indices. This should be an IntIndexManager for non-hierarchical data + #: or a TupleIndexManager for hierarchical data. + index_manager = Instance(AbstractIndexManager) + + #: Event fired when the structure of the data changes. + structure_changed = Event() + + #: Event fired when value changes without changes to structure. This + #: should be set to a 4-tuple of (start_row_index, start_column_index, + #: end_row_index, end_column_index) indicated the subset of data which + #: changed. These end values are inclusive, unlike standard Python + #: slicing notation. + values_changed = Event() + + # Data structure methods + + @abstractmethod + def get_column_count(self): + """ How many columns in the data view model. + + Returns + ------- + column_count : non-negative int + The number of columns that the data view provides. This count + should not include the row header. + """ + raise NotImplementedError() + + @abstractmethod + def can_have_children(self, row): + """ Whether or not a row can have child rows. + + The root row should always return True. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + + Returns + ------- + can_have_children : bool + Whether or not the row can ever have child rows. + """ + raise NotImplementedError() + + @abstractmethod + def get_row_count(self, row): + """ How many child rows the row currently has. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + + Returns + ------- + row_count : non-negative int + The number of child rows that the row has. + """ + raise NotImplementedError() + + # Data value methods + + @abstractmethod + def get_value(self, row, column): + """ Return the Python value for the row and column. + + The values for column headers are returned by calling this method with + row equal to (). The values for row headers are returned by calling + this method with column equal to (). + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + + Returns + ------- + value : any + The value represented by the given row and column. + """ + raise NotImplementedError() + + def can_set_value(self, row, column): + """ Whether the value in the indicated row and column can be set. + + The default method assumes the data is read-only and always + returns False. + + Whether or a column header can be set is returned by calling this + method with row equal to (). Whether or a row header can be set + is returned by calling this method with column equal to (). + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + + Returns + ------- + can_set_value : bool + Whether or not the value can be set. + """ + return False + + def set_value(self, row, column, value): + """ Set the Python value for the row and column. + + The default method assumes the data is read-only and always + returns False. + + The values for column headers can be set by calling this method with + row equal to (). The values for row headers can be set by calling + this method with column equal to (). + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + value : any + The new value for the given row and column. + + Raises + ------- + DataViewSetError + If the value cannot be set. + """ + raise DataViewSetError() + + @abstractmethod + def get_value_type(self, row, column): + """ Return the value type of the given row and column. + + The value type for column headers are returned by calling this method + with row equal to (). The value types for row headers are returned + by calling this method with column equal to (). + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + + Returns + ------- + value_type : AbstractValueType or None + The value type of the given row and column, or None if no value + should be displayed. + """ + raise NotImplementedError() + + # Convenience iterator methods + + def iter_rows(self, start_row=()): + """ Iterator that yields rows in preorder. + + Parameters + ---------- + start_row : row index + The row to start at. The iterator will yeild the row and all + descendant rows. + + Yields + ------ + row_index + The current row index. + """ + start_row = tuple(start_row) + yield start_row + if self.can_have_children(start_row): + for row in range(self.get_row_count(start_row)): + yield from self.iter_rows(start_row + (row,)) + + def iter_items(self, start_row=()): + """ Iterator that yields rows and columns in preorder. + + This yields pairs of row, column for all rows in preorder + and and all column indices for all rows, including (). + Columns are iterated in order. + + Parameters + ---------- + start_row : row index + The row to start iteration from. + + Yields + ------ + row_index, column_index + The current row and column indices. + """ + for row in self.iter_rows(start_row): + yield row, () + for column in range(self.get_column_count()): + yield row, (column,) diff --git a/pyface/data_view/abstract_value_type.py b/pyface/data_view/abstract_value_type.py new file mode 100644 index 000000000..59d600732 --- /dev/null +++ b/pyface/data_view/abstract_value_type.py @@ -0,0 +1,188 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Provides an AbstractValueType ABC for Pyface data models. + +This module provides an ABC for data view value types, which are responsible +for adapting raw data values as used by the data model's ``get_value`` and +``set_value`` methods to the data channels that the data view expects, such +as text, color, icons, etc. + +It is up to the data view to take this standardized data and determine what +and how to actually display it. +""" + +from traits.api import ABCHasStrictTraits, Event, observe + +from .abstract_data_model import DataViewSetError + + +class AbstractValueType(ABCHasStrictTraits): + """ A value type converts raw data into data channels. + + The data channels are editor value, text, color, image, and description. + The data channels are used by other parts of the code to produce the actual + display. + + Subclasses should mark traits that potentially affect the display of values + with ``update_value_type=True`` metdadata, or alternatively fire the + ``updated`` event when the state of the value type changes. + + Each data channel is set up to have a method which returns whether there + is a value for the channel, a second method which returns the value, + and an optional third method which sets the channel value. These methods + should not raise an Exception, eveen when called inappropriately (eg. + calling a "get" method after a "has" method has returned False). + """ + + #: Fired when a change occurs that requires updating values. + updated = Event + + def has_editor_value(self, model, row, column): + """ Return whether or not the value can be edited. + + The default implementation is that cells that can be set are + editable. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + has_editor_value : bool + Whether or not the value is editable. + """ + return model.can_set_value(row, column) + + def get_editor_value(self, model, row, column): + """ Return a value suitable for editing. + + The default implementation is to return the underlying data value + directly from the data model. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + value : any + The value to edit. + """ + return model.get_value(row, column) + + def set_editor_value(self, model, row, column, value): + """ Set a value that is returned from editing. + + The default implementation is to set the value directly from the + data model. Returns True if successful, False if it fails. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + value : any + The value to set. + + Raises + ------- + DataViewSetError + If the value cannot be set. + """ + model.set_value(row, column, value) + + def has_text(self, model, row, column): + """ Whether or not the value has a textual representation. + + The default implementation returns True if ``get_text`` + returns a non-empty value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + has_text : bool + Whether or not the value has a textual representation. + """ + return self.get_text(model, row, column) != "" + + def get_text(self, model, row, column): + """ The textual representation of the underlying value. + + The default implementation calls str() on the underlying value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + text : str + The textual representation of the underlying value. + """ + return str(model.get_value(row, column)) + + def set_text(self, model, row, column, text): + """ Set the text of the underlying value. + + This is provided primarily for backends which may not permit + non-text editing of values, in which case this provides an + alternative route to setting the value. The default implementation + does not allow setting the text. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + text : str + The text to set. + + Raises + ------- + DataViewSetError + If the value cannot be set. + """ + raise DataViewSetError("Cannot set value.") + + @observe('+update_value_type') + def update_value_type(self, event=None): + """ Fire update event when marked traits change. """ + self.updated = True diff --git a/pyface/data_view/data_models/__init__.py b/pyface/data_view/data_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/data_view/data_models/api.py b/pyface/data_view/data_models/api.py new file mode 100644 index 000000000..4e6ad7712 --- /dev/null +++ b/pyface/data_view/data_models/api.py @@ -0,0 +1,11 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from .array_data_model import ArrayDataModel # noqa: F401 diff --git a/pyface/data_view/data_models/array_data_model.py b/pyface/data_view/data_models/array_data_model.py new file mode 100644 index 000000000..04c21280c --- /dev/null +++ b/pyface/data_view/data_models/array_data_model.py @@ -0,0 +1,290 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Provides an N-dimensional array data model implementation. + +This module provides a concrete implementation of a data model for an +n-dim numpy array. +""" +from collections.abc import Sequence + +from traits.api import Array, HasRequiredTraits, Instance, observe + +from pyface.data_view.abstract_data_model import AbstractDataModel, DataViewSetError +from pyface.data_view.abstract_value_type import AbstractValueType +from pyface.data_view.value_types.api import ( + ConstantValue, FloatValue, IntValue, TextValue, no_value +) +from pyface.data_view.index_manager import TupleIndexManager + + +class _AtLeastTwoDArray(Array): + """ Trait type that holds an array that at least two dimensional. + """ + + def validate(self, object, name, value): + value = super().validate(object, name, value) + if value.ndim == 0: + value = value.reshape((0, 0)) + elif value.ndim == 1: + value = value.reshape((-1, 1)) + return value + + +class ArrayDataModel(AbstractDataModel, HasRequiredTraits): + """ A data model for an n-dim array. + + This data model presents the data from a multidimensional array + hierarchically by dimension. The underlying array must be at least 2 + dimensional. + + Values are adapted by the ``value_type`` trait. This provides sensible + default values for integer, float and text dtypes, but other dtypes may + need the user of the class to supply an appropriate value type class to + adapt values. + + There are additional value types which provide data sources for row + headers, column headers, and the label of the row header column. The + defaults are likely suitable for most cases, but can be overriden if + required. + """ + + #: The array being displayed. This must have dimension at least 2. + data = _AtLeastTwoDArray() + + #: The index manager that helps convert toolkit indices to data view + #: indices. + index_manager = Instance(TupleIndexManager, args=()) + + #: The value type of the row index column header. + label_header_type = Instance( + AbstractValueType, + factory=ConstantValue, + kw={'text': "Index"}, + allow_none=False, + ) + + #: The value type of the column titles. + column_header_type = Instance( + AbstractValueType, + factory=IntValue, + kw={'is_editable': False}, + allow_none=False, + ) + + #: The value type of the row titles. + row_header_type = Instance( + AbstractValueType, + factory=IntValue, + kw={'is_editable': False}, + allow_none=False, + ) + + #: The type of value being displayed in the data model. + value_type = Instance(AbstractValueType, allow_none=False, required=True) + + # Data structure methods + + def get_column_count(self): + """ How many columns in the data view model. + + The number of columns is the size of the last dimension of the array. + + Returns + ------- + column_count : non-negative int + The number of columns in the data view model, which is the size of + the last dimension of the array. + """ + return self.data.shape[-1] + + def can_have_children(self, row): + """ Whether or not a row can have child rows. + + A row is a leaf row if the length of the index is one less than + the dimension of the array: the final coordinate for the value will + be supplied by the column index. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + + Returns + ------- + can_have_children : bool + Whether or not the row can ever have child rows. + """ + if len(row) < self.data.ndim - 1: + return True + return False + + def get_row_count(self, row): + """ Whether or not the row currently has any child rows. + + The number of rows in a non-leaf row is equal to the size of the + next dimension. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + + Returns + ------- + has_children : bool + Whether or not the row currently has child rows. + """ + if len(row) < self.data.ndim - 1: + return self.data.shape[len(row)] + return 0 + + # Data value methods + + def get_value(self, row, column): + """ Return the Python value for the row and column. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + + Returns + ------- + row_count : non-negative int + The number of child rows that the row has. + """ + if len(row) == 0: + if len(column) == 0: + return None + return column[0] + elif len(column) == 0: + return row[-1] + else: + index = tuple(row + column) + if len(index) != self.data.ndim: + return None + return self.data[index] + + def can_set_value(self, row, column): + """ Whether the value in the indicated row and column can be set. + + This returns False for row and column headers, but True for all + array values. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + + Returns + ------- + can_set_value : bool + Whether or not the value can be set. + """ + # can only set values when we have the full index + index = tuple(row + column) + return len(index) == self.data.ndim + + def set_value(self, row, column, value): + """ Return the Python value for the row and column. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 1. + + Returns + ------- + value : any + The value represented by the given row and column. + """ + if self.can_set_value(row, column): + index = tuple(row + column) + self.data[index] = value + self.values_changed = (row, column, row, column) + else: + raise DataViewSetError() + + def get_value_type(self, row, column): + """ Return the value type of the given row and column. + + This method returns the value of ``column_header_type`` for column + headers, the value of ``row_header_type`` for row headers, the value + of ``label_header_type`` for the top-left corner value, the value of + ``value_type`` for all array values, and ``no_value`` for everything + else. + + Parameters + ---------- + row : sequence of int + The indices of the row as a sequence from root to leaf. + column : sequence of int + The indices of the column as a sequence of length 0 or 1. + + Returns + ------- + value_type : AbstractValueType + The value type of the given row and column. + """ + if len(row) == 0: + if len(column) == 0: + return self.label_header_type + return self.column_header_type + elif len(column) == 0: + return self.row_header_type + elif len(row) < self.data.ndim - 1: + return no_value + else: + return self.value_type + + # data update methods + + @observe('data') + def data_updated(self, event): + """ Handle the array being replaced with a new array. """ + if event.new.shape == event.old.shape: + self.values_changed = ( + (0,), (0,), + (event.old.shape[0] - 1,), (event.old.shape[-1] - 1,) + ) + else: + self.structure_changed = True + + @observe('value_type.updated') + def value_type_updated(self, event): + """ Handle the value type being updated. """ + self.values_changed = ( + (0,), (0,), (self.data.shape[0] - 1,), (self.data.shape[-1] - 1,) + ) + + @observe('column_header_type.updated') + def column_header_type_updated(self, event): + """ Handle the column header type being updated. """ + self.values_changed = ((), (0,), (), (self.data.shape[-1] - 1,)) + + @observe('row_header_type.updated') + def value_header_type_updated(self, event): + """ Handle the value header type being updated. """ + self.values_changed = ((0,), (), (self.data.shape[0] - 1,), ()) + + @observe('label_header_type.updated') + def label_header_type_updated(self, event): + """ Handle the label header type being updated. """ + self.values_changed = ((), (), (), ()) + + # default array value + + def _data_default(self): + from numpy import zeros + return zeros(shape=(0, 0)) diff --git a/pyface/data_view/data_models/tests/__init__.py b/pyface/data_view/data_models/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/data_view/data_models/tests/test_array_data_model.py b/pyface/data_view/data_models/tests/test_array_data_model.py new file mode 100644 index 000000000..805331ef6 --- /dev/null +++ b/pyface/data_view/data_models/tests/test_array_data_model.py @@ -0,0 +1,350 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase + +from traits.testing.unittest_tools import UnittestTools +from traits.testing.optional_dependencies import numpy as np, requires_numpy + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.abstract_value_type import AbstractValueType +from pyface.data_view.value_types.api import ( + FloatValue, IntValue, TextValue, no_value +) +from pyface.data_view.data_models.array_data_model import ArrayDataModel + + +@requires_numpy +class TestArrayDataModel(UnittestTools, TestCase): + + def setUp(self): + super().setUp() + self.array = np.arange(30.0).reshape(5, 2, 3) + self.model = ArrayDataModel(data=self.array, value_type=FloatValue()) + self.values_changed_event = None + self.structure_changed_event = None + self.model.observe(self.model_values_changed, 'values_changed') + self.model.observe(self.model_structure_changed, 'structure_changed') + + def tearDown(self): + self.model.observe( + self.model_values_changed, 'values_changed', remove=True) + self.model.observe( + self.model_structure_changed, 'structure_changed', remove=True) + self.values_changed_event = None + self.structure_changed_event = None + super().tearDown() + + def model_values_changed(self, event): + self.values_changed_event = event + + def model_structure_changed(self, event): + self.structure_changed_event = event + + def test_no_data(self): + model = ArrayDataModel(value_type=FloatValue()) + self.assertEqual(model.data.ndim, 2) + self.assertEqual(model.data.shape, (0, 0)) + self.assertEqual(model.data.dtype, np.float) + self.assertEqual(model.get_column_count(), 0) + self.assertTrue(model.can_have_children(())) + self.assertEqual(model.get_row_count(()), 0) + + def test_data_1d(self): + array = np.arange(30.0) + model = ArrayDataModel(data=array, value_type=FloatValue()) + self.assertEqual(model.data.ndim, 2) + self.assertEqual(model.data.shape, (30, 1)) + + def test_data_list(self): + data = list(range(30)) + model = ArrayDataModel(data=data, value_type=FloatValue()) + self.assertEqual(model.data.ndim, 2) + self.assertEqual(model.data.shape, (30, 1)) + + def test_set_data_1d(self): + with self.assertTraitChanges(self.model, 'structure_changed'): + self.model.data = np.arange(30.0) + self.assertEqual(self.model.data.ndim, 2) + self.assertEqual(self.model.data.shape, (30, 1)) + + def test_set_data_list(self): + with self.assertTraitChanges(self.model, 'structure_changed'): + self.model.data = list(range(30)) + self.assertEqual(self.model.data.ndim, 2) + self.assertEqual(self.model.data.shape, (30, 1)) + + def test_get_column_count(self): + result = self.model.get_column_count() + self.assertEqual(result, 3) + + def test_can_have_children(self): + for row in self.model.iter_rows(): + with self.subTest(row=row): + result = self.model.can_have_children(row) + if len(row) <= 1: + self.assertEqual(result, True) + else: + self.assertEqual(result, False) + + def test_get_row_count(self): + for row in self.model.iter_rows(): + with self.subTest(row=row): + result = self.model.get_row_count(row) + if len(row) == 0: + self.assertEqual(result, 5) + elif len(row) == 1: + self.assertEqual(result, 2) + else: + self.assertEqual(result, 0) + + def test_get_value(self): + for row, column in self.model.iter_items(): + with self.subTest(row=row, column=column): + result = self.model.get_value(row, column) + if len(row) == 0 and len(column) == 0: + self.assertIsNone(result) + elif len(row) == 0: + self.assertEqual(result, column[0]) + elif len(column) == 0: + self.assertEqual(result, row[-1]) + elif len(row) == 1: + self.assertIsNone(result) + else: + self.assertEqual( + result, + self.array[row[0], row[1], column[0]] + ) + + def test_set_value(self): + for row, column in self.model.iter_items(): + with self.subTest(row=row, column=column): + if len(row) == 0 and len(column) == 0: + with self.assertRaises(DataViewSetError): + self.model.set_value(row, column, 0) + elif len(row) == 0: + with self.assertRaises(DataViewSetError): + self.model.set_value(row, column, column[0] + 1) + elif len(column) == 0: + with self.assertRaises(DataViewSetError): + self.model.set_value(row, column, row[-1] + 1) + elif len(row) == 1: + value = 6.0 * row[-1] + 2 * column[0] + with self.assertTraitDoesNotChange( + self.model, "values_changed"): + with self.assertRaises(DataViewSetError): + self.model.set_value(row, column, value) + else: + value = 6.0 * row[-1] + 2 * column[0] + with self.assertTraitChanges(self.model, "values_changed"): + self.model.set_value(row, column, value) + self.assertEqual( + self.array[row[0], row[1], column[0]], + value, + ) + self.assertEqual( + self.values_changed_event.new, + (row, column, row, column) + ) + + def test_get_value_type(self): + for row, column in self.model.iter_items(): + with self.subTest(row=row, column=column): + result = self.model.get_value_type(row, column) + if len(row) == 0 and len(column) == 0: + self.assertIsInstance(result, AbstractValueType) + self.assertIs(result, self.model.label_header_type) + elif len(row) == 0: + self.assertIsInstance(result, AbstractValueType) + self.assertIs(result, self.model.column_header_type) + elif len(column) == 0: + self.assertIsInstance(result, AbstractValueType) + self.assertIs(result, self.model.row_header_type) + elif len(row) == 1: + self.assertIs(result, no_value) + else: + self.assertIsInstance(result, AbstractValueType) + self.assertIs(result, self.model.value_type) + + def test_data_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.data = 2 * self.array + self.assertEqual( + self.values_changed_event.new, + ((0,), (0,), (4,), (2,)) + ) + + def test_data_updated_new_shape(self): + with self.assertTraitChanges(self.model, "structure_changed"): + self.model.data = 2 * self.array.T + self.assertTrue(self.structure_changed_event.new) + + def test_type_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.value_type = IntValue() + self.assertEqual( + self.values_changed_event.new, + ((0,), (0,), (4,), (2,)) + ) + + def test_type_attribute_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.value_type.is_editable = False + self.assertEqual( + self.values_changed_event.new, + ((0,), (0,), (4,), (2,)) + ) + + def test_row_header_type_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.row_header_type = no_value + self.assertEqual( + self.values_changed_event.new, + ((0,), (), (4,), ()) + ) + + def test_row_header_attribute_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.row_header_type.format = str + self.assertEqual( + self.values_changed_event.new, + ((0,), (), (4,), ()) + ) + + def test_column_header_type_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.column_header_type = no_value + self.assertEqual( + self.values_changed_event.new, + ((), (0,), (), (2,)) + ) + + def test_column_header_type_attribute_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.column_header_type.format = str + self.assertEqual( + self.values_changed_event.new, + ((), (0,), (), (2,)) + ) + + def test_label_header_type_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.label_header_type = no_value + self.assertEqual( + self.values_changed_event.new, + ((), (), (), ()) + ) + + def test_label_header_type_attribute_updated(self): + with self.assertTraitChanges(self.model, "values_changed"): + self.model.label_header_type.text = "My Table" + self.assertEqual( + self.values_changed_event.new, + ((), (), (), ()) + ) + + def test_iter_rows(self): + result = list(self.model.iter_rows()) + self.assertEqual( + result, + [ + (), + (0,), + (0, 0), + (0, 1), + (1,), + (1, 0), + (1, 1), + (2,), + (2, 0), + (2, 1), + (3,), + (3, 0), + (3, 1), + (4,), + (4, 0), + (4, 1), + ] + ) + + def test_iter_rows_start(self): + result = list(self.model.iter_rows((2,))) + self.assertEqual( + result, + [(2,), (2, 0), (2, 1)] + ) + + def test_iter_rows_leaf(self): + result = list(self.model.iter_rows([2, 0])) + self.assertEqual(result, [(2, 0)]) + + def test_iter_items(self): + result = list(self.model.iter_items()) + self.assertEqual( + result, + [ + ((), ()), + ((), (0,)), ((), (1,)), ((), (2,)), + ((0,), ()), + ((0,), (0,)), ((0,), (1,)), ((0,), (2,)), + ((0, 0), ()), + ((0, 0), (0,)), ((0, 0), (1,)), ((0, 0), (2,)), + ((0, 1), ()), + ((0, 1), (0,)), ((0, 1), (1,)), ((0, 1), (2,)), + ((1,), ()), + ((1,), (0,)), ((1,), (1,)), ((1,), (2,)), + ((1, 0), ()), + ((1, 0), (0,)), ((1, 0), (1,)), ((1, 0), (2,)), + ((1, 1), ()), + ((1, 1), (0,)), ((1, 1), (1,)), ((1, 1), (2,)), + ((2,), ()), + ((2,), (0,)), ((2,), (1,)), ((2,), (2,)), + ((2, 0), ()), + ((2, 0), (0,)), ((2, 0), (1,)), ((2, 0), (2,)), + ((2, 1), ()), + ((2, 1), (0,)), ((2, 1), (1,)), ((2, 1), (2,)), + ((3,), ()), + ((3,), (0,)), ((3,), (1,)), ((3,), (2,)), + ((3, 0), ()), + ((3, 0), (0,)), ((3, 0), (1,)), ((3, 0), (2,)), + ((3, 1), ()), + ((3, 1), (0,)), ((3, 1), (1,)), ((3, 1), (2,)), + ((4,), ()), + ((4,), (0,)), ((4,), (1,)), ((4,), (2,)), + ((4, 0), ()), + ((4, 0), (0,)), ((4, 0), (1,)), ((4, 0), (2,)), + ((4, 1), ()), + ((4, 1), (0,)), ((4, 1), (1,)), ((4, 1), (2,)), + ] + ) + + def test_iter_items_start(self): + result = list(self.model.iter_items((2,))) + self.assertEqual( + result, + [ + ((2,), ()), + ((2,), (0,)), ((2,), (1,)), ((2,), (2,)), + ((2, 0), ()), + ((2, 0), (0,)), ((2, 0), (1,)), ((2, 0), (2,)), + ((2, 1), ()), + ((2, 1), (0,)), ((2, 1), (1,)), ((2, 1), (2,)), + ] + ) + + def test_iter_items_leaf(self): + result = list(self.model.iter_items((2, 0))) + self.assertEqual( + result, + [ + ((2, 0), ()), + ((2, 0), (0,)), ((2, 0), (1,)), ((2, 0), (2,)), + ] + ) diff --git a/pyface/data_view/data_view_widget.py b/pyface/data_view/data_view_widget.py new file mode 100644 index 000000000..9d62fcf15 --- /dev/null +++ b/pyface/data_view/data_view_widget.py @@ -0,0 +1,13 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from pyface.toolkit import toolkit_object + +DataViewWidget = toolkit_object('data_view.data_view_widget:DataViewWidget') diff --git a/pyface/data_view/i_data_view_widget.py b/pyface/data_view/i_data_view_widget.py new file mode 100644 index 000000000..547218d37 --- /dev/null +++ b/pyface/data_view/i_data_view_widget.py @@ -0,0 +1,59 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Bool, HasStrictTraits, Instance + +from pyface.data_view.abstract_data_model import AbstractDataModel +from pyface.i_widget import IWidget + + +class IDataViewWidget(IWidget): + + #: Whether or not the column headers are visible. + header_visible = Bool(True) + + #: The data model for the data view. + data_model = Instance(AbstractDataModel, allow_none=False) + + +class MDataViewWidget(HasStrictTraits): + + #: Whether or not the column headers are visible. + header_visible = Bool(True) + + #: The data model for the data view. + data_model = Instance(AbstractDataModel, allow_none=False) + + def _add_event_listeners(self): + super()._add_event_listeners() + self.observe( + self._header_visible_updated, 'header_visible', dispatch='ui') + + def _remove_event_listeners(self): + self.observe( + self._header_visible_updated, + 'header_visible', + dispatch='ui', + remove=True, + ) + super()._remove_event_listeners() + + def _header_visible_updated(self, event): + """ Observer for header_visible trait. """ + if self.control is not None: + self._set_control_header_visible(event.new) + + def _get_control_header_visible(self): + """ Toolkit specific method to get the visibility of the header. """ + raise NotImplementedError() + + def _set_control_header_visible(self, tooltip): + """ Toolkit specific method to set the visibility of the header. """ + raise NotImplementedError() diff --git a/pyface/data_view/index_manager.py b/pyface/data_view/index_manager.py new file mode 100644 index 000000000..3e08e8d46 --- /dev/null +++ b/pyface/data_view/index_manager.py @@ -0,0 +1,424 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" +Index Managers +============== + +This module provides a number of classes for efficiently managing the +mapping between different ways of representing indices. To do so, each +index manager provides an intermediate, opaque index object that is +suitable for use in these situations and is guaranteed to have a long +enough life that it will not change or be garbage collected while a C++ +object has a reference to it. + +The wx DataView classes expect to be given an integer id value that is +stable and can be used to return the parent reference id. + +And Qt's ModelView system expects to be given a pointer to an object +that is long-lived (in particular, it will not be garbage-collected +during the lifetime of a QModelIndex) and which can be used to find +the parent object of the current object. + +The default representation of an index from the point of view of the +data view infrastructure is a sequence of integers, giving the index at +each level of the hierarchy. DataViewModel classes can then use these +indices to identify objects in the underlying data model. + +There are three main classes defined in the module: AbstractIndexManager, +IntIndexManager, and TupleIndexManager. + +AbstractIndexManager + An ABC that defines the API + +IntIndexManager + An efficient index manager for non-hierarchical data, such as + lists, tables and 2D arrays. + +TupleIndexManager + An index manager that handles non-hierarchical data while trying + to be fast and memory efficient. + +The two concrete subclasses should be sufficient for most cases, but advanced +users may create their own if for some reason the provided managers do not +work well for a particular situation. Developers who implement this API +need to be mindful of the requirements on the lifetime and identity +constraints required by the various toolkit APIs. + +""" + +from abc import abstractmethod + +from traits.api import ABCHasStrictTraits, Dict, Int, Tuple + + +#: The singular root object for all index managers. +Root = () + + +class AbstractIndexManager(ABCHasStrictTraits): + """ Abstract base class for index managers. + """ + + @abstractmethod + def create_index(self, parent, row): + """ Given a parent index and a row number, create an index. + + The internal structure of the index should not matter to + consuming code. However obejcts returned from this method + should persist until the reset method is called. + + Parameters + ---------- + parent : index object + The parent index object. + row : int + The position of the resuling index in the parent's children. + + Returns + ------- + index : index object + The resulting opaque index object. + + Raises + ------ + IndexError + Negative row values raise an IndexError exception. + RuntimeError + If asked to create a persistent index for a parent and row + where that is not possible, a RuntimeError will be raised. + """ + raise NotImplementedError() + + @abstractmethod + def get_parent_and_row(self, index): + """ Given an index object, return the parent index and row. + + Parameters + ---------- + index : index object + The opaque index object. + + Returns + ------- + parent : index object + The parent index object. + row : int + The position of the resuling index in the parent's children. + + Raises + ------ + IndexError + If the Root object is passed as the index, this method will + raise an IndexError, as it has no parent. + """ + raise NotImplementedError() + + def from_sequence(self, indices): + """ Given a sequence of indices, return the index object. + + The default implementation starts at the root and repeatedly calls + create_index() to find the index at each level, returning the final + value. + + Parameters + ---------- + indices : sequence of int + The row location at each level of the hierarchy. + + Returns + ------- + index : index object + The persistent index object associated with this sequence. + + Raises + ------ + RuntimeError + If asked to create a persistent index for a sequence of indices + where that is not possible, a RuntimeError will be raised. + """ + index = Root + for row in indices: + index = self.create_index(index, row) + return index + + def to_sequence(self, index): + """ Given an index, return the corresponding sequence of row values. + + The default implementation repeatedly calls get_parent_and_row() + to walk up the hierarchy and push the row values into the start + of the sequence. + + Parameters + ---------- + index : index object + The opaque index object. + + Returns + ------- + sequence : tuple of int + The row location at each level of the hierarchy. + """ + result = () + while index != Root: + index, row = self.get_parent_and_row(index) + result = (row,) + result + return result + + @abstractmethod + def from_id(self, id): + """ Given an integer id, return the corresponding index. + + Parameters + ---------- + id : int + An integer object id value. + + Returns + ------- + index : index object + The persistent index object associated with this id. + """ + raise NotImplementedError() + + @abstractmethod + def id(self, index): + """ Given an index, return the corresponding id. + + Parameters + ---------- + index : index object + The persistent index object. + + Returns + ------- + id : int + The associated integer object id value. + """ + raise NotImplementedError() + + def reset(self): + """ Reset any caches and other state. + + Resettable traits in subclasses are indicated by having + ``can_reset=True`` metadata. This is provided to allow + toolkit code to clear caches to prevent memory leaks when + working with very large tables. + + Care should be taken when calling this method, as Qt may + crash if a QModelIndex is referencing an index that no + longer has a reference in a cache. + + For some IndexManagers, particularly for those which are flat + or static, reset() may do nothing. + """ + resettable_traits = self.trait_names(can_reset=True) + self.reset_traits(resettable_traits) + + +class IntIndexManager(AbstractIndexManager): + """ Efficient IndexManager for non-hierarchical indexes. + + This is a simple index manager for flat data structures. The + index values returned are either the Root, or simple integers + that indicate the position of the index as a child of the root. + + While it cannot handle nested data, this index manager can + operate without having to perform any caching, and so is very + efficient. + """ + + def create_index(self, parent, row): + """ Given a parent index and a row number, create an index. + + This should only ever be called with Root as the parent. + + Parameters + ---------- + parent : index object + The parent index object. + row : non-negative int + The position of the resulting index in the parent's children. + + Returns + ------- + index : index object + The resulting opaque index object. + + Raises + ------ + IndexError + Negative row values raise an IndexError exception. + RuntimeError + If the parent is not the Root, a RuntimeError will be raised + """ + if row < 0: + raise IndexError("Row must be non-negative. Got {}".format(row)) + if parent != Root: + raise RuntimeError( + "{} cannot create persistent index value for {}.".format( + self.__class__.__name__, + (parent, row) + ) + ) + return row + + def get_parent_and_row(self, index): + """ Given an index object, return the parent index and row. + + Parameters + ---------- + index : index object + The opaque index object. + + Returns + ------- + parent : index object + The parent index object. + row : int + The position of the resuling index in the parent's children. + + Raises + ------ + IndexError + If the Root object is passed as the index, this method will + raise an IndexError, as it has no parent. + """ + if index == Root: + raise IndexError("Root index has no parent.") + return Root, int(index) + + def from_id(self, id): + """ Given an integer id, return the corresponding index. + + Parameters + ---------- + id : int + An integer object id value. + + Returns + ------- + index : index object + The persistent index object associated with this id. + """ + if id == 0: + return Root + return id - 1 + + def id(self, index): + """ Given an index, return the corresponding id. + + Parameters + ---------- + index : index object + The persistent index object. + + Returns + ------- + id : int + The associated integer object id value. + """ + if index == Root: + return 0 + return index + 1 + + +class TupleIndexManager(AbstractIndexManager): + + #: A dictionary that maps tuples to the canonical version of the tuple. + _cache = Dict(Tuple, Tuple, {Root: Root}, can_reset=True) + + #: A dictionary that maps ids to the canonical version of the tuple. + _id_cache = Dict(Int, Tuple, {0: Root}, can_reset=True) + + def create_index(self, parent, row): + """ Given a parent index and a row number, create an index. + + Parameters + ---------- + parent : index object + The parent index object. + row : non-negative int + The position of the resulting index in the parent's children. + + Returns + ------- + index : index object + The resulting opaque index object. + + Raises + ------ + IndexError + Negative row values raise an IndexError exception. + """ + if row < 0: + raise IndexError("Row must be non-negative. Got {}".format(row)) + + index = (parent, row) + canonical_index = self._cache.setdefault(index, index) + self._id_cache[self.id(canonical_index)] = canonical_index + return canonical_index + + def get_parent_and_row(self, index): + """ Given an index object, return the parent index and row. + + Parameters + ---------- + index : index object + The opaque index object. + + Returns + ------- + parent : index object + The parent index object. + row : int + The position of the resuling index in the parent's children. + + Raises + ------ + IndexError + If the Root object is passed as the index, this method will + raise an IndexError, as it has no parent. + """ + if index == Root: + raise IndexError("Root index has no parent.") + return index + + def from_id(self, id): + """ Given an integer id, return the corresponding index. + + Parameters + ---------- + id : int + An integer object id value. + + Returns + ------- + index : index object + The persistent index object associated with this id. + """ + return self._id_cache[id] + + def id(self, index): + """ Given an index, return the corresponding id. + + Parameters + ---------- + index : index object + The persistent index object. + + Returns + ------- + id : int + The associated integer object id value. + """ + if index == Root: + return 0 + canonical_index = self._cache.setdefault(index, index) + return id(canonical_index) diff --git a/pyface/data_view/tests/__init__.py b/pyface/data_view/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/data_view/tests/test_abstract_value_type.py b/pyface/data_view/tests/test_abstract_value_type.py new file mode 100644 index 000000000..b3cea4e52 --- /dev/null +++ b/pyface/data_view/tests/test_abstract_value_type.py @@ -0,0 +1,81 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase +from unittest.mock import Mock + +from traits.api import Str +from traits.testing.unittest_tools import UnittestTools + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.abstract_value_type import AbstractValueType + + +class ValueType(AbstractValueType): + + #: a parameter which should fire the update trait + sample_parameter = Str(update_value_type=True) + + +class TestAbstractValueType(UnittestTools, TestCase): + + def setUp(self): + self.model = Mock() + self.model.get_value = Mock(return_value=1.0) + self.model.can_set_value = Mock(return_value=True) + self.model.set_value = Mock() + + def test_has_editor_value(self): + value_type = ValueType() + result = value_type.has_editor_value(self.model, [0], [0]) + self.assertTrue(result) + + def test_has_editor_value_can_set_value_false(self): + self.model.can_set_value = Mock(return_value=False) + value_type = ValueType() + result = value_type.has_editor_value(self.model, [0], [0]) + self.assertFalse(result) + + def test_get_editor_value(self): + value_type = ValueType() + result = value_type.get_editor_value(self.model, [0], [0]) + self.assertEqual(result, 1.0) + + def test_set_editor_value(self): + value_type = ValueType() + value_type.set_editor_value(self.model, [0], [0], 2.0) + self.model.set_value.assert_called_once_with([0], [0], 2.0) + + def test_set_editor_value_set_value_raises(self): + self.model.set_value = Mock(side_effect=DataViewSetError) + value_type = ValueType() + with self.assertRaises(DataViewSetError): + value_type.set_editor_value(self.model, [0], [0], 2.0) + self.model.set_value.assert_called_once_with([0], [0], 2.0) + + def test_has_text(self): + value_type = ValueType() + result = value_type.has_text(self.model, [0], [0]) + self.assertTrue(result) + + def test_get_text(self): + value_type = ValueType() + result = value_type.get_text(self.model, [0], [0]) + self.assertEqual(result, "1.0") + + def test_set_text(self): + value_type = ValueType() + with self.assertRaises(DataViewSetError): + value_type.set_text(self.model, [0], [0], "2.0") + + def test_parameter_update(self): + value_type = ValueType() + with self.assertTraitChanges(value_type, 'updated', count=1): + value_type.sample_parameter = "new value" diff --git a/pyface/data_view/tests/test_data_view_widget.py b/pyface/data_view/tests/test_data_view_widget.py new file mode 100644 index 000000000..c3487e232 --- /dev/null +++ b/pyface/data_view/tests/test_data_view_widget.py @@ -0,0 +1,82 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +import unittest + +from traits.testing.optional_dependencies import numpy as np, requires_numpy +from traits.testing.unittest_tools import UnittestTools + +from pyface.gui import GUI +from pyface.window import Window + +from pyface.data_view.data_models.api import ArrayDataModel +from pyface.data_view.data_view_widget import DataViewWidget +from pyface.data_view.value_types.api import FloatValue + + +@requires_numpy +class TestWidget(unittest.TestCase, UnittestTools): + def setUp(self): + self.gui = GUI() + + self.parent = Window() + self.parent._create() + self.addCleanup(self._destroy_parent) + self.gui.process_events() + + self.widget = self._create_widget() + + self.parent.open() + self.gui.process_events() + + def _create_widget(self): + self.data = np.arange(30.0).reshape(5, 6) + self.model = ArrayDataModel(data=self.data, value_type=FloatValue()) + return DataViewWidget( + parent=self.parent.control, + data_model=self.model + ) + + def _create_widget_control(self): + self.widget._create() + self.addCleanup(self._destroy_widget) + self.widget.show(True) + self.gui.process_events() + + def _destroy_parent(self): + self.parent.destroy() + self.gui.process_events() + self.parent = None + + def _destroy_widget(self): + self.widget.destroy() + self.gui.process_events() + self.widget = None + + def test_defaults(self): + self.assertTrue(self.widget.header_visible) + + def test_lifecycle(self): + self._create_widget_control() + + def test_header_visible(self): + self._create_widget_control() + + self.widget.header_visible = False + self.gui.process_events() + + self.assertFalse(self.widget._get_control_header_visible()) + + def test_header_visible_before_control(self): + self.widget.header_visible = False + + self._create_widget_control() + self.assertFalse(self.widget._get_control_header_visible()) diff --git a/pyface/data_view/tests/test_index_manager.py b/pyface/data_view/tests/test_index_manager.py new file mode 100644 index 000000000..95ae31000 --- /dev/null +++ b/pyface/data_view/tests/test_index_manager.py @@ -0,0 +1,193 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase + +from pyface.data_view.index_manager import ( + IntIndexManager, Root, TupleIndexManager, +) + + +class IndexManagerMixin: + + def test_root_has_no_parent(self): + with self.assertRaises(IndexError): + self.index_manager.get_parent_and_row(Root) + + def test_root_to_sequence(self): + result = self.index_manager.to_sequence(Root) + + self.assertEqual(result, ()) + + def test_root_from_sequence(self): + result = self.index_manager.from_sequence([]) + + self.assertIs(result, Root) + + def test_root_id_round_trip(self): + root_id = self.index_manager.id(Root) + result = self.index_manager.from_id(root_id) + + self.assertIs(result, Root) + + def test_simple_sequence_round_trip(self): + sequence = (5,) + index = self.index_manager.from_sequence(sequence) + result = self.index_manager.to_sequence(index) + + self.assertEqual(result, sequence) + + def test_simple_sequence_invalid(self): + sequence = (-5,) + with self.assertRaises(IndexError): + self.index_manager.from_sequence(sequence) + + def test_simple_sequence_to_parent_row(self): + sequence = (5,) + index = self.index_manager.from_sequence(sequence) + result = self.index_manager.get_parent_and_row(index) + + self.assertEqual(result, (Root, 5)) + + def test_simple_row_round_trip(self): + index = self.index_manager.create_index(Root, 5) + result = self.index_manager.get_parent_and_row(index) + + self.assertEqual(result, (Root, 5)) + + def test_simple_row_invalid(self): + with self.assertRaises(IndexError): + self.index_manager.create_index(Root, -5) + + def test_simple_row_to_sequence(self): + index = self.index_manager.create_index(Root, 5) + result = self.index_manager.to_sequence(index) + + self.assertEqual(result, (5,)) + + def test_simple_id_round_trip(self): + index = self.index_manager.create_index(Root, 5) + id = self.index_manager.id(index) + result = self.index_manager.from_id(id) + + self.assertEqual(result, index) + + +class TestIntIndexManager(IndexManagerMixin, TestCase): + + def setUp(self): + super().setUp() + self.index_manager = IntIndexManager() + + def tearDown(self): + self.index_manager.reset() + + def test_create_index_root(self): + result = self.index_manager.create_index(Root, 5) + self.assertEqual(result, 5) + + def test_create_index_leaf(self): + with self.assertRaises(RuntimeError): + self.index_manager.create_index(5, 1) + + def test_create_index_negative(self): + with self.assertRaises(IndexError): + self.index_manager.create_index(Root, -5) + + +class TestTupleIndexManager(IndexManagerMixin, TestCase): + + def setUp(self): + super().setUp() + self.index_manager = TupleIndexManager() + + def tearDown(self): + self.index_manager.reset() + + def test_complex_sequence_round_trip(self): + sequence = (5, 6, 7, 8, 9, 10) + index = self.index_manager.from_sequence(sequence) + result = self.index_manager.to_sequence(index) + + self.assertEqual(result, sequence) + + def test_complex_sequence_identical_index(self): + sequence = (5, 6, 7, 8, 9, 10) + index_1 = self.index_manager.from_sequence(sequence[:]) + index_2 = self.index_manager.from_sequence(sequence[:]) + + self.assertIs(index_1, index_2) + + def test_complex_sequence_to_parent_row(self): + sequence = (5, 6, 7, 8, 9, 10) + index = self.index_manager.from_sequence(sequence) + + parent, row = self.index_manager.get_parent_and_row(index) + + self.assertEqual(row, 10) + self.assertIs( + parent, + self.index_manager.from_sequence((5, 6, 7, 8, 9)) + ) + + def test_complex_index_round_trip(self): + sequence = (5, 6, 7, 8, 9, 10) + + parent = Root + for depth, row in enumerate(sequence): + with self.subTest(depth=depth): + index = self.index_manager.create_index(parent, row) + result = self.index_manager.get_parent_and_row(index) + self.assertIs(result[0], parent) + self.assertEqual(result[1], row) + parent = index + + def test_complex_index_create_index_identical(self): + sequence = (5, 6, 7, 8, 9, 10) + + parent = Root + for depth, row in enumerate(sequence): + with self.subTest(depth=depth): + index_1 = self.index_manager.create_index(parent, row) + index_2 = self.index_manager.create_index(parent, row) + self.assertIs(index_1, index_2) + parent = index_1 + + def test_complex_index_to_sequence(self): + sequence = (5, 6, 7, 8, 9, 10) + parent = Root + for depth, row in enumerate(sequence): + with self.subTest(depth=depth): + index = self.index_manager.create_index(parent, row) + result = self.index_manager.to_sequence(index) + self.assertEqual(result, sequence[:depth+1]) + parent = index + + def test_complex_index_sequence_round_trip(self): + parent = Root + for depth, row in enumerate([5, 6, 7, 8, 9, 10]): + with self.subTest(depth=depth): + index = self.index_manager.create_index(parent, row) + sequence = self.index_manager.to_sequence(index) + result = self.index_manager.from_sequence(sequence) + self.assertIs(result, index) + parent = index + + def test_complex_index_id_round_trip(self): + sequence = (5, 6, 7, 8, 9, 10) + parent = Root + for depth, row in enumerate(sequence): + with self.subTest(depth=depth): + index = self.index_manager.create_index(parent, row) + id = self.index_manager.id(index) + self.assertIsInstance(id, int) + result = self.index_manager.from_id(id) + self.assertIs(result, index) + parent = index diff --git a/pyface/data_view/value_types/__init__.py b/pyface/data_view/value_types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/data_view/value_types/api.py b/pyface/data_view/value_types/api.py new file mode 100644 index 000000000..1e42d4e49 --- /dev/null +++ b/pyface/data_view/value_types/api.py @@ -0,0 +1,15 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from .constant_value import ConstantValue # noqa: F401 +from .editable_value import EditableValue # noqa: F401 +from .no_value import NoValue, no_value # noqa: F401 +from .numeric_value import FloatValue, IntValue, NumericValue # noqa: F401 +from .text_value import TextValue # noqa: F401 diff --git a/pyface/data_view/value_types/constant_value.py b/pyface/data_view/value_types/constant_value.py new file mode 100644 index 000000000..7b6146a16 --- /dev/null +++ b/pyface/data_view/value_types/constant_value.py @@ -0,0 +1,31 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Str + +from pyface.data_view.abstract_value_type import AbstractValueType + + +class ConstantValue(AbstractValueType): + """ A value type that does not depend on the underlying data model. + + This value type is not editable, but the other data channels it + provides can be modified by changing the appropriate trait on the + value type. + """ + + #: The text value to display. + text = Str(update_value_type=True) + + def has_editor_value(self, model, row, column): + return False + + def get_text(self, model, row, column): + return self.text diff --git a/pyface/data_view/value_types/editable_value.py b/pyface/data_view/value_types/editable_value.py new file mode 100644 index 000000000..73c3ac7a6 --- /dev/null +++ b/pyface/data_view/value_types/editable_value.py @@ -0,0 +1,99 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Bool + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.abstract_value_type import AbstractValueType + + +class EditableValue(AbstractValueType): + """ A base class for editable values. + + This class provides two things beyond the base AbstractValueType: + a trait ``is_editable`` which allows toggling editing state on and + off, and an ``is_valid` method that is used for validation before + setting a value. + """ + + #: Whether or not the value is editable, assuming the underlying data can + #: be set. + is_editable = Bool(True, update_value_type=True) + + def is_valid(self, model, row, column, value): + """ Whether or not the value is valid for the data item specified. + + The default implementation returns True for all values. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + value : any + The value to validate. + + Returns + ------- + is_valid : bool + Whether or not the value is valid. + """ + return True + + # AbstractValueType Interface -------------------------------------------- + + def has_editor_value(self, model, row, column): + """ Return whether or not the value can be edited. + + A cell is editable if the underlying data can be set, and the + ``is_editable`` flag is set to True + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + has_editor_value : bool + Whether or not the value is editable. + """ + return model.can_set_value(row, column) and self.is_editable + + def set_editor_value(self, model, row, column, value): + """ Set the edited value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being set. + column : sequence of int + The column in the data model being set. + value : any + The value being set. + + Raises + ------- + DataViewSetError + If the value cannot be set. + """ + if self.is_valid(model, row, column, value): + model.set_value(row, column, value) + else: + raise DataViewSetError("Invalid value set: {!r}".format(value)) diff --git a/pyface/data_view/value_types/no_value.py b/pyface/data_view/value_types/no_value.py new file mode 100644 index 000000000..085a9be8a --- /dev/null +++ b/pyface/data_view/value_types/no_value.py @@ -0,0 +1,25 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from pyface.data_view.abstract_value_type import AbstractValueType + + +class NoValue(AbstractValueType): + """ A ValueType that has no data in any channel. """ + + def has_editor_value(self, model, row, column): + return False + + def has_text(self, model, row, column): + return False + + +#: Standard instance of the NoValue class, since it has no state. +no_value = NoValue() diff --git a/pyface/data_view/value_types/numeric_value.py b/pyface/data_view/value_types/numeric_value.py new file mode 100644 index 000000000..d1c8e161d --- /dev/null +++ b/pyface/data_view/value_types/numeric_value.py @@ -0,0 +1,149 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import locale +from math import inf + +from traits.api import Callable, Float + +from pyface.data_view.abstract_data_model import DataViewSetError +from .editable_value import EditableValue + + +def format_locale(value): + return "{:n}".format(value) + + +class NumericValue(EditableValue): + """ Data channels for a numeric value. + """ + + #: The minimum value for the numeric value. + minimum = Float(-inf) + + #: The maximum value for the numeric value. + maximum = Float(inf) + + #: A function that converts to the numeric type. + evaluate = Callable() + + #: A function that converts the required type to a string for display. + format = Callable(format_locale, update_value_type=True) + + #: A function that converts the required type from a display string. + unformat = Callable(locale.delocalize) + + def is_valid(self, model, row, column, value): + """ Whether or not the value within the specified range. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + value : any + The value to validate. + + Returns + ------- + is_valid : bool + Whether or not the value is valid. + """ + try: + return self.minimum <= value <= self.maximum + except Exception: + return False + + def get_editor_value(self, model, row, column): + """ Get the numerical value for the editor to use. + + This uses the evaluate method to convert the underlying value to a + number. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + editor_value : number + Whether or not the value is editable. + """ + # evaluate is needed to convert numpy types to python types so + # Qt recognises them + return self.evaluate(model.get_value(row, column)) + + def get_text(self, model, row, column): + """ Get the display text from the underlying value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + text : str + The text to display. + """ + return self.format(model.get_value(row, column)) + + def set_text(self, model, row, column, text): + """ Set the text of the underlying value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + text : str + The text to set. + + Raises + ------- + DataViewSetError + If the value cannot be set. + """ + try: + value = self.evaluate(self.unformat(text)) + except ValueError: + raise DataViewSetError( + "Can't evaluate value: {!r}".format(text) + ) + self.set_editor_value(model, row, column, value) + + +class IntValue(NumericValue): + """ Data channels for an integer value. + """ + + evaluate = Callable(int) + + +class FloatValue(NumericValue): + """ Data channels for a floating point value. + """ + + evaluate = Callable(float) diff --git a/pyface/data_view/value_types/tests/__init__.py b/pyface/data_view/value_types/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/data_view/value_types/tests/test_constant_value.py b/pyface/data_view/value_types/tests/test_constant_value.py new file mode 100644 index 000000000..025dc1298 --- /dev/null +++ b/pyface/data_view/value_types/tests/test_constant_value.py @@ -0,0 +1,51 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase +from unittest.mock import Mock + +from traits.testing.unittest_tools import UnittestTools + +from pyface.data_view.value_types.constant_value import ConstantValue + + +class TestConstantValue(UnittestTools, TestCase): + + def setUp(self): + self.model = Mock() + + def test_defaults(self): + value_type = ConstantValue() + self.assertEqual(value_type.text, "") + + def test_has_editor_value(self): + value_type = ConstantValue() + self.assertFalse(value_type.has_editor_value(self.model, [0], [0])) + + def test_has_text(self): + value_type = ConstantValue() + self.assertFalse(value_type.has_text(self.model, [0], [0])) + + def test_has_text_true(self): + value_type = ConstantValue(text="something") + self.assertTrue(value_type.has_text(self.model, [0], [0])) + + def test_get_text(self): + value_type = ConstantValue(text="something") + self.assertEqual( + value_type.get_text(self.model, [0], [0]), + "something" + ) + + def test_text_changed(self): + value_type = ConstantValue() + with self.assertTraitChanges(value_type, 'updated'): + value_type.text = 'something' + self.assertEqual(value_type.text, 'something') diff --git a/pyface/data_view/value_types/tests/test_editable_value.py b/pyface/data_view/value_types/tests/test_editable_value.py new file mode 100644 index 000000000..c6a28bd49 --- /dev/null +++ b/pyface/data_view/value_types/tests/test_editable_value.py @@ -0,0 +1,74 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase +from unittest.mock import Mock + +from traits.testing.unittest_tools import UnittestTools + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.value_types.editable_value import EditableValue + + +class EditableWithValid(EditableValue): + + def is_valid(self, model, row, column, value): + return value >= 0 + + +class TestEditableValue(UnittestTools, TestCase): + + def setUp(self): + self.model = Mock() + self.model.get_value = Mock(return_value=1.0) + self.model.can_set_value = Mock(return_value=True) + self.model.set_value = Mock() + + def test_default(self): + value_type = EditableValue() + self.assertTrue(value_type.is_editable) + + def test_is_valid(self): + value_type = EditableValue() + result = value_type.is_valid(self.model, [0], [0], 2.0) + self.assertTrue(result) + + def test_has_editor_value(self): + value_type = EditableValue() + result = value_type.has_editor_value(self.model, [0], [0]) + self.assertTrue(result) + + def test_has_editor_value_not_editable(self): + value_type = EditableValue(is_editable=False) + result = value_type.has_editor_value(self.model, [0], [0]) + self.assertFalse(result) + + def test_set_editor_value(self): + value_type = EditableValue() + value_type.set_editor_value(self.model, [0], [0], 2.0) + self.model.set_value.assert_called_once_with([0], [0], 2.0) + + def test_set_editor_value_set_value_raises(self): + self.model.set_value = Mock(side_effect=DataViewSetError) + value_type = EditableValue(is_editable=False) + with self.assertRaises(DataViewSetError): + value_type.set_editor_value(self.model, [0], [0], 2.0) + self.model.set_value.assert_called_once_with([0], [0], 2.0) + + def test_set_editor_value_not_valid(self): + value_type = EditableWithValid() + with self.assertRaises(DataViewSetError): + value_type.set_editor_value(self.model, [0], [0], -1.0) + self.model.set_value.assert_not_called() + + def test_is_editable_update(self): + value_type = EditableValue() + with self.assertTraitChanges(value_type, 'updated', count=1): + value_type.is_editable = False diff --git a/pyface/data_view/value_types/tests/test_no_value.py b/pyface/data_view/value_types/tests/test_no_value.py new file mode 100644 index 000000000..1e4a7659e --- /dev/null +++ b/pyface/data_view/value_types/tests/test_no_value.py @@ -0,0 +1,28 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase +from unittest.mock import Mock + +from pyface.data_view.value_types.no_value import NoValue + + +class TestNoValue(TestCase): + + def setUp(self): + self.model = Mock() + + def test_has_editor_value(self): + value_type = NoValue() + self.assertFalse(value_type.has_editor_value(self.model, [0], [0])) + + def test_has_text(self): + value_type = NoValue() + self.assertFalse(value_type.has_text(self.model, [0], [0])) diff --git a/pyface/data_view/value_types/tests/test_numeric_value.py b/pyface/data_view/value_types/tests/test_numeric_value.py new file mode 100644 index 000000000..7611be9c4 --- /dev/null +++ b/pyface/data_view/value_types/tests/test_numeric_value.py @@ -0,0 +1,101 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase +from unittest.mock import Mock + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.value_types.numeric_value import ( + FloatValue, IntValue, NumericValue, format_locale +) + + +class TestNumericValue(TestCase): + + def setUp(self): + self.model = Mock() + self.model.get_value = Mock(return_value=1.0) + self.model.can_set_value = Mock(return_value=True) + self.model.set_value = Mock(return_value=True) + + def test_defaults(self): + value = NumericValue() + self.assertIsNone(value.evaluate) + + def test_is_valid(self): + value = NumericValue() + self.assertTrue(value.is_valid(None, [0], [0], 0.0)) + + def test_is_valid_false(self): + value = NumericValue(minimum=0.0, maximum=1.0) + self.assertFalse(value.is_valid(None, [0], [0], -1.0)) + + def test_is_valid_error(self): + value = NumericValue() + self.assertFalse(value.is_valid(None, [0], [0], 'invalid')) + + def test_get_editor_value(self): + value = NumericValue(evaluate=float) + editable = value.get_editor_value(self.model, [0], [0]) + + self.assertEqual(editable, 1.0) + + def test_set_editor_value(self): + value = NumericValue(evaluate=float) + value.set_editor_value(self.model, [0], [0], 1.0) + self.model.set_value.assert_called_once_with([0], [0], 1.0) + + def test_set_editor_value_invalid(self): + value = NumericValue(minimum=0.0, maximum=1.0) + with self.assertRaises(DataViewSetError): + value.set_editor_value(self.model, [0], [0], -1.0) + self.model.set_value.assert_not_called() + + def test_set_editor_value_error(self): + value = NumericValue(minimum=0.0, maximum=1.0) + with self.assertRaises(DataViewSetError): + value.set_editor_value(self.model, [0], [0], 'invalid') + self.model.set_value.assert_not_called() + + def test_get_text(self): + value = NumericValue() + text = value.get_text(self.model, [0], [0]) + self.assertEqual(text, format_locale(1.0)) + + def test_set_text(self): + value = NumericValue(evaluate=float) + value.set_text(self.model, [0], [0], format_locale(1.1)) + self.model.set_value.assert_called_once_with([0], [0], 1.1) + + def test_set_text_invalid(self): + value = NumericValue(evaluate=float, minimum=0.0, maximum=1.0) + with self.assertRaises(DataViewSetError): + value.set_text(self.model, [0], [0], format_locale(1.1)) + self.model.set_value.assert_not_called() + + def test_set_text_error(self): + value = NumericValue(evaluate=float) + with self.assertRaises(DataViewSetError): + value.set_text(self.model, [0], [0], "invalid") + self.model.set_value.assert_not_called() + + +class TestIntValue(TestCase): + + def test_defaults(self): + value = IntValue() + self.assertIs(value.evaluate, int) + + +class TestFloatValue(TestCase): + + def test_defaults(self): + value = FloatValue() + self.assertIs(value.evaluate, float) diff --git a/pyface/data_view/value_types/tests/test_text_value.py b/pyface/data_view/value_types/tests/test_text_value.py new file mode 100644 index 000000000..78736e829 --- /dev/null +++ b/pyface/data_view/value_types/tests/test_text_value.py @@ -0,0 +1,58 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from unittest import TestCase +from unittest.mock import Mock + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.value_types.text_value import TextValue + + +class TestTextValue(TestCase): + + def setUp(self): + self.model = Mock() + self.model.get_value = Mock(return_value="test") + self.model.can_set_value = Mock(return_value=True) + self.model.set_value = Mock() + + def test_defaults(self): + value = TextValue() + self.assertTrue(value.is_editable) + + def test_is_valid(self): + value = TextValue() + self.assertTrue(value.is_valid(None, [0], [0], "test")) + + def test_get_editor_value(self): + value = TextValue() + editable = value.get_editor_value(self.model, [0], [0]) + self.assertEqual(editable, "test") + + def test_set_editor_value(self): + value = TextValue() + value.set_editor_value(self.model, [0], [0], "test") + self.model.set_value.assert_called_once_with([0], [0], "test") + + def test_get_text(self): + value = TextValue() + editable = value.get_text(self.model, [0], [0]) + self.assertEqual(editable, "test") + + def test_set_text(self): + value = TextValue() + value.set_text(self.model, [0], [0], "test") + self.model.set_value.assert_called_once_with([0], [0], "test") + + def test_set_text_no_set_value(self): + self.model.can_set_value = Mock(return_value=False) + value = TextValue() + value.set_text(self.model, [0], [0], "test") + self.model.set_value.assert_called_once_with([0], [0], "test") diff --git a/pyface/data_view/value_types/text_value.py b/pyface/data_view/value_types/text_value.py new file mode 100644 index 000000000..0f42f4c81 --- /dev/null +++ b/pyface/data_view/value_types/text_value.py @@ -0,0 +1,65 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Callable + +from .editable_value import EditableValue + + +class TextValue(EditableValue): + """ Editable value that presents a string value. + """ + + #: A function that converts the value to a string for display. + format = Callable(str, update_value_type=True) + + #: A function that converts to a value from a display string. + unformat = Callable(str) + + def get_text(self, model, row, column): + """ Get the display text from the underlying value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + + Returns + ------- + text : str + The text to display. + """ + return self.format(model.get_value(row, column)) + + def set_text(self, model, row, column, text): + """ Set the text of the underlying value. + + Parameters + ---------- + model : AbstractDataModel + The data model holding the data. + row : sequence of int + The row in the data model being queried. + column : sequence of int + The column in the data model being queried. + text : str + The text to set. + + Raises + ------- + DataViewSetError + If the value cannot be set. + """ + value = self.unformat(text) + self.set_editor_value(model, row, column, value) diff --git a/pyface/ui/qt4/data_view/__init__.py b/pyface/ui/qt4/data_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/ui/qt4/data_view/data_view_item_model.py b/pyface/ui/qt4/data_view/data_view_item_model.py new file mode 100644 index 000000000..67839276d --- /dev/null +++ b/pyface/ui/qt4/data_view/data_view_item_model.py @@ -0,0 +1,267 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import logging + +from pyface.qt import is_qt5 +from pyface.qt.QtCore import QAbstractItemModel, QModelIndex, Qt +from pyface.data_view.index_manager import Root +from pyface.data_view.abstract_data_model import ( + AbstractDataModel, DataViewSetError +) + + +logger = logging.getLogger(__name__) + +# XXX This file is scaffolding and may need to be rewritten + + +class DataViewItemModel(QAbstractItemModel): + """ A QAbstractItemModel that understands AbstractDataModels. """ + + def __init__(self, model, parent=None): + super().__init__(parent) + self.model = model + self.destroyed.connect(self._on_destroyed) + + @property + def model(self): + return self._model + + @model.setter + def model(self, model: AbstractDataModel): + self._disconnect_model_observers() + if hasattr(self, '_model'): + self.beginResetModel() + self._model = model + self.endResetModel() + else: + # model is being initialized + self._model = model + self._connect_model_observers() + + # model event listeners + + def on_structure_changed(self, event): + self.beginResetModel() + self.endResetModel() + + def on_values_changed(self, event): + top, left, bottom, right = event.new + if top == () and bottom == (): + # this is a column header change + self.headerDataChanged.emit(Qt.Horizontal, left[0], right[0]) + elif left == () and right == (): + # this is a row header change + # XXX this is currently not supported and not needed + pass + else: + for i, (top_row, bottom_row) in enumerate(zip(top, bottom)): + if top_row != bottom_row: + break + top = top[:i+1] + bottom = bottom[:i+1] + + top_left = self._to_model_index(top, left) + bottom_right = self._to_model_index(bottom, right) + self.dataChanged.emit(top_left, bottom_right) + + # Structure methods + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + parent = index.internalPointer() + if parent == Root: + return QModelIndex() + + grandparent, row = self.model.index_manager.get_parent_and_row(parent) + return self.createIndex(row, 0, grandparent) + + def index(self, row, column, parent): + if parent.isValid(): + parent_index = self.model.index_manager.create_index( + parent.internalPointer(), + parent.row(), + ) + else: + parent_index = Root + index = self.createIndex(row, column, parent_index) + return index + + def rowCount(self, index=QModelIndex()): + row_index = self._to_row_index(index) + try: + if self.model.can_have_children(row_index): + return self.model.get_row_count(row_index) + except Exception: + logger.exception("Error in rowCount") + + return 0 + + def columnCount(self, index=QModelIndex()): + row_index = self._to_row_index(index) + try: + # the number of columns is constant; leaf rows return 0 + if self.model.can_have_children(row_index): + return self.model.get_column_count() + 1 + except Exception: + logger.exception("Error in columnCount") + + return 0 + + # Data methods + + def flags(self, index): + row = self._to_row_index(index) + column = self._to_column_index(index) + value_type = self.model.get_value_type(row, column) + if row == () and column == (): + return 0 + + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if is_qt5 and not self.model.can_have_children(row): + flags |= Qt.ItemNeverHasChildren + + if value_type and value_type.has_editor_value(self.model, row, column): + flags |= Qt.ItemIsEditable + + return flags + + def data(self, index, role=Qt.DisplayRole): + row = self._to_row_index(index) + column = self._to_column_index(index) + value_type = self.model.get_value_type(row, column) + if not value_type: + return None + + if role == Qt.DisplayRole: + if value_type.has_text(self.model, row, column): + return value_type.get_text(self.model, row, column) + elif role == Qt.EditRole: + if value_type.has_editor_value(self.model, row, column): + return value_type.get_editor_value(self.model, row, column) + + return None + + def setData(self, index, value, role=Qt.EditRole): + row = self._to_row_index(index) + column = self._to_column_index(index) + value_type = self.model.get_value_type(row, column) + if not value_type: + return False + + try: + if role == Qt.EditRole: + if value_type.has_editor_value(self.model, row, column): + value_type.set_editor_value(self.model, row, column, value) + elif role == Qt.DisplayRole: + if value_type.has_text(self.model, row, column): + value_type.set_text(self.model, row, column, value) + except DataViewSetError: + return False + except Exception: + # unexpected error, log and persevere + logger.exception( + "setData failed: row %r, column %r, value %r", + row, + column, + value, + ) + return False + else: + return True + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if orientation == Qt.Horizontal: + row = () + if section == 0: + column = () + else: + column = (section - 1,) + else: + # XXX not currently used, but here for symmetry and completeness + row = (section,) + column = () + + value_type = self.model.get_value_type(row, column) + + if role == Qt.DisplayRole: + if value_type.has_text(self.model, row, column): + return value_type.get_text(self.model, row, column) + + # Private utility methods + + def _on_destroyed(self): + self._disconnect_model_observers() + self._model = None + + def _connect_model_observers(self): + if getattr(self, "_model", None) is not None: + self._model.observe( + self.on_structure_changed, + 'structure_changed', + dispatch='ui', + ) + self._model.observe( + self.on_values_changed, + 'values_changed', + dispatch='ui', + ) + + def _disconnect_model_observers(self): + if getattr(self, "_model", None) is not None: + self._model.observe( + self.on_structure_changed, + 'structure_changed', + dispatch='ui', + remove=True, + ) + self._model.observe( + self.on_values_changed, + 'values_changed', + dispatch='ui', + remove=True, + ) + + def _to_row_index(self, index): + if not index.isValid(): + row_index = () + else: + parent = index.internalPointer() + if parent == Root: + row_index = () + else: + row_index = self.model.index_manager.to_sequence(parent) + row_index += (index.row(),) + return row_index + + def _to_column_index(self, index): + if not index.isValid(): + return () + else: + column = index.column() + if column == 0: + return () + else: + return (column - 1,) + + def _to_model_index(self, row_index, column_index): + if len(row_index) == 0: + return QModelIndex() + index = self.model.index_manager.from_sequence(row_index[:-1]) + row = row_index[-1] + if len(column_index) == 0: + column = 0 + else: + column = column_index[0] + 1 + + return self.createIndex(row, column, index) + diff --git a/pyface/ui/qt4/data_view/data_view_widget.py b/pyface/ui/qt4/data_view/data_view_widget.py new file mode 100644 index 000000000..f636ff0dd --- /dev/null +++ b/pyface/ui/qt4/data_view/data_view_widget.py @@ -0,0 +1,66 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Bool, Instance, observe, provides + +from pyface.qt.QtCore import QAbstractItemModel +from pyface.qt.QtGui import QTreeView +from pyface.data_view.abstract_data_model import AbstractDataModel +from pyface.data_view.i_data_view_widget import ( + IDataViewWidget, MDataViewWidget +) +from pyface.ui.qt4.widget import Widget +from .data_view_item_model import DataViewItemModel + +# XXX This file is scaffolding and may need to be rewritten + + +@provides(IDataViewWidget) +class DataViewWidget(MDataViewWidget, Widget): + """ The Qt implementation of the DataViewWidget. """ + + #: The QAbstractItemModel instance used by the view. This will + #: usually be a DataViewItemModel subclass. + _item_model = Instance(QAbstractItemModel) + + def _create_control(self, parent): + """ Create the DataViewWidget's toolkit control. """ + self._create_item_model() + + control = QTreeView(parent) + control.setUniformRowHeights(True) + control.setModel(self._item_model) + control.setHeaderHidden(not self.header_visible) + return control + + def _create_item_model(self): + """ Create the DataViewItemModel which wraps the data model. """ + self._item_model = DataViewItemModel(self.data_model) + + def destroy(self): + """ Perform any actions required to destroy the control. + """ + self.control.setModel(None) + super().destroy() + # ensure that we release the reference to the item model + self._item_model = None + + def _get_control_header_visible(self): + """ Method to get the control's header visibility. """ + return not self.control.isHeaderHidden() + + def _set_control_header_visible(self, tooltip): + """ Method to set the control's header visibility. """ + self.control.setHeaderHidden(not tooltip) + + @observe('data_model', dispatch='ui') + def update_item_model(self, event): + if self._item_model is not None: + self._item_model.model = event.new diff --git a/pyface/ui/wx/data_view/__init__.py b/pyface/ui/wx/data_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyface/ui/wx/data_view/data_view_model.py b/pyface/ui/wx/data_view/data_view_model.py new file mode 100644 index 000000000..bc9d55f1e --- /dev/null +++ b/pyface/ui/wx/data_view/data_view_model.py @@ -0,0 +1,200 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import logging + +from pyface.data_view.abstract_data_model import DataViewSetError +from pyface.data_view.index_manager import Root +from wx.dataview import DataViewItem, DataViewModel as wxDataViewModel + + +logger = logging.getLogger(__name__) + + +type_hint_to_variant = { + 'str': "string", + 'int': "longlong", + 'float': "double", + 'bool': "bool", + 'datetime': "datetime", + 'container': "list", + 'object': "void*", +} + + +# XXX This file is scaffolding and may need to be rewritten or expanded + +class DataViewModel(wxDataViewModel): + """ A wxDataViewModel that understands AbstractDataModels. """ + + def __init__(self, model): + super().__init__() + self.model = model + + @property + def model(self): + return self._model + + @model.setter + def model(self, model): + if hasattr(self, '_model'): + # disconnect trait listeners + self._model.observe( + self.on_structure_changed, + 'structure_changed', + dispatch='ui', + remove=True, + ) + self._model.observe( + self.on_values_changed, + 'values_changed', + dispatch='ui', + remove=True, + ) + self._model = model + else: + # model is being initialized + self._model = model + + # hook up trait listeners + self._model.observe( + self.on_structure_changed, + 'structure_changed', + dispatch='ui', + ) + self._model.observe( + self.on_values_changed, + 'values_changed', + dispatch='ui', + ) + + def on_structure_changed(self, event): + self.Cleared() + + def on_values_changed(self, event): + top, left, bottom, right = event.new + if top == () and bottom == (): + # this is a column header change, reset everything + self.Cleared() + elif left == () and right == (): + # this is a row header change + # XXX this is currently not supported and not needed + pass + else: + for i, (top_row, bottom_row) in enumerate(zip(top, bottom)): + if top_row != bottom_row: + break + top = top[:i+1] + bottom = bottom[:i+1] + + if top == bottom and left == right: + # single value change + self.ValueChanged(self._to_item(top), left[0]) + elif top == bottom: + # single item change + self.ItemChanged(self._to_item(top)) + else: + # multiple item change + items = [ + self._to_item(top[:i] + [row]) + for row in range(top[i], bottom[i]+1) + ] + self.ItemsChanged(items) + + def GetParent(self, item): + index = self._to_index(item) + if index == Root: + return DataViewItem() + parent, row = self.model.index_manager.get_parent_and_row(index) + parent_id = self.model.index_manager.id(parent) + if parent_id == 0: + return DataViewItem() + return DataViewItem(parent_id) + + def GetChildren(self, item, children): + index = self._to_index(item) + row_index = self.model.index_manager.to_sequence(index) + n_children = self.model.get_row_count(row_index) + for i in range(n_children): + child_index = self.model.index_manager.create_index(index, i) + child_id = self.model.index_manager.id(child_index) + children.append(DataViewItem(child_id)) + return n_children + + def IsContainer(self, item): + row_index = self._to_row_index(item) + return self.model.can_have_children(row_index) + + def HasValue(self, item, column): + return True + + def HasChildren(self, item): + row_index = self._to_row_index(item) + return self.model.has_child_rows(row_index) + + def GetValue(self, item, column): + row_index = self._to_row_index(item) + if column == 0: + column_index = () + else: + column_index = (column - 1,) + value_type = self.model.get_value_type(row_index, column_index) + if value_type.has_text(self.model, row_index, column_index): + return value_type.get_text(self.model, row_index, column_index) + return '' + + def SetValue(self, value, item, column): + row_index = self._to_row_index(item) + if column == 0: + column_index = () + else: + column_index = (column - 1,) + try: + value_type = self.model.get_value_type(row_index, column_index) + value_type.set_text(self.model, row_index, column_index, value) + except DataViewSetError: + return False + except Exception: + logger.exception( + "SetValue failed: row %r, column %r, value %r", + row_index, + column_index, + value, + ) + return False + else: + return True + + def GetColumnCount(self): + return self.model.get_column_count() + 1 + + def GetColumnType(self, column): + value_type = self.model.get_value_type((), (column-1,)) + return type_hint_to_variant.get(value_type.type_hint, "string") + + def _to_row_index(self, item): + id = item.GetID() + if id is None: + id = 0 + index = self.model.index_manager.from_id(int(id)) + return self.model.index_manager.to_sequence(index) + + def _to_item(self, row_index): + if len(row_index) == 0: + return DataViewItem() + index = self.model.index_manager.from_sequence(row_index) + id = self.model.index_manager.id(index) + return DataViewItem(id) + + def _to_index(self, item): + id = item.GetID() + if id is None: + id = 0 + return self.model.index_manager.from_id(int(id)) diff --git a/pyface/ui/wx/data_view/data_view_widget.py b/pyface/ui/wx/data_view/data_view_widget.py new file mode 100644 index 000000000..1e6ae5c90 --- /dev/null +++ b/pyface/ui/wx/data_view/data_view_widget.py @@ -0,0 +1,82 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Instance, observe, provides + +from wx.dataview import ( + DataViewCtrl, DataViewModel as wxDataViewModel, DATAVIEW_CELL_EDITABLE, + DATAVIEW_CELL_ACTIVATABLE +) +from pyface.data_view.i_data_view_widget import ( + IDataViewWidget, MDataViewWidget +) +from pyface.ui.wx.widget import Widget +from .data_view_model import DataViewModel + + +# XXX This file is scaffolding and may need to be rewritten + +@provides(IDataViewWidget) +class DataViewWidget(MDataViewWidget, Widget): + """ The Wx implementation of the DataViewWidget. """ + + #: The QAbstractItemModel instance used by the view. This will + #: usually be a DataViewModel subclass. + _item_model = Instance(wxDataViewModel) + + def _create_control(self, parent): + """ Create the DataViewWidget's toolkit control. """ + self._create_item_model() + + control = DataViewCtrl(parent) + control.AssociateModel(self._item_model) + # required for wxPython refcounting system + self._item_model.DecRef() + + # create columns for view + value_type = self._item_model.model.get_value_type([], []) + control.AppendTextColumn( + value_type.get_text(self._item_model.model, [], []), + 0, + mode=DATAVIEW_CELL_ACTIVATABLE, + ) + for column in range(self._item_model.GetColumnCount()-1): + value_type = self._item_model.model.get_value_type([], [column]) + control.AppendTextColumn( + value_type.get_text(self._item_model.model, [], [column]), + column+1, + mode=DATAVIEW_CELL_EDITABLE, + ) + return control + + def _create_item_model(self): + """ Create the DataViewItemModel which wraps the data model. """ + self._item_model = DataViewModel(self.data_model) + + def destroy(self): + """ Perform any actions required to destroy the control. """ + super().destroy() + # ensure that we release the reference to the item model + self._item_model = None + + def _get_control_header_visible(self): + """ Method to get the control's header visibility. """ + # TODO: need to read DV_NO_HEADER + pass + + def _set_control_header_visible(self, tooltip): + """ Method to set the control's header visibility. """ + # TODO: need to toggle DV_NO_HEADER + pass + + @observe('data_model', dispatch='ui') + def update_item_model(self, event): + if self._item_model is not None: + self._item_model.model = event.new