Added tests toward full coverage, revealing bugs in code that had never been reached. Simplified several parts of the API. Merged CollectionAdapter into ListAdapter, so now it goes: Adpater -> SimpleListAdapter, Adapter -> ListAdapter -> DictAdapter, with selection in ListAdapter, and DictAdapter.

This commit is contained in:
geojeff 2012-10-23 13:20:27 -05:00
parent 29ff3259e9
commit 3f76ea8d3a
16 changed files with 1166 additions and 530 deletions

View File

@ -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()

View File

@ -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__':

View File

@ -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__':

View File

@ -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__':

View File

@ -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']}}]}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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.
'''

View File

@ -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)