diff --git a/examples/widgets/lists/fruit_detail_view.py b/examples/widgets/lists/fruit_detail_view.py index e02182fee..eccb2e955 100644 --- a/examples/widgets/lists/fruit_detail_view.py +++ b/examples/widgets/lists/fruit_detail_view.py @@ -15,7 +15,10 @@ class FruitDetailView(GridLayout): def __init__(self, **kwargs): kwargs['cols'] = 2 + self.fruit_name = kwargs.get('fruit_name', '') super(FruitDetailView, self).__init__(**kwargs) + if self.fruit_name: + self.redraw() def redraw(self, *args): self.clear_widgets() @@ -85,7 +88,10 @@ class FruitImageDetailView(BoxLayout): def __init__(self, **kwargs): kwargs['orientation'] = 'vertical' + self.fruit_name = kwargs.get('fruit_name', '') super(FruitImageDetailView, self).__init__(**kwargs) + if self.fruit_name: + self.redraw() def redraw(self, *args): self.clear_widgets() diff --git a/examples/widgets/lists/list_cascade.py b/examples/widgets/lists/list_cascade.py index ae7e6481b..e9b007ea1 100644 --- a/examples/widgets/lists/list_cascade.py +++ b/examples/widgets/lists/list_cascade.py @@ -153,16 +153,14 @@ class CascadingView(GridLayout): # Detail view, for a given fruit, on the right: # - detail_view = FruitDetailView(size_hint=(.6, 1.0)) + detail_view = FruitDetailView( + fruit_name=fruits_list_adapter.selection[0].text, + size_hint=(.6, 1.0)) fruits_list_adapter.bind( on_selection_change=detail_view.fruit_changed) self.add_widget(detail_view) - # Force triggering of on_selection_change() for the DetailView, for - # correct initial display. - fruits_list_adapter.touch_selection() - if __name__ == '__main__': diff --git a/examples/widgets/lists/list_cascade_dict.py b/examples/widgets/lists/list_cascade_dict.py index a050aa16f..d58d263be 100644 --- a/examples/widgets/lists/list_cascade_dict.py +++ b/examples/widgets/lists/list_cascade_dict.py @@ -81,16 +81,14 @@ class CascadingView(GridLayout): # Detail view, for a given fruit, on the right: # - detail_view = FruitDetailView(size_hint=(.6, 1.0)) + detail_view = FruitDetailView( + fruit_name=fruits_dict_adapter.selection[0].text, + size_hint=(.6, 1.0)) fruits_dict_adapter.bind( on_selection_change=detail_view.fruit_changed) self.add_widget(detail_view) - # Force triggering of on_selection_change() for the DetailView, for - # correct initial display. - fruits_dict_adapter.touch_selection() - if __name__ == '__main__': diff --git a/examples/widgets/lists/list_cascade_images.py b/examples/widgets/lists/list_cascade_images.py index 5915aea3a..cbe0b0d1f 100644 --- a/examples/widgets/lists/list_cascade_images.py +++ b/examples/widgets/lists/list_cascade_images.py @@ -110,15 +110,14 @@ class CascadingView(GridLayout): # Detail view, for a given fruit, on the right: # - detail_view = FruitImageDetailView(size_hint=(.6, 1.0)) + detail_view = FruitImageDetailView( + fruit_name=fruits_list_adapter.selection[0].fruit_name, + size_hint=(.6, 1.0)) + fruits_list_adapter.bind( on_selection_change=detail_view.fruit_changed) self.add_widget(detail_view) - # Force triggering of on_selection_change() for the DetailView, for - # correct initial display. [TODO] Surely there is a way to avoid this. - fruits_list_adapter.touch_selection() - if __name__ == '__main__': diff --git a/examples/widgets/lists/list_composite.py b/examples/widgets/lists/list_composite.py index 6e3b25544..ac3503504 100644 --- a/examples/widgets/lists/list_composite.py +++ b/examples/widgets/lists/list_composite.py @@ -38,9 +38,7 @@ class MainView(GridLayout): 'cls_dicts': [{'cls': ListItemButton, 'kwargs': {'text': rec['text']}}, {'cls': ListItemLabel, - 'kwargs': {'text': "Middle", - 'merge_text': True, - 'delimiter': '-', + 'kwargs': {'text': "Middle-{0}".format(rec['text']), 'is_representing_cls': True}}, {'cls': ListItemButton, 'kwargs': {'text': rec['text']}}]} diff --git a/examples/widgets/lists/list_master_detail.py b/examples/widgets/lists/list_master_detail.py index bd99e7037..d3d31ef5d 100644 --- a/examples/widgets/lists/list_master_detail.py +++ b/examples/widgets/lists/list_master_detail.py @@ -35,7 +35,10 @@ class MasterDetailView(GridLayout): self.add_widget(master_list_view) - detail_view = FruitDetailView(size_hint=(.7, 1.0)) + detail_view = FruitDetailView( + fruit_name=dict_adapter.selection[0].text, + size_hint=(.7, 1.0)) + self.add_widget(detail_view) dict_adapter.bind(on_selection_change=detail_view.fruit_changed) diff --git a/kivy/adapters/adapter.py b/kivy/adapters/adapter.py index f8b1b7654..e104a29b4 100644 --- a/kivy/adapters/adapter.py +++ b/kivy/adapters/adapter.py @@ -77,13 +77,7 @@ class Adapter(EventDispatcher): def bind_triggers_to_view(self, func): self.bind(data=func) - def get_count(self): - if self.data: - return 1 - else: - return 0 - - def get_data_item(self, index): + def get_data_item(self): return self.data def get_view(self, index): #pragma: no cover diff --git a/kivy/adapters/collectionadapter.py b/kivy/adapters/collectionadapter.py deleted file mode 100644 index e02b5f07f..000000000 --- a/kivy/adapters/collectionadapter.py +++ /dev/null @@ -1,366 +0,0 @@ -''' -CollectionAdapter -================= - -.. versionadded:: 1.5 - -:class:`CollectionAdapter` is a base class for adapters dedicated to lists or -dicts or other collections of data. - -Provides selection and view creation and management functionality to -"collection-style" views, such as :class:`ListView`, and their item views, via -the :class:`ListAdapter` and :class:`DictAdapter` subclasses. -''' - -from kivy.event import EventDispatcher -from kivy.adapters.adapter import Adapter -from kivy.adapters.models import SelectableDataItem -from kivy.properties import ObjectProperty -from kivy.properties import ListProperty -from kivy.properties import DictProperty -from kivy.properties import BooleanProperty -from kivy.properties import OptionProperty -from kivy.properties import NumericProperty -from kivy.lang import Builder - - -class CollectionAdapter(Adapter, EventDispatcher): - ''' - A base class for adapters interfacing with lists, dictionaries, or other - collection type data, adding selection and view creation and management - functonality. - ''' - - selection = ListProperty([]) - '''The selection list property is the container for selected items. - ''' - - selection_mode = OptionProperty('single', - options=('none', 'single', 'multiple')) - '''Selection modes: - - none -- use the list as a simple list (no select action). This option - is here so that selection can be turned off, momentarily or - permanently, for an existing list adapter. :class:`ListAdapter` - is not meant to be used as a primary no-selection list adapter. - Use :class:`SimpleListAdapter` for that. - - single -- multi-touch/click ignored. single item selecting only - - multiple -- multi-touch / incremental addition to selection allowed; - may be limited to a count by selection_limit - ''' - - propagate_selection_to_data = BooleanProperty(False) - '''Normally, data items are not selected/deselected, because the data items - might not have an is_selected boolean property -- only the item view for a - given data item is selected/deselected, as part of the maintained selection - list. However, if the data items do have an is_selected property, or if - they mix in :class:`SelectableDataItem`, the selection machinery can - propagate selection to data items. This can be useful for storing selection - state in a local database or backend database for maintaining state in game - play or other similar needs. It is a convenience function. - - To propagate selection or not? - - Consider a shopping list application for shopping for fruits at the - market. The app allows selection of fruits to buy for each day of the - week, presenting seven lists, one for each day of the week. Each list is - loaded with all the available fruits, but the selection for each is a - subset. There is only one set of fruit data shared between the lists, so - it would not make sense to propagate selection to the data, because - selection in any of the seven lists would clobber and mix with that of the - others. - - However, consider a game that uses the same fruits data for selecting - fruits available for fruit-tossing. A given round of play could have a - full fruits list, with fruits available for tossing shown selected. If the - game is saved and rerun, the full fruits list, with selection marked on - each item, would be reloaded fine if selection is always propagated to the - data. You could accomplish the same functionality by writing code to - operate on list selection, but having selection stored on the data might - prove convenient in some cases. - ''' - - allow_empty_selection = BooleanProperty(True) - '''The allow_empty_selection may be used for cascading selection between - several list views, or between a list view and an observing view. Such - automatic maintainence of selection is important for all but simple - list displays. Set allow_empty_selection False, so that selection is - auto-initialized, and always maintained, and so that any observing views - may likewise be updated to stay in sync. - ''' - - selection_limit = NumericProperty(0) - '''When selection_mode is multiple, if selection_limit is non-zero, this - number will limit the number of selected items. It can even be 1, which is - equivalent to single selection. This is because a program could be - programmatically changing selection_limit on the fly, and all possible - values should be included. - ''' - - cached_views = DictProperty({}) - '''View instances for data items are instantiated and managed in the - adapter. Here we maintain a dictionary containing the view - instances keyed to the indices in the data. - - This dictionary works as a cache. get_view() only asks for a view from - the adapter if one is not already stored for the requested index. - ''' - - def __init__(self, **kwargs): - super(CollectionAdapter, self).__init__(**kwargs) - - self.register_event_type('on_selection_change') - - self.bind(selection_mode=self.check_for_empty_selection, - allow_empty_selection=self.check_for_empty_selection, - data=self.update_for_new_data) - - self.update_for_new_data() - - def set_item_view(self, index, item_view): - pass - - def delete_cache(self, *args): - self.cached_views = {} - - def get_view(self, index): - if index in self.cached_views: - return self.cached_views[index] - item_view = self.create_view(index) - if item_view: - self.cached_views[index] = item_view - return item_view - - def create_view(self, index): - '''This method is more complicated than the one in Adapter and - SimpleListAdapter, because here we create bindings for the data item, - and its children back to self.handle_selection(), and do other - selection-related tasks to keep item views in sync with the data. - ''' - item = self.get_data_item(index) - if item is None: - return None - - item_args = None - if self.args_converter: - item_args = self.args_converter(item) - else: - item_args = item - - item_args['index'] = index - - if self.cls: - view_instance = self.cls(**item_args) - else: - view_instance = Builder.template(self.template, **item_args) - - if self.propagate_selection_to_data: - # The data item must be a subclass of SelectableDataItem, or must have - # an is_selected boolean or function, so it has is_selected available. - # If is_selected is unavailable on the data item, an exception is - # raised. - # - # [TODO] Only tested boolean is_selected. - # - # [TODO] Wouldn't use of getattr help a lot here? - # - if issubclass(item.__class__, SelectableDataItem): - if item.is_selected: - self.handle_selection(view_instance) - elif type(item) == dict and 'is_selected' in item: - if item['is_selected']: - self.handle_selection(view_instance) - elif hasattr(item, 'is_selected'): - if isfunction(item.is_selected) or ismethod(item.is_selected): - if item.is_selected(): - self.handle_selection(view_instance) - else: - if item.is_selected: - self.handle_selection(view_instance) - else: - msg = "CollectionAdapter: unselectable data item for {0}".format(item) - raise Exception(msg) - - view_instance.bind(on_release=self.handle_selection) - - # [TODO] Tested? - for child in view_instance.children: - child.bind(on_release=self.handle_selection) - - return view_instance - - def on_selection_change(self, *args): - '''on_selection_change() is the default handler for the - on_selection_change event. - ''' - pass - - def handle_selection(self, view, *args): - if view not in self.selection: - if self.selection_mode in ['none', 'single'] and \ - len(self.selection) > 0: - for selected_view in self.selection: - self.deselect_item_view(selected_view) - if self.selection_mode != 'none': - if self.selection_mode == 'multiple': - if self.allow_empty_selection: - if self.selection_limit > 0: - if len(self.selection) < self.selection_limit: - self.select_item_view(view) - else: - self.select_item_view(view) - else: - self.select_item_view(view) - else: - if self.selection_mode == 'none': - for selected_view in self.selection: - self.deselect_item_view(selected_view) - else: - self.deselect_item_view(view) - # If the deselection makes selection empty, the following call - # will check allows_empty_selection, and if False, will - # select the first item. If view happens to be the first item, - # this will be a reselection, and the user will notice no - # change, except perhaps a flicker. - # - self.check_for_empty_selection() - - self.dispatch('on_selection_change') - - def select_data_item(self, item): - self.set_data_item_selection(item, True) - - def deselect_data_item(self, item): - self.set_data_item_selection(item, False) - - def set_data_item_selection(self, item, value): - if issubclass(item.__class__, SelectableDataItem): - item.is_selected = value - elif type(item) is dict: - item['is_selected'] = value - elif hasattr(item, 'is_selected'): - item.is_selected = value - else: - raise Exception('Selection: data item is not selectable') - - def select_item_view(self, view): - view.select() - view.is_selected = True - self.selection.append(view) - - # [TODO] sibling selection for composite items - # Needed? Or handled from parent? - # (avoid circular, redundant selection) - #if hasattr(view, 'parent') and hasattr(view.parent, 'children'): - #siblings = [child for child in view.parent.children if child != view] - #for sibling in siblings: - #if hasattr(sibling, 'select'): - #sibling.select() - - # child selection - for child in view.children: - if hasattr(child, 'select'): - child.select() - - if self.propagate_selection_to_data: - data_item = self.get_data_item(view.index) - self.select_data_item(data_item) - - def select_list(self, view_list, extend): - '''The select call is made for the items in the provided view_list. - - Arguments: - - view_list: the list of item views to become the new selection, or - to add to the existing selection - - extend: boolean for whether or not to extend the existing list - ''' - - # Select all the item views. - for view in view_list: - self.select_item_view(view) - - # Extend or set selection. - if extend: - self.selection.extend(view_list) - else: - self.selection = view_list - - self.dispatch('on_selection_change') - - def deselect_item_view(self, view): - view.deselect() - view.is_selected = False - self.selection.remove(view) - - # [TODO] sibling deselection for composite items - # Needed? Or handled from parent? - # (avoid circular, redundant selection) - #if hasattr(view, 'parent') and hasattr(view.parent, 'children'): - #siblings = [child for child in view.parent.children if child != view] - #for sibling in siblings: - #if hasattr(sibling, 'deselect'): - #sibling.deselect() - - # child deselection - for child in view.children: - if hasattr(child, 'deselect'): - child.deselect() - - if self.propagate_selection_to_data: - item = self.get_data_item(view.index) - self.deselect_data_item(item) - - def deselect_list(self, l): - for view in l: - self.deselect_item_view(view) - self.dispatch('on_selection_change') - - def update_for_new_data(self, *args): - self.delete_cache() - self.initialize_selection() - - def initialize_selection(self, *args): - if len(self.selection) > 0: - self.selection = [] - self.dispatch('on_selection_change') - - self.check_for_empty_selection() - - def check_for_empty_selection(self, *args): - if not self.allow_empty_selection: - if len(self.selection) == 0: - # Select the first item if we have it. - v = self.get_view(0) - if v is not None: - print 'selecting first data item view', v, v.is_selected - self.handle_selection(v) - - def trim_left_of_sel(self, *args): #pragma: no cover - '''Cut list items with indices in sorted_keys that are less than the - index of the first selected item, if there is selection. - ''' - raise NotImplementedError - - def trim_right_of_sel(self, *args): #pragma: no cover - '''Cut list items with indices in sorted_keys that are greater than - the index of the last selected item, if there is selection. - ''' - raise NotImplementedError - - def trim_to_sel(self, *args): #pragma: no cover - '''Cut list items with indices in sorted_keys that are les than or - greater than the index of the last selected item, if there is - selection. This preserves intervening list items within the selected - range. - ''' - raise NotImplementedError - - def cut_to_sel(self, *args): #pragma: no cover - '''Same as trim_to_sel, but intervening list items within the selected - range are cut also, leaving only list items that are selected. - ''' - raise NotImplementedError diff --git a/kivy/adapters/dictadapter.py b/kivy/adapters/dictadapter.py index adfc95091..f3bb36f27 100644 --- a/kivy/adapters/dictadapter.py +++ b/kivy/adapters/dictadapter.py @@ -6,7 +6,7 @@ DictAdapter :class:`DictAdapter` is an adapter around a python dictionary of records. -From :class:`Adapter`, :class:`DictAdapter` gets these properties: +From :class:`ListAdapter`, :class:`DictAdapter` gets these properties: Use only one: @@ -22,9 +22,6 @@ From :class:`Adapter`, :class:`DictAdapter` gets these properties: provided, a default one is set, that assumes that the data items are strings. -From the :class:`CollectionAdapter` mixin, :class:`DictAdapter` has -these properties: - - selection - selection_mode - allow_empty_selection @@ -38,11 +35,11 @@ If you wish to have a bare-bones list adapter, without selection, use from kivy.properties import ListProperty, DictProperty from kivy.lang import Builder -from kivy.adapters.collectionadapter import CollectionAdapter +from kivy.adapters.listadapter import ListAdapter from kivy.adapters.models import SelectableDataItem -class DictAdapter(CollectionAdapter): +class DictAdapter(ListAdapter): sorted_keys = ListProperty([]) '''The sorted_keys list property contains a list of hashable objects (can @@ -75,10 +72,26 @@ class DictAdapter(CollectionAdapter): self.bind(sorted_keys=func) self.bind(data=func) + # self.data is paramount to self.sorted_keys. If sorted_keys is reset to + # mismatch data, force a reset of sorted_keys to data.keys(). So, in order + # to do a complete reset of data and sorted_keys, data must be reset + # first, followed by a reset of sorted_keys, if needed. def initialize_sorted_keys(self, *args): + stale_sorted_keys = False + for key in self.sorted_keys: + if not key in self.data: + stale_sorted_keys = True + break + if stale_sorted_keys: + self.sorted_keys = sorted(self.data.keys()) self.delete_cache() self.initialize_selection() + # Override ListAdapter.update_for_new_data(). + def update_for_new_data(self, *args): + self.initialize_sorted_keys() + + # Note: this is not len(self.data). def get_count(self): return len(self.sorted_keys) @@ -87,29 +100,6 @@ class DictAdapter(CollectionAdapter): return None return self.data[self.sorted_keys[index]] - def select_data_item(self, item): - # The data item must be a subclass of SelectableDataItem, or must have - # an is_selected boolean or function, so it has is_selected available. - # If is_selected is unavailable on the data item, an exception is - # raised. - # - if issubclass(item.__class__, SelectableDataItem): - item.is_selected = True - elif type(item) == dict and 'is_selected' in item: - item['is_selected'] = True - elif hasattr(item, 'is_selected'): - if isfunction(item.is_selected) or ismethod(item.is_selected): - item.is_selected() - else: - item.is_selected = True - else: - msg = "ListAdapter: unselectable data item for {0}".format(item) - raise Exception(msg) - - def touch_selection(self, *args): - self.dispatch('on_selection_change') - - # [TODO] Also make methods for scroll_to_sel_start, scroll_to_sel_end, # scroll_to_sel_middle. diff --git a/kivy/adapters/listadapter.py b/kivy/adapters/listadapter.py index 22cd3e6b1..207c5e9e9 100644 --- a/kivy/adapters/listadapter.py +++ b/kivy/adapters/listadapter.py @@ -1,4 +1,9 @@ ''' +ListAdapter +================= + +.. versionadded:: 1.5 + ListAdapter =========== @@ -6,6 +11,8 @@ ListAdapter :class:`ListAdapter` is an adapter around a python list. +Selection operations are a main concern for the class. + From :class:`Adapter`, :class:`ListAdapter` gets these properties: Use only one: @@ -22,30 +29,138 @@ From :class:`Adapter`, :class:`ListAdapter` gets these properties: provided, a default one is set, that assumes that the data items are strings. -From the :class:`CollectionAdapter` mixin, :class:`ListAdapter` has -these properties: +and adds several for selection: - - selection - - selection_mode - - allow_empty_selection + - selection, a list of selected items. + + - selection_mode, 'single', 'multiple', 'none' + + - allow_empty_selection, a boolean -- False, and a selection is forced; + True, and only user or programmatic action will + change selection, and it can be empty. and several methods used in selection operations. If you wish to have a bare-bones list adapter, without selection, use :class:`SimpleListAdapter`. + +:class:`DictAdapter` is a subclass of :class:`ListAdapter`. + ''' -from kivy.properties import ListProperty, DictProperty, ObjectProperty -from kivy.adapters.collectionadapter import CollectionAdapter +import inspect +from kivy.event import EventDispatcher +from kivy.adapters.adapter import Adapter from kivy.adapters.models import SelectableDataItem - -from inspect import isfunction, ismethod +from kivy.properties import ObjectProperty +from kivy.properties import ListProperty +from kivy.properties import DictProperty +from kivy.properties import BooleanProperty +from kivy.properties import OptionProperty +from kivy.properties import NumericProperty +from kivy.lang import Builder -class ListAdapter(CollectionAdapter): +class ListAdapter(Adapter, EventDispatcher): + ''' + A base class for adapters interfacing with lists, dictionaries, or other + collection type data, adding selection and view creation and management + functonality. + ''' + + selection = ListProperty([]) + '''The selection list property is the container for selected items. + ''' + + selection_mode = OptionProperty('single', + options=('none', 'single', 'multiple')) + '''Selection modes: + + none -- use the list as a simple list (no select action). This option + is here so that selection can be turned off, momentarily or + permanently, for an existing list adapter. :class:`ListAdapter` + is not meant to be used as a primary no-selection list adapter. + Use :class:`SimpleListAdapter` for that. + + single -- multi-touch/click ignored. single item selecting only + + multiple -- multi-touch / incremental addition to selection allowed; + may be limited to a count by selection_limit + ''' + + propagate_selection_to_data = BooleanProperty(False) + '''Normally, data items are not selected/deselected, because the data items + might not have an is_selected boolean property -- only the item view for a + given data item is selected/deselected, as part of the maintained selection + list. However, if the data items do have an is_selected property, or if + they mix in :class:`SelectableDataItem`, the selection machinery can + propagate selection to data items. This can be useful for storing selection + state in a local database or backend database for maintaining state in game + play or other similar needs. It is a convenience function. + + To propagate selection or not? + + Consider a shopping list application for shopping for fruits at the + market. The app allows selection of fruits to buy for each day of the + week, presenting seven lists, one for each day of the week. Each list is + loaded with all the available fruits, but the selection for each is a + subset. There is only one set of fruit data shared between the lists, so + it would not make sense to propagate selection to the data, because + selection in any of the seven lists would clobber and mix with that of the + others. + + However, consider a game that uses the same fruits data for selecting + fruits available for fruit-tossing. A given round of play could have a + full fruits list, with fruits available for tossing shown selected. If the + game is saved and rerun, the full fruits list, with selection marked on + each item, would be reloaded fine if selection is always propagated to the + data. You could accomplish the same functionality by writing code to + operate on list selection, but having selection stored on the data might + prove convenient in some cases. + ''' + + allow_empty_selection = BooleanProperty(True) + '''The allow_empty_selection may be used for cascading selection between + several list views, or between a list view and an observing view. Such + automatic maintainence of selection is important for all but simple + list displays. Set allow_empty_selection False, so that selection is + auto-initialized, and always maintained, and so that any observing views + may likewise be updated to stay in sync. + ''' + + selection_limit = NumericProperty(-1) + '''When selection_mode is multiple, if selection_limit is non-negative, + this number will limit the number of selected items. It can even be 1, + which is equivalent to single selection. This is because a program could + be programmatically changing selection_limit on the fly, and all possible + values should be included. + + If selection_limit is not set, the default is -1, meaning that no limit + will be enforced. + ''' + + cached_views = DictProperty({}) + '''View instances for data items are instantiated and managed in the + adapter. Here we maintain a dictionary containing the view + instances keyed to the indices in the data. + + This dictionary works as a cache. get_view() only asks for a view from + the adapter if one is not already stored for the requested index. + ''' def __init__(self, **kwargs): super(ListAdapter, self).__init__(**kwargs) + + self.register_event_type('on_selection_change') + + self.bind(selection_mode=self.selection_mode_changed, + allow_empty_selection=self.check_for_empty_selection, + data=self.update_for_new_data) + + self.update_for_new_data() + + def delete_cache(self, *args): + self.cached_views = {} def get_count(self): return len(self.data) @@ -55,12 +170,220 @@ class ListAdapter(CollectionAdapter): return None return self.data[index] - def bind_triggers_to_view(self, func): - self.bind(data=func) + def selection_mode_changed(self, *args): + if self.selection_mode == 'none': + for selected_view in self.selection: + self.deselect_item_view(selected_view) + else: + self.check_for_empty_selection() + + def get_view(self, index): + if index in self.cached_views: + return self.cached_views[index] + item_view = self.create_view(index) + if item_view: + self.cached_views[index] = item_view + return item_view + + def create_view(self, index): + '''This method is more complicated than the one in Adapter and + SimpleListAdapter, because here we create bindings for the data item, + and its children back to self.handle_selection(), and do other + selection-related tasks to keep item views in sync with the data. + ''' + item = self.get_data_item(index) + if item is None: + return None + + item_args = self.args_converter(item) + + item_args['index'] = index + + if self.cls: + view_instance = self.cls(**item_args) + else: + view_instance = Builder.template(self.template, **item_args) + + if self.propagate_selection_to_data: + # The data item must be a subclass of SelectableDataItem, or must have + # an is_selected boolean or function, so it has is_selected available. + # If is_selected is unavailable on the data item, an exception is + # raised. + # + if isinstance(item, SelectableDataItem): + if item.is_selected: + self.handle_selection(view_instance) + elif type(item) == dict and 'is_selected' in item: + if item['is_selected']: + self.handle_selection(view_instance) + elif hasattr(item, 'is_selected'): + if (inspect.isfunction(item.is_selected) + or inspect.ismethod(item.is_selected)): + if item.is_selected(): + self.handle_selection(view_instance) + else: + if item.is_selected: + self.handle_selection(view_instance) + else: + msg = "ListAdapter: unselectable data item for {0}" + raise Exception(msg.format(index)) + + view_instance.bind(on_release=self.handle_selection) + + for child in view_instance.children: + child.bind(on_release=self.handle_selection) + + return view_instance + + def on_selection_change(self, *args): + '''on_selection_change() is the default handler for the + on_selection_change event. + ''' + pass + + def handle_selection(self, view, hold_dispatch=False, *args): + if view not in self.selection: + if self.selection_mode in ['none', 'single'] and \ + len(self.selection) > 0: + for selected_view in self.selection: + self.deselect_item_view(selected_view) + if self.selection_mode != 'none': + if self.selection_mode == 'multiple': + if self.allow_empty_selection: + # If < 0, selection_limit is not active. + if self.selection_limit < 0: + self.select_item_view(view) + else: + if len(self.selection) < self.selection_limit: + self.select_item_view(view) + else: + self.select_item_view(view) + else: + self.select_item_view(view) + else: + self.deselect_item_view(view) + if self.selection_mode != 'none': + # If the deselection makes selection empty, the following call + # will check allows_empty_selection, and if False, will + # select the first item. If view happens to be the first item, + # this will be a reselection, and the user will notice no + # change, except perhaps a flicker. + # + self.check_for_empty_selection() + + if not hold_dispatch: + self.dispatch('on_selection_change') + + def select_data_item(self, item): + self.set_data_item_selection(item, True) + + def deselect_data_item(self, item): + self.set_data_item_selection(item, False) + + def set_data_item_selection(self, item, value): + if isinstance(item, SelectableDataItem): + item.is_selected = value + elif type(item) == dict: + item['is_selected'] = value + elif hasattr(item, 'is_selected'): + if (inspect.isfunction(item.is_selected) + or inspect.ismethod(item.is_selected)): + item.is_selected() + else: + item.is_selected = value + + def select_item_view(self, view): + view.select() + view.is_selected = True + self.selection.append(view) + + # [TODO] sibling selection for composite items + # Needed? Or handled from parent? + # (avoid circular, redundant selection) + #if hasattr(view, 'parent') and hasattr(view.parent, 'children'): + #siblings = [child for child in view.parent.children if child != view] + #for sibling in siblings: + #if hasattr(sibling, 'select'): + #sibling.select() + + # child selection + for child in view.children: + if hasattr(child, 'select'): + child.select() + + if self.propagate_selection_to_data: + data_item = self.get_data_item(view.index) + self.select_data_item(data_item) + + def select_list(self, view_list, extend=True): + '''The select call is made for the items in the provided view_list. + + Arguments: + + view_list: the list of item views to become the new selection, or + to add to the existing selection + + extend: boolean for whether or not to extend the existing list + ''' + if not extend: + self.selection = [] + + for view in view_list: + self.handle_selection(view, hold_dispatch=True) - def touch_selection(self, *args): self.dispatch('on_selection_change') + def deselect_item_view(self, view): + view.deselect() + view.is_selected = False + self.selection.remove(view) + + # [TODO] sibling deselection for composite items + # Needed? Or handled from parent? + # (avoid circular, redundant selection) + #if hasattr(view, 'parent') and hasattr(view.parent, 'children'): + #siblings = [child for child in view.parent.children if child != view] + #for sibling in siblings: + #if hasattr(sibling, 'deselect'): + #sibling.deselect() + + # child deselection + for child in view.children: + if hasattr(child, 'deselect'): + child.deselect() + + if self.propagate_selection_to_data: + item = self.get_data_item(view.index) + self.deselect_data_item(item) + + def deselect_list(self, l): + for view in l: + self.handle_selection(view, hold_dispatch=True) + + self.dispatch('on_selection_change') + + # [TODO] Could easily add select_all() and deselect_all(). + + def update_for_new_data(self, *args): + self.delete_cache() + self.initialize_selection() + + def initialize_selection(self, *args): + if len(self.selection) > 0: + self.selection = [] + self.dispatch('on_selection_change') + + self.check_for_empty_selection() + + def check_for_empty_selection(self, *args): + if not self.allow_empty_selection: + if len(self.selection) == 0: + # Select the first item if we have it. + v = self.get_view(0) + if v is not None: + print 'selecting first data item view', v, v.is_selected + self.handle_selection(v) + # [TODO] Also make methods for scroll_to_sel_start, scroll_to_sel_end, # scroll_to_sel_middle. @@ -68,13 +391,18 @@ class ListAdapter(CollectionAdapter): '''Cut list items with indices in sorted_keys that are less than the index of the first selected item, if there is selection. ''' - pass + if len(self.selection) > 0: + first_sel_index = min([sel.index for sel in self.selection]) + self.data = self.data[first_sel_index:] def trim_right_of_sel(self, *args): '''Cut list items with indices in sorted_keys that are greater than the index of the last selected item, if there is selection. ''' - pass + if len(self.selection) > 0: + last_sel_index = max([sel.index for sel in self.selection]) + print 'last_sel_index', last_sel_index + self.data = self.data[:last_sel_index+1] def trim_to_sel(self, *args): '''Cut list items with indices in sorted_keys that are les than or @@ -82,10 +410,15 @@ class ListAdapter(CollectionAdapter): selection. This preserves intervening list items within the selected range. ''' - pass + if len(self.selection) > 0: + sel_indices = [sel.index for sel in self.selection] + first_sel_index = min(sel_indices) + last_sel_index = max(sel_indices) + self.data = self.data[first_sel_index:last_sel_index+1] def cut_to_sel(self, *args): '''Same as trim_to_sel, but intervening list items within the selected range are cut also, leaving only list items that are selected. ''' - pass + if len(self.selection) > 0: + self.data = self.selection diff --git a/kivy/adapters/models.py b/kivy/adapters/models.py index 5e9f4c41c..7de58de5f 100644 --- a/kivy/adapters/models.py +++ b/kivy/adapters/models.py @@ -52,8 +52,3 @@ class SelectableDataItem(object): @is_selected.setter def is_selected(self, value): self._is_selected = value - - @is_selected.deleter - def is_selected(self): - self._is_selected = None - diff --git a/kivy/tests/test_adapters.py b/kivy/tests/test_adapters.py index 1534302ae..f76331d86 100644 --- a/kivy/tests/test_adapters.py +++ b/kivy/tests/test_adapters.py @@ -5,8 +5,10 @@ Adapter tests import unittest -from kivy.uix.selectableview import SelectableView +from kivy.uix.listview import SelectableView from kivy.uix.listview import ListItemButton +from kivy.uix.listview import ListItemLabel +from kivy.uix.listview import CompositeListItem from kivy.uix.label import Label from kivy.adapters.models import SelectableDataItem @@ -15,6 +17,9 @@ from kivy.adapters.simplelistadapter import SimpleListAdapter from kivy.adapters.listadapter import ListAdapter from kivy.adapters.dictadapter import DictAdapter +from kivy.properties import BooleanProperty +from kivy.properties import StringProperty + from kivy.factory import Factory from kivy.lang import Builder @@ -148,6 +153,7 @@ fruit_data_list_of_dicts = \ 'data': [80, 0, 0, 0, 0, 0, 270, 8, 21, 7, 1, 4, 20, 1, 30, 25, 2, 4], 'is_selected': False}] + fruit_data_attributes = ['(gram weight/ ounce weight)', 'Calories', 'Calories from Fat', @@ -163,6 +169,7 @@ fruit_data_attributes = ['(gram weight/ ounce weight)', 'Calcium', 'Iron'] + fruit_data_attribute_units = ['(g)', '(%DV)', '(mg)', @@ -197,7 +204,7 @@ class CategoryItem(SelectableDataItem): super(CategoryItem, self).__init__(**kwargs) self.name = kwargs.get('name', '') self.fruits = kwargs.get('fruits', []) - #self.is_selected = kwargs.get('is_selected', False) + self.is_selected = kwargs.get('is_selected', False) class FruitItem(SelectableDataItem): @@ -206,7 +213,7 @@ class FruitItem(SelectableDataItem): self.name = kwargs.get('name', '') self.serving_size = kwargs.get('Serving Size', '') self.data = kwargs.get('data', []) - #self.is_selected = kwargs.get('is_selected', False) + self.is_selected = kwargs.get('is_selected', False) def reset_to_defaults(db_dict): @@ -257,6 +264,14 @@ Builder.load_string(''' is_selected: ctx.is_selected ''') +Builder.load_string(''' +[CustomSimpleListItem@SelectableView+BoxLayout]: + size_hint_y: ctx.size_hint_y + height: ctx.height + ListItemButton: + text: ctx.text +''') + class AdaptersTestCase(unittest.TestCase): @@ -265,6 +280,28 @@ class AdaptersTestCase(unittest.TestCase): 'size_hint_y': None, 'height': 25} + self.integers_dict = \ + {str(i): {'text': str(i), 'is_selected': False} for i in xrange(100)} + + # The third of the four cls_dict items has no kwargs nor text, so + # rec['text'] will be set for it. Likewise, the fifth item has kwargs, + # but it has no 'text' key/value, so should receive the same treatment. + self.composite_args_converter = \ + lambda rec: \ + {'text': rec['text'], + 'size_hint_y': None, + 'height': 25, + 'cls_dicts': [{'cls': ListItemButton, + 'kwargs': {'text': rec['text']}}, + {'cls': ListItemLabel, + 'kwargs': {'text': "Middle-{0}".format(rec['text']), + 'is_representing_cls': True}}, + {'cls': ListItemButton}, + {'cls': ListItemButton, + 'kwargs': {'some key': 'some value'}}, + {'cls': ListItemButton, + 'kwargs': {'text': rec['text']}}]} + reset_to_defaults(fruit_data) @raises(Exception) @@ -366,14 +403,29 @@ class AdaptersTestCase(unittest.TestCase): self.assertEqual(adapter_2.args_converter, list_item_args_converter) adapter = Adapter(data='cat', cls=Label) - self.assertEqual(adapter.get_count(), 1) - self.assertEqual(adapter.get_data_item(0), 'cat') + self.assertEqual(adapter.get_data_item(), 'cat') adapter = Adapter(data=None, cls=Label) - self.assertEqual(adapter.get_count(), 0) - self.assertEqual(adapter.get_data_item(0), None) + self.assertEqual(adapter.get_data_item(), None) - def test_instantiating_simple_list_adapter(self): + def test_instantiating_adapter_bind_triggers_to_view(self): + class PetListener(object): + def __init__(self, pet): + self.current_pet = pet + + def callback(self, *args): + self.current_pet = args[1] + + pet_listener = PetListener('cat') + + adapter = Adapter(data='cat', cls=Label) + adapter.bind_triggers_to_view(pet_listener.callback) + + self.assertEqual(pet_listener.current_pet, 'cat') + adapter.data = 'dog' + self.assertEqual(pet_listener.current_pet, 'dog') + + def test_simple_list_adapter_for_exceptions(self): # with no data with self.assertRaises(Exception) as cm: simple_list_adapter = SimpleListAdapter() @@ -388,6 +440,23 @@ class AdaptersTestCase(unittest.TestCase): msg = 'list adapter: data must be a tuple or list' self.assertEqual(str(cm.exception), msg) + def test_simple_list_adapter_with_template(self): + list_item_args_converter = \ + lambda obj: {'text': str(obj), + 'size_hint_y': None, + 'height': 25} + + simple_list_adapter = \ + SimpleListAdapter(data=['cat', 'dog'], + args_converter=list_item_args_converter, + template='CustomSimpleListItem') + + view = simple_list_adapter.get_view(0) + self.assertEqual(view.__class__.__name__, 'CustomSimpleListItem') + + # For coverage of __repr__: + self.assertEqual(type(str(view)), str) + def test_simple_list_adapter_methods(self): simple_list_adapter = SimpleListAdapter(data=['cat', 'dog'], cls=Label) @@ -402,9 +471,196 @@ class AdaptersTestCase(unittest.TestCase): self.assertIsNone(simple_list_adapter.get_view(-1)) self.assertIsNone(simple_list_adapter.get_view(2)) - def test_list_adapter_selection_mode_none(self): + def test_instantiating_list_adapter(self): + str_args_converter = lambda rec: {'text': rec, + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=['cat', 'dog'], + args_converter=str_args_converter, + cls=ListItemButton) + + self.assertEqual([obj for obj in list_adapter.data], + ['cat', 'dog']) + self.assertEqual(list_adapter.get_count(), 2) + + self.assertEqual(list_adapter.cls, ListItemButton) + self.assertEqual(list_adapter.args_converter, str_args_converter) + self.assertEqual(list_adapter.template, None) + + cat_data_item = list_adapter.get_data_item(0) + self.assertEqual(cat_data_item, 'cat') + self.assertTrue(isinstance(cat_data_item, str)) + + view = list_adapter.get_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + + view = list_adapter.create_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + + view = list_adapter.create_view(-1) + self.assertIsNone(view) + + view = list_adapter.create_view(100) + self.assertIsNone(view) + + def test_list_adapter_selection_mode_single(self): + fruit_data_items[0].is_selected = True + + list_item_args_converter = \ + lambda selectable: {'text': selectable.name, + 'size_hint_y': None, + 'height': 25} + list_adapter = ListAdapter(data=fruit_data_items, - args_converter=self.args_converter, + args_converter=list_item_args_converter, + selection_mode='single', + propagate_selection_to_data=True, + allow_empty_selection=False, + cls=ListItemButton) + + self.assertEqual(sorted([obj.name for obj in list_adapter.data]), + ['Apple', 'Avocado', 'Banana', 'Cantaloupe', 'Cherry', 'Grape', + 'Grapefruit', 'Honeydew', 'Kiwifruit', 'Lemon', 'Lime', + 'Nectarine', 'Orange', 'Peach', 'Pear', 'Pineapple', 'Plum', + 'Strawberry', 'Tangerine', 'Watermelon']) + + self.assertEqual(list_adapter.cls, ListItemButton) + self.assertEqual(list_adapter.args_converter, + list_item_args_converter) + self.assertEqual(list_adapter.template, None) + + apple_data_item = list_adapter.get_data_item(0) + self.assertTrue(isinstance(apple_data_item, FruitItem)) + self.assertTrue(isinstance(apple_data_item, SelectableDataItem)) + self.assertTrue(apple_data_item.is_selected) + + view = list_adapter.get_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + self.assertTrue(view.is_selected) + + def test_list_adapter_with_dict_data(self): + alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + letters_dicts = \ + [{'text': l, 'is_selected': False} for l in alphabet] + + letters_dicts[0]['is_selected'] = True + + list_item_args_converter = lambda rec: {'text': rec['text'], + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=letters_dicts, + args_converter=list_item_args_converter, + selection_mode='single', + propagate_selection_to_data=True, + allow_empty_selection=False, + cls=ListItemButton) + + self.assertEqual(list_adapter.cls, ListItemButton) + self.assertEqual(list_adapter.args_converter, + list_item_args_converter) + self.assertEqual(list_adapter.template, None) + + apple_data_item = list_adapter.get_data_item(0) + self.assertTrue(isinstance(apple_data_item, dict)) + self.assertTrue(apple_data_item['is_selected']) + + view = list_adapter.get_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + self.assertTrue(view.is_selected) + + def test_list_adapter_with_custom_class(self): + + # Use a widget as data item. + class DataItem(Label): + is_selected = BooleanProperty(True) + text = StringProperty('') + + class DataItemWithMethod(DataItem): + _is_selected = BooleanProperty(True) + + def is_selected(self): + return self._is_selected + + class BadDataItem(Label): + text = StringProperty('') + + data_items = [] + data_items.append(DataItem(text='cat')) + data_items.append(DataItemWithMethod(text='dog')) + data_items.append(BadDataItem(text='frog')) + + list_item_args_converter = lambda obj: {'text': obj.text, + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=data_items, + args_converter=list_item_args_converter, + selection_mode='single', + propagate_selection_to_data=True, + allow_empty_selection=False, + cls=ListItemButton) + + self.assertEqual(list_adapter.cls, ListItemButton) + self.assertEqual(list_adapter.args_converter, + list_item_args_converter) + self.assertEqual(list_adapter.template, None) + + apple_data_item = list_adapter.get_data_item(0) + self.assertTrue(isinstance(apple_data_item, DataItem)) + self.assertTrue(apple_data_item.is_selected) + + view = list_adapter.get_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + self.assertTrue(view.is_selected) + + view = list_adapter.get_view(1) + self.assertTrue(isinstance(view, ListItemButton)) + self.assertTrue(view.is_selected) + + with self.assertRaises(Exception) as cm: + view = list_adapter.get_view(2) + + msg = "ListAdapter: unselectable data item for 2" + self.assertEqual(str(cm.exception), msg) + + def test_instantiating_list_adapter_no_args_converter(self): + list_adapter = \ + ListAdapter(data=['cat', 'dog'], + cls=ListItemButton) + + self.assertEqual(list_adapter.get_count(), 2) + + self.assertEqual(list_adapter.cls, ListItemButton) + self.assertIsNotNone(list_adapter.args_converter) + self.assertEqual(list_adapter.template, None) + + cat_data_item = list_adapter.get_data_item(0) + self.assertEqual(cat_data_item, 'cat') + self.assertTrue(isinstance(cat_data_item, str)) + + view = list_adapter.get_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + + view = list_adapter.create_view(0) + self.assertTrue(isinstance(view, ListItemButton)) + + view = list_adapter.create_view(-1) + self.assertIsNone(view) + + view = list_adapter.create_view(100) + self.assertIsNone(view) + + def test_list_adapter_selection_mode_none(self): + list_item_args_converter = \ + lambda selectable: {'text': selectable.name, + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=fruit_data_items, + args_converter=list_item_args_converter, selection_mode='none', allow_empty_selection=True, cls=ListItemButton) @@ -416,12 +672,54 @@ class AdaptersTestCase(unittest.TestCase): 'Strawberry', 'Tangerine', 'Watermelon']) self.assertEqual(list_adapter.cls, ListItemButton) - self.assertEqual(list_adapter.args_converter, self.args_converter) + self.assertEqual(list_adapter.args_converter, list_item_args_converter) self.assertEqual(list_adapter.template, None) apple_data_item = list_adapter.get_data_item(0) self.assertTrue(isinstance(apple_data_item, FruitItem)) + def test_list_adapter_selection_mode_multiple_select_list(self): + list_item_args_converter = \ + lambda selectable: {'text': selectable.name, + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=fruit_data_items, + args_converter=list_item_args_converter, + selection_mode='multiple', + allow_empty_selection=True, + cls=ListItemButton) + + views = [] + views.append(list_adapter.get_view(0)) + views.append(list_adapter.get_view(1)) + views.append(list_adapter.get_view(2)) + + self.assertEqual(len(views), 3) + list_adapter.select_list(views) + self.assertEqual(len(list_adapter.selection), 3) + + views = [] + views.append(list_adapter.get_view(3)) + views.append(list_adapter.get_view(4)) + views.append(list_adapter.get_view(5)) + + self.assertEqual(len(views), 3) + list_adapter.select_list(views) + self.assertEqual(len(list_adapter.selection), 6) + + views = [] + views.append(list_adapter.get_view(0)) + views.append(list_adapter.get_view(1)) + views.append(list_adapter.get_view(2)) + + self.assertEqual(len(views), 3) + list_adapter.select_list(views, extend=False) + self.assertEqual(len(list_adapter.selection), 3) + + list_adapter.deselect_list(views) + self.assertEqual(len(list_adapter.selection), 0) + def test_list_adapter_with_dicts_as_data(self): bare_minimum_dicts = \ [{'text': str(i), 'is_selected': False} for i in xrange(100)] @@ -445,6 +743,46 @@ class AdaptersTestCase(unittest.TestCase): data_item = list_adapter.get_data_item(0) self.assertTrue(type(data_item), dict) + # Utility calls for coverage: + + self.assertEqual(list_adapter.get_count(), 100) + + # Bad index: + self.assertIsNone(list_adapter.get_data_item(-1)) + self.assertIsNone(list_adapter.get_data_item(101)) + + def test_list_adapter_with_dicts_as_data_multiple_selection(self): + bare_minimum_dicts = \ + [{'text': str(i), 'is_selected': False} for i in xrange(100)] + + args_converter = lambda rec: {'text': rec['text'], + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=bare_minimum_dicts, + args_converter=args_converter, + selection_mode='multiple', + allow_empty_selection=False, + cls=ListItemButton) + + self.assertEqual([rec['text'] for rec in list_adapter.data], + [str(i) for i in xrange(100)]) + + self.assertEqual(list_adapter.cls, ListItemButton) + self.assertEqual(list_adapter.args_converter, args_converter) + + for i in range(50): + list_adapter.handle_selection(list_adapter.get_view(i)) + + self.assertEqual(len(list_adapter.selection), 50) + + # This is for code coverage: + list_adapter.selection_mode = 'none' + list_adapter.handle_selection(list_adapter.get_view(25)) + list_adapter.selection_mode = 'single' + list_adapter.handle_selection(list_adapter.get_view(24)) + list_adapter.handle_selection(list_adapter.get_view(24)) + def test_list_adapter_bindings(self): list_item_args_converter = \ lambda selectable: {'text': selectable.name, @@ -540,6 +878,228 @@ class AdaptersTestCase(unittest.TestCase): view = fruit_categories_list_adapter.get_view(0) self.assertEqual(view.__class__.__name__, 'CustomListItem') + second_view = fruit_categories_list_adapter.get_view(1) + fruit_categories_list_adapter.handle_selection(second_view) + self.assertEqual(len(fruit_categories_list_adapter.selection), 1) + + def test_list_adapter_operations_trimming(self): + alphabet = [l for l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + + list_item_args_converter = lambda letter: {'text': letter, + 'size_hint_y': None, + 'height': 25} + + # trim right of sel + + alphabet_adapter = ListAdapter( + data=alphabet, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + a_view = alphabet_adapter.get_view(0) + self.assertEqual(a_view.text, 'A') + + alphabet_adapter.handle_selection(a_view) + self.assertEqual(len(alphabet_adapter.selection), 1) + self.assertTrue(a_view.is_selected) + + alphabet_adapter.trim_right_of_sel() + self.assertEqual(len(alphabet_adapter.data), 1) + + # trim left of sel + + alphabet_adapter = ListAdapter( + data=alphabet, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + z_view = alphabet_adapter.get_view(25) + self.assertEqual(z_view.text, 'Z') + + alphabet_adapter.handle_selection(z_view) + self.assertEqual(len(alphabet_adapter.selection), 1) + self.assertTrue(z_view.is_selected) + + alphabet_adapter.trim_left_of_sel() + self.assertEqual(len(alphabet_adapter.data), 1) + + # trim to sel + + alphabet_adapter = ListAdapter( + data=alphabet, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + g_view = alphabet_adapter.get_view(6) + self.assertEqual(g_view.text, 'G') + alphabet_adapter.handle_selection(g_view) + + m_view = alphabet_adapter.get_view(12) + self.assertEqual(m_view.text, 'M') + alphabet_adapter.handle_selection(m_view) + + alphabet_adapter.trim_to_sel() + self.assertEqual(len(alphabet_adapter.data), 7) + + # cut to sel + + alphabet_adapter = ListAdapter( + data=alphabet, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + g_view = alphabet_adapter.get_view(6) + self.assertEqual(g_view.text, 'G') + alphabet_adapter.handle_selection(g_view) + + m_view = alphabet_adapter.get_view(12) + self.assertEqual(m_view.text, 'M') + alphabet_adapter.handle_selection(m_view) + + alphabet_adapter.cut_to_sel() + self.assertEqual(len(alphabet_adapter.data), 2) + + def test_dict_adapter_composite(self): + item_strings = ["{0}".format(index) for index in xrange(100)] + + # And now the list adapter, constructed with the item_strings as + # the data, a dict to add the required is_selected boolean onto + # data records, and the args_converter above that will operate one + # each item in the data to produce list item view instances from the + # CompositeListItem class. + dict_adapter = DictAdapter(sorted_keys=item_strings, + data=self.integers_dict, + args_converter=self.composite_args_converter, + selection_mode='single', + allow_empty_selection=False, + cls=CompositeListItem) + + self.assertEqual(len(dict_adapter.selection), 1) + + view = dict_adapter.get_view(1) + dict_adapter.handle_selection(view) + + self.assertEqual(len(dict_adapter.selection), 1) + + # test that sorted_keys is built, if not provided. + def test_dict_adapter_no_sorted_keys(self): + dict_adapter = DictAdapter(data=self.integers_dict, + args_converter=self.composite_args_converter, + selection_mode='single', + allow_empty_selection=False, + cls=CompositeListItem) + + self.assertEqual(len(dict_adapter.sorted_keys), 100) + + self.assertEqual(len(dict_adapter.selection), 1) + + view = dict_adapter.get_view(1) + dict_adapter.handle_selection(view) + + self.assertEqual(len(dict_adapter.selection), 1) + + def test_dict_adapter_bad_sorted_keys(self): + with self.assertRaises(Exception) as cm: + dict_adapter = DictAdapter(sorted_keys={}, + data=self.integers_dict, + args_converter=self.composite_args_converter, + selection_mode='single', + allow_empty_selection=False, + cls=CompositeListItem) + + msg = 'DictAdapter: sorted_keys must be tuple or list' + self.assertEqual(str(cm.exception), msg) + + def test_instantiating_dict_adapter_bind_triggers_to_view(self): + class PetListener(object): + def __init__(self, pets): + self.current_pets = pets + + def callback(self, *args): + self.current_pets = args[1] + + pet_listener = PetListener(['cat']) + + list_item_args_converter = lambda rec: {'text': rec['text'], + 'size_hint_y': None, + 'height': 25} + + dict_adapter = DictAdapter(sorted_keys=['cat'], + data={'cat': {'text': 'cat', 'is_selected': False}, + 'dog': {'text': 'dog', 'is_selected': False}}, + args_converter=list_item_args_converter, + propagate_selection_to_data=True, + selection_mode='single', + allow_empty_selection=False, + cls=ListItemButton) + + dict_adapter.bind_triggers_to_view(pet_listener.callback) + + self.assertEqual(pet_listener.current_pets, ['cat']) + dict_adapter.sorted_keys = ['dog'] + self.assertEqual(pet_listener.current_pets, ['dog']) + + def test_dict_adapter_reset_data(self): + class PetListener(object): + def __init__(self, pet): + self.current_pet = pet + + # This can happen as a result of sorted_keys changing, + # or data changing. + def callback(self, *args): + self.current_pet = args[1] + + pet_listener = PetListener('cat') + + list_item_args_converter = lambda rec: {'text': rec['text'], + 'size_hint_y': None, + 'height': 25} + + dict_adapter = DictAdapter( + sorted_keys=['cat'], + data={'cat': {'text': 'cat', 'is_selected': False}}, + args_converter=list_item_args_converter, + propagate_selection_to_data=True, + selection_mode='single', + allow_empty_selection=False, + cls=ListItemButton) + + dict_adapter.bind_triggers_to_view(pet_listener.callback) + + self.assertEqual(pet_listener.current_pet, 'cat') + dog_data = {'dog': {'text': 'dog', 'is_selected': False}} + dict_adapter.data = dog_data + self.assertEqual(dict_adapter.sorted_keys, ['dog']) + self.assertEqual(pet_listener.current_pet, dog_data) + cat_dog_data = {'cat': {'text': 'cat', 'is_selected': False}, + 'dog': {'text': 'dog', 'is_selected': False}} + dict_adapter.data = cat_dog_data + # sorted_keys should remain ['dog'], as it still matches data. + self.assertEqual(dict_adapter.sorted_keys, ['dog']) + dict_adapter.sorted_keys = ['cat'] + self.assertEqual(pet_listener.current_pet, ['cat']) + + # Make some utility calls for coverage: + + # 1, because get_count() does len(self.sorted_keys). + self.assertEqual(dict_adapter.get_count(), 1) + + # Bad index: + self.assertIsNone(dict_adapter.get_data_item(-1)) + self.assertIsNone(dict_adapter.get_data_item(2)) + def test_dict_adapter_selection_mode_single_without_propagation(self): list_item_args_converter = lambda rec: {'text': rec['name'], @@ -657,3 +1217,102 @@ class AdaptersTestCase(unittest.TestCase): tangerine_view = dict_adapter.get_view(2) self.assertEqual(tangerine_view.text, 'Tangerine') + + def test_dict_adapter_operations_trimming(self): + alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + letters_dict = \ + {l: {'text': l, 'is_selected': False} for l in alphabet} + + list_item_args_converter = lambda rec: {'text': rec['text'], + 'size_hint_y': None, + 'height': 25} + + letters = [l for l in alphabet] + + # trim left of sel + + letters_dict_adapter = DictAdapter( + sorted_keys=letters[:], + data=letters_dict, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + a_view = letters_dict_adapter.get_view(0) + self.assertEqual(a_view.text, 'A') + + letters_dict_adapter.handle_selection(a_view) + self.assertEqual(len(letters_dict_adapter.selection), 1) + self.assertTrue(a_view.is_selected) + + letters_dict_adapter.trim_right_of_sel() + self.assertEqual(len(letters_dict_adapter.data), 1) + + # trim right of sel + + letters_dict_adapter = DictAdapter( + sorted_keys=letters[:], + data=letters_dict, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + z_view = letters_dict_adapter.get_view(25) + self.assertEqual(z_view.text, 'Z') + + letters_dict_adapter.handle_selection(z_view) + self.assertEqual(len(letters_dict_adapter.selection), 1) + self.assertTrue(z_view.is_selected) + + letters_dict_adapter.trim_left_of_sel() + self.assertEqual(len(letters_dict_adapter.data), 1) + + # trim to sel + + letters_dict_adapter = DictAdapter( + sorted_keys=letters[:], + data=letters_dict, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + g_view = letters_dict_adapter.get_view(6) + self.assertEqual(g_view.text, 'G') + letters_dict_adapter.handle_selection(g_view) + + m_view = letters_dict_adapter.get_view(12) + self.assertEqual(m_view.text, 'M') + letters_dict_adapter.handle_selection(m_view) + + letters_dict_adapter.trim_to_sel() + self.assertEqual(len(letters_dict_adapter.data), 7) + + # cut to sel + + letters_dict_adapter = DictAdapter( + sorted_keys=letters[:], + data=letters_dict, + args_converter=list_item_args_converter, + selection_mode='multiple', + selection_limit=1000, + allow_empty_selection=True, + cls=ListItemButton) + + g_view = letters_dict_adapter.get_view(6) + self.assertEqual(g_view.text, 'G') + letters_dict_adapter.handle_selection(g_view) + + m_view = letters_dict_adapter.get_view(12) + self.assertEqual(m_view.text, 'M') + letters_dict_adapter.handle_selection(m_view) + + letters_dict_adapter.cut_to_sel() + self.assertEqual(len(letters_dict_adapter.data), 2) + diff --git a/kivy/tests/test_selection.py b/kivy/tests/test_selection.py index 973f558d1..fa0ab703c 100644 --- a/kivy/tests/test_selection.py +++ b/kivy/tests/test_selection.py @@ -2,12 +2,6 @@ Selection tests =============== -NOTE: In tests, take care calling list_view.get_item_view(0), because this - will call list_adapter.get_view(0), which does a selection. - Instead, you can get the cached view with direct references such as - list_view.item_view_instances[0], paying attention to when one should be - available. If you want to trigger a selection as a user touch would do, - call list_item_view.get_item_view(). ''' import unittest @@ -17,7 +11,7 @@ from kivy.uix.listview import ListView, ListItemButton from kivy.properties import NumericProperty, StringProperty from kivy.adapters.listadapter import ListAdapter from kivy.adapters.dictadapter import DictAdapter -from kivy.adapters.mixins.selection import SelectableDataItem +from kivy.adapters.models import SelectableDataItem # The following integers_dict and fruit categories / fruit data dictionaries # are from kivy/examples/widgets/lists/fixtures.py, and the classes are from @@ -275,6 +269,7 @@ class ListAdapterTestCase(unittest.TestCase): list_adapter = ListAdapter(data=fruit_data_items, args_converter=self.args_converter, selection_mode='single', + propagate_selection_to_data=True, allow_empty_selection=True, cls=ListItemButton) list_view = ListView(adapter=list_adapter) @@ -291,13 +286,10 @@ class ListAdapterTestCase(unittest.TestCase): self.assertEqual(len(list_adapter.selection), 0) # Still no selection, but triggering a selection should make len = 1. - # Calling get_item_view(0)) here will, in turn, call - # list_adapter.get_item_view(0), which does a selection if the - # associated data item is selected. So, first we need to select the - # associated data item. + # So, first we need to select the associated data item. self.assertEqual(fruit_data_items[0].name, 'Apple') fruit_data_items[0].is_selected = True - apple = list_view.get_item_view(0) + apple = list_view.adapter.get_view(0) self.assertEqual(apple.text, 'Apple') self.assertTrue(apple.is_selected) self.assertEqual(len(list_adapter.selection), 1) @@ -314,7 +306,7 @@ class ListAdapterTestCase(unittest.TestCase): # at the end of its __init__(), calls check_for_empty_selection() # and triggers the initial selection, because allow_empty_selection is # False. - apple = list_view.item_view_instances[0] + apple = list_view.adapter.cached_views[0] self.assertEqual(list_adapter.selection[0], apple) self.assertEqual(len(list_adapter.selection), 1) list_adapter.check_for_empty_selection() @@ -326,6 +318,7 @@ class ListAdapterTestCase(unittest.TestCase): list_adapter = ListAdapter(data=fruit_data_items, args_converter=self.args_converter, selection_mode='multiple', + propagate_selection_to_data=True, allow_empty_selection=False, cls=ListItemButton) list_view = ListView(adapter=list_adapter) @@ -341,7 +334,7 @@ class ListAdapterTestCase(unittest.TestCase): # Add Avocado to the selection, doing necessary steps on data first. self.assertEqual(fruit_data_items[1].name, 'Avocado') fruit_data_items[1].is_selected = True - avocado = list_view.get_item_view(1) # does selection + avocado = list_view.adapter.get_view(1) # does selection self.assertEqual(avocado.text, 'Avocado') self.assertEqual(len(list_adapter.selection), 2) @@ -362,7 +355,7 @@ class ListAdapterTestCase(unittest.TestCase): # And select some different ones. self.assertEqual(fruit_data_items[2].name, 'Banana') fruit_data_items[2].is_selected = True - banana = list_view.get_item_view(2) # does selection + banana = list_view.adapter.get_view(2) # does selection self.assertEqual(list_adapter.selection, [apple, avocado, banana]) self.assertEqual(len(list_adapter.selection), 3) @@ -370,6 +363,7 @@ class ListAdapterTestCase(unittest.TestCase): list_adapter = ListAdapter(data=fruit_data_items, args_converter=self.args_converter, selection_mode='multiple', + propagate_selection_to_data=True, selection_limit=3, allow_empty_selection=True, cls=ListItemButton) @@ -379,7 +373,7 @@ class ListAdapterTestCase(unittest.TestCase): for i in range(5): # Add item to the selection, doing necessary steps on data first. fruit_data_items[i].is_selected = True - list_view.get_item_view(i) # does selection + list_view.adapter.get_view(i) # does selection self.assertEqual(len(list_adapter.selection), i + 1 if i < 3 else 3) @@ -387,6 +381,7 @@ class ListAdapterTestCase(unittest.TestCase): list_adapter = ListAdapter(data=fruit_data_items, args_converter=self.args_converter, selection_mode='single', + propagate_selection_to_data=True, allow_empty_selection=False, cls=ListItemButton) @@ -396,22 +391,17 @@ class ListAdapterTestCase(unittest.TestCase): list_view = ListView(adapter=list_adapter) - list_adapter.touch_selection() - - # There should have been a call for initial selection, triggered by - # the check_for_empty_selection() at the end of ListView.__init__() - # followed by the forced dispatch from the call to touch_selection. - self.assertEqual(selection_observer.call_count, 2) + self.assertEqual(selection_observer.call_count, 0) # From the check for initial selection, we should have apple selected. self.assertEqual(list_adapter.selection[0].text, 'Apple') self.assertEqual(len(list_adapter.selection), 1) - # Go throught the tests routine to trigger selection of banana. + # Go through the tests routine to trigger selection of banana. # (See notes above about triggering selection in tests.) self.assertEqual(fruit_data_items[2].name, 'Banana') fruit_data_items[2].is_selected = True - banana = list_view.get_item_view(2) # does selection + banana = list_view.adapter.get_view(2) # does selection self.assertTrue(banana.is_selected) # Now unselect it with handle_selection(). @@ -424,10 +414,10 @@ class ListAdapterTestCase(unittest.TestCase): # Call count: # - # Apple got selected initally (1), then unselected (2) when Banana was - # selected (3). Then banana was unselected(4), causing reselection of - # Apple (5). len should be 1. - self.assertEqual(selection_observer.call_count, 5) + # Apple got selected initally (0), then unselected when Banana was + # selected (1). Then banana was unselected, causing reselection of + # Apple (3). len should be 1. + self.assertEqual(selection_observer.call_count, 3) self.assertEqual(len(list_adapter.selection), 1) diff --git a/kivy/tests/test_uix_listview.py b/kivy/tests/test_uix_listview.py index 3c8b54480..9036cdbab 100644 --- a/kivy/tests/test_uix_listview.py +++ b/kivy/tests/test_uix_listview.py @@ -6,7 +6,8 @@ ListView tests import unittest from kivy.uix.label import Label -from kivy.adapters.listadapter import SimpleListAdapter, ListAdapter +from kivy.adapters.listadapter import ListAdapter +from kivy.adapters.simplelistadapter import SimpleListAdapter from kivy.uix.listview import ListItemButton, ListView @@ -36,7 +37,7 @@ class ListViewTestCase(unittest.TestCase): self.assertEqual(type(list_view.adapter), SimpleListAdapter) self.assertFalse(hasattr(list_view.adapter, 'selection')) self.assertEqual(len(list_view.adapter.data), 100) - self.assertEqual(type(list_view.get_item_view(0)), Label) + self.assertEqual(type(list_view.adapter.get_view(0)), Label) def test_list_view_with_list_of_integers(self): @@ -57,7 +58,26 @@ class ListViewTestCase(unittest.TestCase): self.assertEqual(type(list_view.adapter), ListAdapter) self.assertTrue(hasattr(list_view.adapter, 'selection')) self.assertEqual(len(list_view.adapter.data), 100) - self.assertEqual(type(list_view.get_item_view(0)), ListItemButton) + self.assertEqual(type(list_view.adapter.get_view(0)), ListItemButton) + + def test_list_view_with_list_of_integers_scrolling(self): + + data = [{'text': str(i), 'is_selected': False} for i in xrange(100)] + + args_converter = lambda rec: {'text': rec['text'], + 'size_hint_y': None, + 'height': 25} + + list_adapter = ListAdapter(data=data, + args_converter=args_converter, + selection_mode='single', + allow_empty_selection=False, + cls=ListItemButton) + + list_view = ListView(adapter=list_adapter) + + list_view.scroll_to(20) + self.assertEqual(list_view._index, 20) def test_simple_list_view_deletion(self): @@ -68,3 +88,9 @@ class ListViewTestCase(unittest.TestCase): del list_view.adapter.data[49] self.assertEqual(len(list_view.adapter.data), 99) + def test_list_view_bad_instantiation(self): + with self.assertRaises(Exception) as cm: + listview = ListView() + + msg = 'ListView: item_strings needed or an adapter' + self.assertEqual(str(cm.exception), msg) diff --git a/kivy/uix/abstractview.py b/kivy/uix/abstractview.py index 2357d1e33..8c98b77a8 100644 --- a/kivy/uix/abstractview.py +++ b/kivy/uix/abstractview.py @@ -5,8 +5,8 @@ Abstract View .. versionadded:: 1.5 The :class:`AbstractView` widget has an adapter property for an adapter that -mediates to data, and an item_view_instances dict property that holds views -managed by the adapter. +mediates to data. The adapter manages an item_view_instances dict property +that holds views for each data item, operating as a cache. ''' @@ -24,5 +24,3 @@ class AbstractView(FloatLayout): '''The adapter can be one of several defined in kivy/adapters. The most common example is the ListAdapter used for managing data items in a list. ''' - - diff --git a/kivy/uix/listview.py b/kivy/uix/listview.py index 80cdc0180..4cc0e2e4a 100644 --- a/kivy/uix/listview.py +++ b/kivy/uix/listview.py @@ -62,13 +62,12 @@ The instance of :class:`SimpleListAdapter` has a required data argument, which contains data items to use as the basis for list items, along with a cls argument for the class to be instantiated for each list item from the data. -CollectionAdapter: ListAdapter and DictAdapter ---------------------------------------------- +ListAdapter and DictAdapter +--------------------------- For many uses of a list, the data is more than a simple list of strings and selection functionality is needed. :class:`ListAdapter` and -:class:`DictAdapter` each subclass :class:`CollectionAdapter`, extending its -base functionality for selection. +:class:`DictAdapter` each contain functionality for selection. See the :class:`ListAdapter` docs for details, but here are synopses of its arguments: @@ -198,9 +197,6 @@ selected, because allow_empty_selection is False. Uses for Selection ------------------ -In the previous example, we saw how a listview gains selection support just by -using ListAdapter, which subclasses CollectionAdapter. - What can we do with selection? Combining selection with the system of bindings in Kivy, we can build a wide range of user interface designs. @@ -244,13 +240,46 @@ from kivy.uix.label import Label from kivy.uix.boxlayout import BoxLayout from kivy.adapters.simplelistadapter import SimpleListAdapter from kivy.uix.abstractview import AbstractView -from kivy.uix.selectableview import SelectableView from kivy.properties import ObjectProperty, DictProperty, \ NumericProperty, ListProperty, BooleanProperty from kivy.lang import Builder from math import ceil, floor +class SelectableView(object): + '''The :class:`SelectableView` mixin is used in list item and other + classes that are to be instantiated in a list view, or another class + which uses a selection-enabled adapter such as ListAdapter. select() and + deselect() are to be overridden with display code to mark items as + selected or not, if desired. + ''' + + index = NumericProperty(-1) + '''The index into the underlying data list or the data item this view + represents. + ''' + + is_selected = BooleanProperty(False) + '''A SelectableView instance carries this property, which should be kept + in sync with the equivalent property in the data item it represents. + ''' + + def __init__(self, **kwargs): + super(SelectableView, self).__init__(**kwargs) + + def select(self, *args): + '''The list item is responsible for updating the display for + being selected, if desired. + ''' + self.is_selected = True + + def deselect(self, *args): + '''The list item is responsible for updating the display for + being unselected, if desired. + ''' + self.is_selected = False + + class ListItemButton(SelectableView, Button): selected_color = ListProperty([1., 0., 0., 1]) deselected_color = None @@ -316,7 +345,7 @@ class CompositeListItem(SelectableView, BoxLayout): selected_color = ListProperty([1., 0., 0., 1]) deselected_color = ListProperty([.33, .33, .33, 1]) - representing_cls = ObjectProperty([]) + representing_cls = ObjectProperty(None) '''Which component view class, if any, should represent for the composite list item in __repr__()? ''' @@ -327,18 +356,12 @@ class CompositeListItem(SelectableView, BoxLayout): # Example data: # # 'cls_dicts': [{'cls': ListItemButton, - # 'kwargs': {'text': "Left", - # 'merge_text': True, - # 'delimiter': '-'}}, + # 'kwargs': {'text': "Left"}}, # 'cls': ListItemLabel, # 'kwargs': {'text': "Middle", - # 'merge_text': True, - # 'delimiter': '-', # 'is_representing_cls': True}}, # 'cls': ListItemButton, - # 'kwargs': {'text': "Right", - # 'merge_text': True, - # 'delimiter': '-'}}]} + # 'kwargs': {'text': "Right"}] # There is an index to the data item this composite list item view # represents. Get it from kwargs and pass it along to children in the @@ -347,34 +370,26 @@ class CompositeListItem(SelectableView, BoxLayout): for cls_dict in kwargs['cls_dicts']: cls = cls_dict['cls'] - cls_kwargs = cls_dict['kwargs'] + cls_kwargs = cls_dict.get('kwargs', None) - cls_kwargs['index'] = index + if cls_kwargs: + cls_kwargs['index'] = index - if 'selection_target' not in cls_kwargs: - cls_kwargs['selection_target'] = self + if 'selection_target' not in cls_kwargs: + cls_kwargs['selection_target'] = self - if 'merge_text' in cls_kwargs: - if cls_kwargs['merge_text'] is True: - if 'text' in cls_kwargs: - if 'delimiter' in cls_kwargs: - cls_kwargs['text'] = "{0}{1}{2}".format( - cls_kwargs['text'], - cls_kwargs['delimiter'], - kwargs['text']) - else: - cls_kwargs['text'] = "{0}{1}".format( - cls_kwargs['text'], - kwargs['text']) - elif 'text' not in cls_kwargs: + if 'text' not in cls_kwargs: cls_kwargs['text'] = kwargs['text'] - elif 'text' not in cls_kwargs: - cls_kwargs['text'] = kwargs['text'] - if 'is_representing_cls' in cls_kwargs: - self.representing_cls = cls + if 'is_representing_cls' in cls_kwargs: + self.representing_cls = cls - self.add_widget(cls(**cls_kwargs)) + self.add_widget(cls(**cls_kwargs)) + else: + cls_kwargs = {} + if 'text' in kwargs: + cls_kwargs['text'] = kwargs['text'] + self.add_widget(cls(**cls_kwargs)) def select(self, *args): self.background_color = self.selected_color @@ -396,7 +411,7 @@ class CompositeListItem(SelectableView, BoxLayout): if self.representing_cls is not None: return str(self.representing_cls) else: - return 'unknown' + return super(CompositeListItem, self).__repr__() Builder.load_string(''' @@ -464,7 +479,7 @@ class ListView(AbstractView, EventDispatcher): # provided, raise an exception. if 'adapter' not in kwargs: if 'item_strings' not in kwargs: - raise Exception('ListView: input needed, or an adapter') + raise Exception('ListView: item_strings needed or an adapter') list_adapter = SimpleListAdapter(data=kwargs['item_strings'], cls=Label)