first commit

This commit is contained in:
Yura 2024-09-15 15:12:16 +03:00
commit 417e54da96
5696 changed files with 900003 additions and 0 deletions

View file

@ -0,0 +1,624 @@
"""
RecycleView
===========
.. versionadded:: 1.10.0
The RecycleView provides a flexible model for viewing selected sections of
large data sets. It aims to prevent the performance degradation that can occur
when generating large numbers of widgets in order to display many data items.
.. warning::
Because :class:`RecycleView` reuses widgets, any state change to a single
widget will stay with that widget as it's reused, even if the
:attr:`~RecycleView.data` assigned to it by the :class:`RecycleView`
changes, unless the complete state is tracked in :attr:`~RecycleView.data`
(see below).
The view is generated by processing the :attr:`~RecycleView.data`, essentially
a list of dicts, and uses these dicts to generate instances of the
:attr:`~RecycleView.viewclass` as required. Its design is based on the
MVC (`Model-View-Controller
<https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller>`_)
pattern.
* Model: The model is formed by :attr:`~RecycleView.data` you pass in via a
list of dicts.
* View: The View is split across layout and views and implemented using
adapters.
* Controller: The controller determines the logical interaction and is
implemented by :class:`RecycleViewBehavior`.
These are abstract classes and cannot be used directly. The default concrete
implementations are the
:class:`~kivy.uix.recycleview.datamodel.RecycleDataModel` for the model, the
:class:`~kivy.uix.recyclelayout.RecycleLayout` for the view, and the
:class:`RecycleView` for the controller.
When a RecycleView is instantiated, it automatically creates the views and data
classes. However, one must manually create the layout classes and add them to
the RecycleView.
A layout manager is automatically created as a
:attr:`~RecycleViewBehavior.layout_manager` when added as the child of the
RecycleView. Similarly when removed. A requirement is that the layout manager
must be contained as a child somewhere within the RecycleView's widget tree so
the view port can be found.
A minimal example might look something like this::
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.recycleview import RecycleView
Builder.load_string('''
<RV>:
viewclass: 'Label'
RecycleBoxLayout:
default_size: None, dp(56)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
''')
class RV(RecycleView):
def __init__(self, **kwargs):
super(RV, self).__init__(**kwargs)
self.data = [{'text': str(x)} for x in range(100)]
class TestApp(App):
def build(self):
return RV()
if __name__ == '__main__':
TestApp().run()
In order to support selection in the view, you can add the required behaviors
as follows::
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.recycleview import RecycleView
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.label import Label
from kivy.properties import BooleanProperty
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
Builder.load_string('''
<SelectableLabel>:
# Draw a background to indicate selection
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0, 0, 0, 1)
Rectangle:
pos: self.pos
size: self.size
<RV>:
viewclass: 'SelectableLabel'
SelectableRecycleBoxLayout:
default_size: None, dp(56)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
multiselect: True
touch_multiselect: True
''')
class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
RecycleBoxLayout):
''' Adds selection and focus behavior to the view. '''
class SelectableLabel(RecycleDataViewBehavior, Label):
''' Add selection support to the Label '''
index = None
selected = BooleanProperty(False)
selectable = BooleanProperty(True)
def refresh_view_attrs(self, rv, index, data):
''' Catch and handle the view changes '''
self.index = index
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def on_touch_down(self, touch):
''' Add selection on touch down '''
if super(SelectableLabel, self).on_touch_down(touch):
return True
if self.collide_point(*touch.pos) and self.selectable:
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
''' Respond to the selection of items in the view. '''
self.selected = is_selected
if is_selected:
print("selection changed to {0}".format(rv.data[index]))
else:
print("selection removed for {0}".format(rv.data[index]))
class RV(RecycleView):
def __init__(self, **kwargs):
super(RV, self).__init__(**kwargs)
self.data = [{'text': str(x)} for x in range(100)]
class TestApp(App):
def build(self):
return RV()
if __name__ == '__main__':
TestApp().run()
Please see the `examples/widgets/recycleview/basic_data.py` file for a more
complete example.
Viewclass State
^^^^^^^^^^^^^^^
Because the viewclass widgets are reused or instantiated as needed by the
:class:`RecycleView`, the order and content of the widgets are mutable. So any
state change to a single widget will stay with that widget, even when the data
assigned to it from the :attr:`~RecycleView.data` dict changes, unless
:attr:`~RecycleView.data` tracks those changes or they are manually refreshed
when re-used.
There are two methods for managing state changes in viewclass widgets:
1. Store state in the RecycleView.data Model
2. Generate state changes on-the-fly by catching :attr:`~RecycleView.data`
updates and manually refreshing.
An example::
from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.recycleview import RecycleView
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.properties import BooleanProperty, StringProperty
Builder.load_string('''
<StatefulLabel>:
active: stored_state.active
CheckBox:
id: stored_state
active: root.active
on_release: root.store_checkbox_state()
Label:
text: root.text
Label:
id: generate_state
text: root.generated_state_text
<RV>:
viewclass: 'StatefulLabel'
RecycleBoxLayout:
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
''')
class StatefulLabel(RecycleDataViewBehavior, BoxLayout):
text = StringProperty()
generated_state_text = StringProperty()
active = BooleanProperty()
index = 0
'''
To change a viewclass' state as the data assigned to it changes,
overload the refresh_view_attrs function (inherited from
RecycleDataViewBehavior)
'''
def refresh_view_attrs(self, rv, index, data):
self.index = index
if data['text'] == '0':
self.generated_state_text = "is zero"
elif int(data['text']) % 2 == 1:
self.generated_state_text = "is odd"
else:
self.generated_state_text = "is even"
super(StatefulLabel, self).refresh_view_attrs(rv, index, data)
'''
To keep state changes in the viewclass with associated data,
they can be explicitly stored in the RecycleView's data object
'''
def store_checkbox_state(self):
rv = App.get_running_app().rv
rv.data[self.index]['active'] = self.active
class RV(RecycleView, App):
def __init__(self, **kwargs):
super(RV, self).__init__(**kwargs)
self.data = [{'text': str(x), 'active': False} for x in range(10)]
App.get_running_app().rv = self
def build(self):
return self
if __name__ == '__main__':
RV().run()
TODO:
- Method to clear cached class instances.
- Test when views cannot be found (e.g. viewclass is None).
- Fix selection goto.
.. warning::
When views are re-used they may not trigger if the data remains the same.
"""
__all__ = ('RecycleViewBehavior', 'RecycleView')
from copy import deepcopy
from kivy.uix.scrollview import ScrollView
from kivy.properties import AliasProperty
from kivy.clock import Clock
from kivy.uix.recycleview.layout import RecycleLayoutManagerBehavior, \
LayoutChangeException
from kivy.uix.recycleview.views import RecycleDataAdapter
from kivy.uix.recycleview.datamodel import RecycleDataModelBehavior, \
RecycleDataModel
class RecycleViewBehavior(object):
"""RecycleViewBehavior provides a behavioral model upon which the
:class:`RecycleView` is built. Together, they offer an extensible and
flexible way to produce views with limited windows over large data sets.
See the module documentation for more information.
"""
# internals
_view_adapter = None
_data_model = None
_layout_manager = None
_refresh_flags = {'data': [], 'layout': [], 'viewport': False}
_refresh_trigger = None
def __init__(self, **kwargs):
self._refresh_trigger = Clock.create_trigger(self.refresh_views, -1)
self._refresh_flags = deepcopy(self._refresh_flags)
super(RecycleViewBehavior, self).__init__(**kwargs)
def get_viewport(self):
pass
def save_viewport(self):
pass
def restore_viewport(self):
pass
def refresh_views(self, *largs):
lm = self.layout_manager
flags = self._refresh_flags
if lm is None or self.view_adapter is None or self.data_model is None:
return
data = self.data
f = flags['data']
if f:
self.save_viewport()
# lm.clear_layout()
flags['data'] = []
flags['layout'] = [{}]
lm.compute_sizes_from_data(data, f)
while flags['layout']:
# if `data` we were re-triggered so finish in the next call.
# Otherwise go until fully laid out.
self.save_viewport()
if flags['data']:
return
flags['viewport'] = True
f = flags['layout']
flags['layout'] = []
try:
lm.compute_layout(data, f)
except LayoutChangeException:
flags['layout'].append({})
continue
if flags['data']: # in case that happened meanwhile
return
# make sure if we were re-triggered in the loop that we won't be
# called needlessly later.
self._refresh_trigger.cancel()
self.restore_viewport()
if flags['viewport']:
# TODO: make this also listen to LayoutChangeException
flags['viewport'] = False
viewport = self.get_viewport()
indices = lm.compute_visible_views(data, viewport)
lm.set_visible_views(indices, data, viewport)
def refresh_from_data(self, *largs, **kwargs):
"""
This should be called when data changes. Data changes typically
indicate that everything should be recomputed since the source data
changed.
This method is automatically bound to the
:attr:`~RecycleDataModelBehavior.on_data_changed` method of the
:class:`~RecycleDataModelBehavior` class and
therefore responds to and accepts the keyword arguments of that event.
It can be called manually to trigger an update.
"""
self._refresh_flags['data'].append(kwargs)
self._refresh_trigger()
def refresh_from_layout(self, *largs, **kwargs):
"""
This should be called when the layout changes or needs to change. It is
typically called when a layout parameter has changed and therefore the
layout needs to be recomputed.
"""
self._refresh_flags['layout'].append(kwargs)
self._refresh_trigger()
def refresh_from_viewport(self, *largs):
"""
This should be called when the viewport changes and the displayed data
must be updated. Neither the data nor the layout will be recomputed.
"""
self._refresh_flags['viewport'] = True
self._refresh_trigger()
def _dispatch_prop_on_source(self, prop_name, *largs):
# Dispatches the prop of this class when the
# view_adapter/layout_manager property changes.
getattr(self.__class__, prop_name).dispatch(self)
def _get_data_model(self):
return self._data_model
def _set_data_model(self, value):
data_model = self._data_model
if value is data_model:
return
if data_model is not None:
self._data_model = None
data_model.detach_recycleview()
if value is None:
return True
if not isinstance(value, RecycleDataModelBehavior):
raise ValueError(
'Expected object based on RecycleDataModelBehavior, got {}'.
format(value.__class__))
self._data_model = value
value.attach_recycleview(self)
self.refresh_from_data()
return True
data_model = AliasProperty(_get_data_model, _set_data_model)
"""
The Data model responsible for maintaining the data set.
data_model is an :class:`~kivy.properties.AliasProperty` that gets and sets
the current data model.
"""
def _get_view_adapter(self):
return self._view_adapter
def _set_view_adapter(self, value):
view_adapter = self._view_adapter
if value is view_adapter:
return
if view_adapter is not None:
self._view_adapter = None
view_adapter.detach_recycleview()
if value is None:
return True
if not isinstance(value, RecycleDataAdapter):
raise ValueError(
'Expected object based on RecycleAdapter, got {}'.
format(value.__class__))
self._view_adapter = value
value.attach_recycleview(self)
self.refresh_from_layout()
return True
view_adapter = AliasProperty(_get_view_adapter, _set_view_adapter)
"""
The adapter responsible for providing views that represent items in a data
set.
view_adapter is an :class:`~kivy.properties.AliasProperty` that gets and
sets the current view adapter.
"""
def _get_layout_manager(self):
return self._layout_manager
def _set_layout_manager(self, value):
lm = self._layout_manager
if value is lm:
return
if lm is not None:
self._layout_manager = None
lm.detach_recycleview()
if value is None:
return True
if not isinstance(value, RecycleLayoutManagerBehavior):
raise ValueError(
'Expected object based on RecycleLayoutManagerBehavior, '
'got {}'.format(value.__class__))
self._layout_manager = value
value.attach_recycleview(self)
self.refresh_from_layout()
return True
layout_manager = AliasProperty(_get_layout_manager, _set_layout_manager)
"""
The Layout manager responsible for positioning views within the
:class:`RecycleView`.
layout_manager is an :class:`~kivy.properties.AliasProperty` that gets
and sets the layout_manger.
"""
class RecycleView(RecycleViewBehavior, ScrollView):
"""
RecycleView is a flexible view for providing a limited window
into a large data set.
See the module documentation for more information.
"""
def __init__(self, **kwargs):
if self.data_model is None:
kwargs.setdefault('data_model', RecycleDataModel())
if self.view_adapter is None:
kwargs.setdefault('view_adapter', RecycleDataAdapter())
super(RecycleView, self).__init__(**kwargs)
fbind = self.fbind
fbind('scroll_x', self.refresh_from_viewport)
fbind('scroll_y', self.refresh_from_viewport)
fbind('size', self.refresh_from_viewport)
self.refresh_from_data()
def _convert_sv_to_lm(self, x, y):
lm = self.layout_manager
tree = [lm]
parent = lm.parent
while parent is not None and parent is not self:
tree.append(parent)
parent = parent.parent
if parent is not self:
raise Exception(
'The layout manager must be a sub child of the recycleview. '
'Could not find {} in the parent tree of {}'.format(self, lm))
for widget in reversed(tree):
x, y = widget.to_local(x, y)
return x, y
def get_viewport(self):
lm = self.layout_manager
lm_w, lm_h = lm.size
w, h = self.size
scroll_y = min(1, max(self.scroll_y, 0))
scroll_x = min(1, max(self.scroll_x, 0))
if lm_h <= h:
bottom = 0
else:
above = (lm_h - h) * scroll_y
bottom = max(0, lm_h - above - h)
bottom = max(0, (lm_h - h) * scroll_y)
left = max(0, (lm_w - w) * scroll_x)
width = min(w, lm_w)
height = min(h, lm_h)
# now convert the sv coordinates into the coordinates of the lm. In
# case there's a relative layout type widget in the parent tree
# between the sv and the lm.
left, bottom = self._convert_sv_to_lm(left, bottom)
return left, bottom, width, height
def save_viewport(self):
pass
def restore_viewport(self):
pass
def add_widget(self, widget, *args, **kwargs):
super(RecycleView, self).add_widget(widget, *args, **kwargs)
if (isinstance(widget, RecycleLayoutManagerBehavior) and
not self.layout_manager):
self.layout_manager = widget
def remove_widget(self, widget, *args, **kwargs):
super(RecycleView, self).remove_widget(widget, *args, **kwargs)
if self.layout_manager == widget:
self.layout_manager = None
# or easier way to use
def _get_data(self):
d = self.data_model
return d and d.data
def _set_data(self, value):
d = self.data_model
if d is not None:
d.data = value
data = AliasProperty(_get_data, _set_data, bind=["data_model"])
"""
The data used by the current view adapter. This is a list of dicts whose
keys map to the corresponding property names of the
:attr:`~RecycleView.viewclass`.
data is an :class:`~kivy.properties.AliasProperty` that gets and sets the
data used to generate the views.
"""
def _get_viewclass(self):
a = self.layout_manager
return a and a.viewclass
def _set_viewclass(self, value):
a = self.layout_manager
if a:
a.viewclass = value
viewclass = AliasProperty(_get_viewclass, _set_viewclass,
bind=["layout_manager"])
"""
The viewclass used by the current layout_manager.
viewclass is an :class:`~kivy.properties.AliasProperty` that gets and sets
the class used to generate the individual items presented in the view.
"""
def _get_key_viewclass(self):
a = self.layout_manager
return a and a.key_viewclass
def _set_key_viewclass(self, value):
a = self.layout_manager
if a:
a.key_viewclass = value
key_viewclass = AliasProperty(_get_key_viewclass, _set_key_viewclass,
bind=["layout_manager"])
"""
key_viewclass is an :class:`~kivy.properties.AliasProperty` that gets and
sets the key viewclass for the current
:attr:`~kivy.uix.recycleview.layout_manager`.
"""

View file

@ -0,0 +1,194 @@
'''
RecycleView Data Model
======================
.. versionadded:: 1.10.0
The data model part of the RecycleView model-view-controller pattern.
It defines the models (classes) that store the data associated with a
:class:`~kivy.uix.recycleview.RecycleViewBehavior`. Each model (class)
determines how the data is stored and emits requests to the controller
(:class:`~kivy.uix.recycleview.RecycleViewBehavior`) when the data is
modified.
'''
from kivy.properties import ListProperty, ObservableDict, ObjectProperty
from kivy.event import EventDispatcher
from functools import partial
__all__ = ('RecycleDataModelBehavior', 'RecycleDataModel')
def recondition_slice_assign(val, last_len, new_len):
if not isinstance(val, slice):
return slice(val, val + 1)
diff = new_len - last_len
start, stop, step = val.start, val.stop, val.step
if stop <= start:
return slice(0, 0)
if step is not None and step != 1:
assert last_len == new_len
if stop < 0:
stop = max(0, last_len + stop)
stop = min(last_len, stop)
if start < 0:
start = max(0, last_len + start)
start = min(last_len, start)
return slice(start, stop, step)
if start < 0:
start = last_len + start
if stop < 0:
stop = last_len + stop
# whatever, too complicated don't try to compute it
if (start < 0 or stop < 0 or start > last_len or stop > last_len or
new_len != last_len):
return None
return slice(start, stop)
class RecycleDataModelBehavior(object):
""":class:`RecycleDataModelBehavior` is the base class for the models
that describes and provides the data for the
:class:`~kivy.uix.recycleview.RecycleViewBehavior`.
:Events:
`on_data_changed`:
Fired when the data changes. The event may dispatch
keyword arguments specific to each implementation of the data
model.
When dispatched, the event and keyword arguments are forwarded to
:meth:`~kivy.uix.recycleview.RecycleViewBehavior.\
refresh_from_data`.
"""
__events__ = ("on_data_changed", )
recycleview = ObjectProperty(None, allownone=True)
'''The
:class:`~kivy.uix.recycleview.RecycleViewBehavior` instance
associated with this data model.
'''
def attach_recycleview(self, rv):
'''Associates a
:class:`~kivy.uix.recycleview.RecycleViewBehavior` with
this data model.
'''
self.recycleview = rv
if rv:
self.fbind('on_data_changed', rv.refresh_from_data)
def detach_recycleview(self):
'''Removes the
:class:`~kivy.uix.recycleview.RecycleViewBehavior`
associated with this data model.
'''
rv = self.recycleview
if rv:
self.funbind('on_data_changed', rv.refresh_from_data)
self.recycleview = None
def on_data_changed(self, *largs, **kwargs):
pass
class RecycleDataModel(RecycleDataModelBehavior, EventDispatcher):
'''An implementation of :class:`RecycleDataModelBehavior` that keeps the
data in a indexable list. See :attr:`data`.
When data changes this class currently dispatches `on_data_changed` with
one of the following additional keyword arguments.
`none`: no keyword argument
With no additional argument it means a generic data change.
`removed`: a slice or integer
The value is a slice or integer indicating the indices removed.
`appended`: a slice
The slice in :attr:`data` indicating the first and last new items
(i.e. the slice pointing to the new items added at the end).
`inserted`: a integer
The index in :attr:`data` where a new data item was inserted.
`modified`: a slice
The slice with the indices where the data has been modified.
This currently does not allow changing of size etc.
'''
data = ListProperty([])
'''Stores the model's data using a list.
The data for a item at index `i` can also be accessed with
:class:`RecycleDataModel` `[i]`.
'''
_last_len = 0
def __init__(self, **kwargs):
self.fbind('data', self._on_data_callback)
super(RecycleDataModel, self).__init__(**kwargs)
def __getitem__(self, index):
return self.data[index]
@property
def observable_dict(self):
'''A dictionary instance, which when modified will trigger a `data` and
consequently an `on_data_changed` dispatch.
'''
return partial(ObservableDict, self.__class__.data, self)
def attach_recycleview(self, rv):
super(RecycleDataModel, self).attach_recycleview(rv)
if rv:
self.fbind('data', rv._dispatch_prop_on_source, 'data')
def detach_recycleview(self):
rv = self.recycleview
if rv:
self.funbind('data', rv._dispatch_prop_on_source, 'data')
super(RecycleDataModel, self).detach_recycleview()
def _on_data_callback(self, instance, value):
last_len = self._last_len
new_len = self._last_len = len(self.data)
op, val = value.last_op
if op == '__setitem__':
val = recondition_slice_assign(val, last_len, new_len)
if val is not None:
self.dispatch('on_data_changed', modified=val)
else:
self.dispatch('on_data_changed')
elif op == '__delitem__':
self.dispatch('on_data_changed', removed=val)
elif op == '__setslice__':
val = recondition_slice_assign(slice(*val), last_len, new_len)
if val is not None:
self.dispatch('on_data_changed', modified=val)
else:
self.dispatch('on_data_changed')
elif op == '__delslice__':
self.dispatch('on_data_changed', removed=slice(*val))
elif op == '__iadd__' or op == '__imul__':
self.dispatch('on_data_changed', appended=slice(last_len, new_len))
elif op == 'append':
self.dispatch('on_data_changed', appended=slice(last_len, new_len))
elif op == 'insert':
self.dispatch('on_data_changed', inserted=val)
elif op == 'pop':
if val:
self.dispatch('on_data_changed', removed=val[0])
else:
self.dispatch('on_data_changed', removed=last_len - 1)
elif op == 'extend':
self.dispatch('on_data_changed', appended=slice(last_len, new_len))
else:
self.dispatch('on_data_changed')

View file

@ -0,0 +1,253 @@
'''
RecycleView Layouts
===================
.. versionadded:: 1.10.0
The Layouts handle the presentation of views for the
:class:`~kivy.uix.recycleview.RecycleView`.
.. warning::
This module is highly experimental, its API may change in the future and
the documentation is not complete at this time.
'''
from kivy.compat import string_types
from kivy.factory import Factory
from kivy.properties import StringProperty, ObjectProperty
from kivy.uix.behaviors import CompoundSelectionBehavior
from kivy.uix.recycleview.views import RecycleDataViewBehavior, \
_view_base_cache
class LayoutChangeException(Exception):
pass
class LayoutSelectionBehavior(CompoundSelectionBehavior):
'''The :class:`LayoutSelectionBehavior` can be combined with
:class:`RecycleLayoutManagerBehavior` to allow its derived classes
selection behaviors similarly to how
:class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`
can be used to add selection behaviors to normal layout.
:class:`RecycleLayoutManagerBehavior` manages its children
differently than normal layouts or widgets so this class adapts
:class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`
based selection to work with :class:`RecycleLayoutManagerBehavior` as well.
Similarly to
:class:`~kivy.uix.behaviors.compoundselection.CompoundSelectionBehavior`,
one can select using the keyboard or touch, which calls :meth:`select_node`
or :meth:`deselect_node`, or one can call these methods directly. When a
item is selected or deselected :meth:`apply_selection` is called. See
:meth:`apply_selection`.
'''
key_selection = StringProperty(None, allownone=True)
'''The key used to check whether a view of a data item can be selected
with touch or the keyboard.
:attr:`key_selection` is the key in data, which if present and ``True``
will enable selection for this item from the keyboard or with a touch.
When None, the default, not item will be selectable.
:attr:`key_selection` is a :class:`StringProperty` and defaults to None.
.. note::
All data items can be selected directly using :meth:`select_node` or
:meth:`deselect_node`, even if :attr:`key_selection` is False.
'''
_selectable_nodes = []
_nodes_map = {}
def __init__(self, **kwargs):
self.nodes_order_reversed = False
super(LayoutSelectionBehavior, self).__init__(**kwargs)
def compute_sizes_from_data(self, data, flags):
# overwrite this method so that when data changes we update
# selectable nodes.
key = self.key_selection
if key is None:
nodes = self._selectable_nodes = []
else:
nodes = self._selectable_nodes = [
i for i, d in enumerate(data) if d.get(key)]
self._nodes_map = {v: k for k, v in enumerate(nodes)}
return super(LayoutSelectionBehavior, self).compute_sizes_from_data(
data, flags)
def get_selectable_nodes(self):
# the indices of the data is used as the nodes
return self._selectable_nodes
def get_index_of_node(self, node, selectable_nodes):
# the indices of the data is used as the nodes, so node
return self._nodes_map[node]
def goto_node(self, key, last_node, last_node_idx):
node, idx = super(LayoutSelectionBehavior, self).goto_node(
key, last_node, last_node_idx)
if node is not last_node:
self.goto_view(node)
return node, idx
def select_node(self, node):
if super(LayoutSelectionBehavior, self).select_node(node):
view = self.recycleview.view_adapter.get_visible_view(node)
if view is not None:
self.apply_selection(node, view, True)
def deselect_node(self, node):
if super(LayoutSelectionBehavior, self).deselect_node(node):
view = self.recycleview.view_adapter.get_visible_view(node)
if view is not None:
self.apply_selection(node, view, False)
def apply_selection(self, index, view, is_selected):
'''Applies the selection to the view. This is called internally when
a view is displayed and it needs to be shown as selected or as not
selected.
It is called when :meth:`select_node` or :meth:`deselect_node` is
called or when a view needs to be refreshed. Its function is purely to
update the view to reflect the selection state. So the function may be
called multiple times even if the selection state may not have changed.
If the view is a instance of
:class:`~kivy.uix.recycleview.views.RecycleDataViewBehavior`, its
:meth:`~kivy.uix.recycleview.views.RecycleDataViewBehavior.\
apply_selection` method will be called every time the view needs to refresh
the selection state. Otherwise, the this method is responsible
for applying the selection.
:Parameters:
`index`: int
The index of the data item that is associated with the view.
`view`: widget
The widget that is the view of this data item.
`is_selected`: bool
Whether the item is selected.
'''
viewclass = view.__class__
if viewclass not in _view_base_cache:
_view_base_cache[viewclass] = isinstance(view,
RecycleDataViewBehavior)
if _view_base_cache[viewclass]:
view.apply_selection(self.recycleview, index, is_selected)
def refresh_view_layout(self, index, layout, view, viewport):
super(LayoutSelectionBehavior, self).refresh_view_layout(
index, layout, view, viewport)
self.apply_selection(index, view, index in self.selected_nodes)
class RecycleLayoutManagerBehavior(object):
"""A RecycleLayoutManagerBehavior is responsible for positioning views into
the :attr:`RecycleView.data` within a :class:`RecycleView`. It adds new
views into the data when it becomes visible to the user, and removes them
when they leave the visible area.
"""
viewclass = ObjectProperty(None)
'''See :attr:`RecyclerView.viewclass`.
'''
key_viewclass = StringProperty(None)
'''See :attr:`RecyclerView.key_viewclass`.
'''
recycleview = ObjectProperty(None, allownone=True)
asked_sizes = None
def attach_recycleview(self, rv):
self.recycleview = rv
if rv:
fbind = self.fbind
# can be made more selective update than refresh_from_data which
# causes a full update. But this likely affects most of the data.
fbind('viewclass', rv.refresh_from_data)
fbind('key_viewclass', rv.refresh_from_data)
fbind('viewclass', rv._dispatch_prop_on_source, 'viewclass')
fbind('key_viewclass', rv._dispatch_prop_on_source,
'key_viewclass')
def detach_recycleview(self):
self.clear_layout()
rv = self.recycleview
if rv:
funbind = self.funbind
funbind('viewclass', rv.refresh_from_data)
funbind('key_viewclass', rv.refresh_from_data)
funbind('viewclass', rv._dispatch_prop_on_source, 'viewclass')
funbind('key_viewclass', rv._dispatch_prop_on_source,
'key_viewclass')
self.recycleview = None
def compute_sizes_from_data(self, data, flags):
pass
def compute_layout(self, data, flags):
pass
def compute_visible_views(self, data, viewport):
'''`viewport` is in coordinates of the layout manager.
'''
pass
def set_visible_views(self, indices, data, viewport):
'''`viewport` is in coordinates of the layout manager.
'''
pass
def refresh_view_layout(self, index, layout, view, viewport):
'''`See :meth:`~kivy.uix.recycleview.views.RecycleDataAdapter.\
refresh_view_layout`.
'''
self.recycleview.view_adapter.refresh_view_layout(
index, layout, view, viewport)
def get_view_index_at(self, pos):
"""Return the view `index` on which position, `pos`, falls.
`pos` is in coordinates of the layout manager.
"""
pass
def remove_views(self):
rv = self.recycleview
if rv:
adapter = rv.view_adapter
if adapter:
adapter.make_views_dirty()
def remove_view(self, view, index):
rv = self.recycleview
if rv:
adapter = rv.view_adapter
if adapter:
adapter.make_view_dirty(view, index)
def clear_layout(self):
rv = self.recycleview
if rv:
adapter = rv.view_adapter
if adapter:
adapter.invalidate()
def goto_view(self, index):
'''Moves the views so that the view corresponding to `index` is
visible.
'''
pass
def on_viewclass(self, instance, value):
# resolve the real class if it was a string.
if isinstance(value, string_types):
self.viewclass = getattr(Factory, value)

View file

@ -0,0 +1,423 @@
'''
RecycleView Views
=================
.. versionadded:: 1.10.0
The adapter part of the RecycleView which together with the layout is the
view part of the model-view-controller pattern.
The view module handles converting the data to a view using the adapter class
which is then displayed by the layout. A view can be any Widget based class.
However, inheriting from RecycleDataViewBehavior adds methods for converting
the data to a view.
TODO:
* Make view caches specific to each view class type.
'''
from kivy.properties import ObjectProperty
from kivy.event import EventDispatcher
from collections import defaultdict
__all__ = (
'RecycleDataViewBehavior', 'RecycleKVIDsDataViewBehavior',
'RecycleDataAdapter')
_view_base_cache = {}
'''Cache whose keys are classes and values is a boolean indicating whether the
class inherits from :class:`RecycleDataViewBehavior`.
'''
_cached_views = defaultdict(list)
'''A size limited cache that contains old views (instances) that are not used.
Each key is a class whose value is the list of the instances of that class.
'''
# current number of unused classes in the class cache
_cache_count = 0
# maximum number of items in the class cache
_max_cache_size = 1000
def _clean_cache():
'''Trims _cached_views cache to half the size of `_max_cache_size`.
'''
# all keys will be reduced to max_size.
max_size = (_max_cache_size // 2) // len(_cached_views)
global _cache_count
for cls, instances in _cached_views.items():
_cache_count -= max(0, len(instances) - max_size)
del instances[max_size:]
class RecycleDataViewBehavior(object):
'''A optional base class for data views (:attr:`RecycleView`.viewclass).
If a view inherits from this class, the class's functions will be called
when the view needs to be updated due to a data change or layout update.
'''
def refresh_view_attrs(self, rv, index, data):
'''Called by the :class:`RecycleAdapter` when the view is initially
populated with the values from the `data` dictionary for this item.
Any pos or size info should be removed because they are set
subsequently with :attr:`refresh_view_layout`.
:Parameters:
`rv`: :class:`RecycleView` instance
The :class:`RecycleView` that caused the update.
`data`: dict
The data dict used to populate this view.
'''
sizing_attrs = RecycleDataAdapter._sizing_attrs
for key, value in data.items():
if key not in sizing_attrs:
setattr(self, key, value)
def refresh_view_layout(self, rv, index, layout, viewport):
'''Called when the view's size is updated by the layout manager,
:class:`RecycleLayoutManagerBehavior`.
:Parameters:
`rv`: :class:`RecycleView` instance
The :class:`RecycleView` that caused the update.
`viewport`: 4-tuple
The coordinates of the bottom left and width height in layout
manager coordinates. This may be larger than this view item.
:raises:
`LayoutChangeException`: If the sizing or data changed during a
call to this method, raising a `LayoutChangeException` exception
will force a refresh. Useful when data changed and we don't want
to layout further since it'll be overwritten again soon.
'''
w, h = layout.pop('size')
if w is None:
if h is not None:
self.height = h
else:
if h is None:
self.width = w
else:
self.size = w, h
for name, value in layout.items():
setattr(self, name, value)
def apply_selection(self, rv, index, is_selected):
pass
class RecycleKVIDsDataViewBehavior(RecycleDataViewBehavior):
"""Similar to :class:`RecycleDataViewBehavior`, except that the data keys
can signify properties of an object named with an id in the root KV rule.
E.g. given a KV rule::
<MyRule@RecycleKVIDsDataViewBehavior+BoxLayout>:
Label:
id: name
Label:
id: value
Then setting the data list with
``rv.data = [{'name.text': 'Kivy user', 'value.text': '12'}]`` would
automatically set the corresponding labels.
So, if the key doesn't have a period, the named property of the root widget
will be set to the corresponding value. If there is a period, the named
property of the widget with the id listed before the period will be set to
the corresponding value.
.. versionadded:: 2.0.0
"""
def refresh_view_attrs(self, rv, index, data):
sizing_attrs = RecycleDataAdapter._sizing_attrs
for key, value in data.items():
if key not in sizing_attrs:
name, *ids = key.split('.')
if ids:
if len(ids) != 1:
raise ValueError(
f'Data key "{key}" has more than one period')
setattr(self.ids[name], ids[0], value)
else:
setattr(self, name, value)
class RecycleDataAdapter(EventDispatcher):
'''The class that converts data to a view.
--- Internal details ---
A view can have 3 states.
* It can be completely in sync with the data, which
occurs when the view is displayed. These are stored in :attr:`views`.
* It can be dirty, which occurs when the view is in sync with the data,
except for the size/pos parameters which is controlled by the layout.
This occurs when the view is not currently displayed but the data has
not changed. These views are stored in :attr:`dirty_views`.
* Finally the view can be dead which occurs when the data changes and
the view was not updated or when a view is just created. Such views
are typically added to the internal cache.
Typically what happens is that the layout manager lays out the data
and then asks for views, using :meth:`set_visible_views`, for some specific
data items that it displays.
These views are gotten from the current views, dirty or global cache. Then
depending on the view state :meth:`refresh_view_attrs` is called to bring
the view up to date with the data (except for sizing parameters). Finally,
the layout manager gets these views, updates their size and displays them.
'''
recycleview = ObjectProperty(None, allownone=True)
'''The :class:`~kivy.uix.recycleview.RecycleViewBehavior` associated
with this instance.
'''
# internals
views = {} # current displayed items
# items whose attrs, except for pos/size is still accurate
dirty_views = defaultdict(dict)
_sizing_attrs = {
'size', 'width', 'height', 'size_hint', 'size_hint_x', 'size_hint_y',
'pos', 'x', 'y', 'center', 'center_x', 'center_y', 'pos_hint',
'size_hint_min', 'size_hint_min_x', 'size_hint_min_y', 'size_hint_max',
'size_hint_max_x', 'size_hint_max_y'}
def __init__(self, **kwargs):
"""
Fix for issue https://github.com/kivy/kivy/issues/5913:
Scrolling RV A, then Scrolling RV B, content of A and B seemed
to be getting mixed up
"""
self.views = {}
self.dirty_views = defaultdict(dict)
super(RecycleDataAdapter, self).__init__(**kwargs)
def attach_recycleview(self, rv):
'''Associates a :class:`~kivy.uix.recycleview.RecycleViewBehavior`
with this instance. It is stored in :attr:`recycleview`.
'''
self.recycleview = rv
def detach_recycleview(self):
'''Removes the :class:`~kivy.uix.recycleview.RecycleViewBehavior`
associated with this instance and clears :attr:`recycleview`.
'''
self.recycleview = None
def create_view(self, index, data_item, viewclass):
'''(internal) Creates and initializes the view for the data at `index`.
The returned view is synced with the data, except for the pos/size
information.
'''
if viewclass is None:
return
view = viewclass()
self.refresh_view_attrs(index, data_item, view)
return view
def get_view(self, index, data_item, viewclass):
'''(internal) Returns a view instance for the data at `index`
It looks through the various caches and finally creates a view if it
doesn't exist. The returned view is synced with the data, except for
the pos/size information.
If found in the cache it's removed from the source
before returning. It doesn't check the current views.
'''
# is it in the dirtied views?
dirty_views = self.dirty_views
if viewclass is None:
return
stale = False
view = None
if viewclass in dirty_views: # get it first from dirty list
dirty_class = dirty_views[viewclass]
if index in dirty_class:
# we found ourself in the dirty list, no need to update data!
view = dirty_class.pop(index)
elif _cached_views[viewclass]:
# global cache has this class, update data
view, stale = _cached_views[viewclass].pop(), True
elif dirty_class:
# random any dirty view element - update data
view, stale = dirty_class.popitem()[1], True
elif _cached_views[viewclass]: # otherwise go directly to cache
# global cache has this class, update data
view, stale = _cached_views[viewclass].pop(), True
if view is None:
view = self.create_view(index, data_item, viewclass)
if view is None:
return
if stale:
self.refresh_view_attrs(index, data_item, view)
return view
def refresh_view_attrs(self, index, data_item, view):
'''(internal) Syncs the view and brings it up to date with the data.
This method calls :meth:`RecycleDataViewBehavior.refresh_view_attrs`
if the view inherits from :class:`RecycleDataViewBehavior`. See that
method for more details.
.. note::
Any sizing and position info is skipped when syncing with the data.
'''
viewclass = view.__class__
if viewclass not in _view_base_cache:
_view_base_cache[viewclass] = isinstance(view,
RecycleDataViewBehavior)
if _view_base_cache[viewclass]:
view.refresh_view_attrs(self.recycleview, index, data_item)
else:
sizing_attrs = RecycleDataAdapter._sizing_attrs
for key, value in data_item.items():
if key not in sizing_attrs:
setattr(view, key, value)
def refresh_view_layout(self, index, layout, view, viewport):
'''Updates the sizing information of the view.
viewport is in coordinates of the layout manager.
This method calls :meth:`RecycleDataViewBehavior.refresh_view_attrs`
if the view inherits from :class:`RecycleDataViewBehavior`. See that
method for more details.
.. note::
Any sizing and position info is skipped when syncing with the data.
'''
if view.__class__ not in _view_base_cache:
_view_base_cache[view.__class__] = isinstance(
view, RecycleDataViewBehavior)
if _view_base_cache[view.__class__]:
view.refresh_view_layout(
self.recycleview, index, layout, viewport)
else:
w, h = layout.pop('size')
if w is None:
if h is not None:
view.height = h
else:
if h is None:
view.width = w
else:
view.size = w, h
for name, value in layout.items():
setattr(view, name, value)
def make_view_dirty(self, view, index):
'''(internal) Used to flag this view as dirty, ready to be used for
others. See :meth:`make_views_dirty`.
'''
del self.views[index]
self.dirty_views[view.__class__][index] = view
def make_views_dirty(self):
'''Makes all the current views dirty.
Dirty views are still in sync with the corresponding data. However, the
size information may go out of sync. Therefore a dirty view can be
reused by the same index by just updating the sizing information.
Once the underlying data of this index changes, the view should be
removed from the dirty views and moved to the global cache with
:meth:`invalidate`.
This is typically called when the layout manager needs to re-layout all
the data.
'''
views = self.views
if not views:
return
dirty_views = self.dirty_views
for index, view in views.items():
dirty_views[view.__class__][index] = view
self.views = {}
def invalidate(self):
'''Moves all the current views into the global cache.
As opposed to making a view dirty where the view is in sync with the
data except for sizing information, this will completely disconnect the
view from the data, as it is assumed the data has gone out of sync with
the view.
This is typically called when the data changes.
'''
global _cache_count
for view in self.views.values():
_cached_views[view.__class__].append(view)
_cache_count += 1
for cls, views in self.dirty_views.items():
_cached_views[cls].extend(views.values())
_cache_count += len(views)
if _cache_count >= _max_cache_size:
_clean_cache()
self.views = {}
self.dirty_views.clear()
def set_visible_views(self, indices, data, viewclasses):
'''Gets a 3-tuple of the new, remaining, and old views for the current
viewport.
The new views are synced to the data except for the size/pos
properties.
The old views need to be removed from the layout, and the new views
added.
The new views are not necessarily *new*, but are all the currently
visible views.
'''
visible_views = {}
previous_views = self.views
ret_new = []
ret_remain = []
get_view = self.get_view
# iterate though the visible view
# add them into the container if not already done
for index in indices:
view = previous_views.pop(index, None)
if view is not None: # was current view
visible_views[index] = view
ret_remain.append((index, view))
else:
view = get_view(index, data[index],
viewclasses[index]['viewclass'])
if view is None:
continue
visible_views[index] = view
ret_new.append((index, view))
old_views = previous_views.items()
self.make_views_dirty()
self.views = visible_views
return ret_new, ret_remain, old_views
def get_visible_view(self, index):
'''Returns the currently visible view associated with ``index``.
If no view is currently displayed for ``index`` it returns ``None``.
'''
return self.views.get(index)