"""
RecycleGridLayout
=================
.. versionadded:: 1.10.0
.. warning::
This module is highly experimental, its API may change in the future and
the documentation is not complete at this time.
The RecycleGridLayout is designed to provide a
:class:`~kivy.uix.gridlayout.GridLayout` type layout when used with the
:class:`~kivy.uix.recycleview.RecycleView` widget. Please refer to the
:mod:`~kivy.uix.recycleview` module documentation for more information.
"""
import itertools
chain_from_iterable = itertools.chain.from_iterable
from kivy.uix.recyclelayout import RecycleLayout
from kivy.uix.gridlayout import GridLayout, GridLayoutException, nmax, nmin
from collections import defaultdict
from math import ceil
__all__ = ('RecycleGridLayout', )
[docs]class RecycleGridLayout(RecycleLayout, GridLayout):
_cols_pos = None
_rows_pos = None
def __init__(self, **kwargs):
super(RecycleGridLayout, self).__init__(**kwargs)
self.funbind('children', self._trigger_layout)
def on_children(self, instance, value):
pass
def _fill_rows_cols_sizes(self):
cols, rows = self._cols, self._rows
cols_sh, rows_sh = self._cols_sh, self._rows_sh
cols_sh_min, rows_sh_min = self._cols_sh_min, self._rows_sh_min
cols_sh_max, rows_sh_max = self._cols_sh_max, self._rows_sh_max
self._cols_count = cols_count = [defaultdict(int) for _ in cols]
self._rows_count = rows_count = [defaultdict(int) for _ in rows]
# calculate minimum size for each columns and rows
idx_iter = self._create_idx_iter(len(cols), len(rows))
has_bound_y = has_bound_x = False
for opt, (col, row) in zip(self.view_opts, idx_iter):
(shw, shh), (w, h) = opt['size_hint'], opt['size']
shw_min, shh_min = opt['size_hint_min']
shw_max, shh_max = opt['size_hint_max']
if shw is None:
cols_count[col][w] += 1
if shh is None:
rows_count[row][h] += 1
# compute minimum size / maximum stretch needed
if shw is None:
cols[col] = nmax(cols[col], w)
else:
cols_sh[col] = nmax(cols_sh[col], shw)
if shw_min is not None:
has_bound_x = True
cols_sh_min[col] = nmax(cols_sh_min[col], shw_min)
if shw_max is not None:
has_bound_x = True
cols_sh_max[col] = nmin(cols_sh_max[col], shw_max)
if shh is None:
rows[row] = nmax(rows[row], h)
else:
rows_sh[row] = nmax(rows_sh[row], shh)
if shh_min is not None:
has_bound_y = True
rows_sh_min[row] = nmax(rows_sh_min[row], shh_min)
if shh_max is not None:
has_bound_y = True
rows_sh_max[row] = nmin(rows_sh_max[row], shh_max)
self._has_hint_bound_x = has_bound_x
self._has_hint_bound_y = has_bound_y
def _update_rows_cols_sizes(self, changed):
cols_count, rows_count = self._cols_count, self._rows_count
cols, rows = self._cols, self._rows
remove_view = self.remove_view
n_cols = len(cols)
n_rows = len(rows)
orientation = self.orientation
# this can be further improved to reduce re-comp, but whatever...
for index, widget, (w, h), (wn, hn), sh, shn, sh_min, shn_min, \
sh_max, shn_max, _, _ in changed:
if sh != shn or sh_min != shn_min or sh_max != shn_max:
return True
elif (sh[0] is not None and w != wn and
(h == hn or sh[1] is not None) or
sh[1] is not None and h != hn and
(w == wn or sh[0] is not None)):
remove_view(widget, index)
else: # size hint is None, so check if it can be resized inplace
col, row = self._calculate_idx_from_a_view_idx(
n_cols, n_rows, index)
if w != wn:
col_w = cols[col]
cols_count[col][w] -= 1
cols_count[col][wn] += 1
was_last_w = cols_count[col][w] <= 0
if was_last_w and col_w == w or wn > col_w:
return True
if was_last_w:
del cols_count[col][w]
if h != hn:
row_h = rows[row]
rows_count[row][h] -= 1
rows_count[row][hn] += 1
was_last_h = rows_count[row][h] <= 0
if was_last_h and row_h == h or hn > row_h:
return True
if was_last_h:
del rows_count[row][h]
return False
def compute_layout(self, data, flags):
super(RecycleGridLayout, self).compute_layout(data, flags)
n = len(data)
smax = self.get_max_widgets()
if smax and n > smax:
raise GridLayoutException(
'Too many children ({}) in GridLayout. Increase rows/cols!'.
format(n))
changed = self._changed_views
if (changed is None or
changed and not self._update_rows_cols_sizes(changed)):
return
self.clear_layout()
if not self._init_rows_cols_sizes(n):
self._cols_pos = None
l, t, r, b = self.padding
self.minimum_size = l + r, t + b
return
self._fill_rows_cols_sizes()
self._update_minimum_size()
self._finalize_rows_cols_sizes()
view_opts = self.view_opts
for widget, x, y, w, h in self._iterate_layout(n):
opt = view_opts[n - widget - 1]
shw, shh = opt['size_hint']
opt['pos'] = x, y
wo, ho = opt['size']
# layout won't/shouldn't change previous size if size_hint is None
# which is what w/h being None means.
opt['size'] = [(wo if shw is None else w),
(ho if shh is None else h)]
spacing_x, spacing_y = self.spacing
cols, rows = self._cols, self._rows
cols_pos = self._cols_pos = [None, ] * len(cols)
rows_pos = self._rows_pos = [None, ] * len(rows)
cols_pos[0] = self.x
last = cols_pos[0] + self.padding[0] + cols[0] + spacing_x / 2.
for i, val in enumerate(cols[1:], 1):
cols_pos[i] = last
last += val + spacing_x
last = rows_pos[-1] = \
self.y + self.height - self.padding[1] - rows[0] - spacing_y / 2.
n = len(rows)
for i, val in enumerate(rows[1:], 1):
last -= spacing_y + val
rows_pos[n - 1 - i] = last
[docs] def get_view_index_at(self, pos):
if self._cols_pos is None:
return 0
x, y = pos
col_pos = self._cols_pos
row_pos = self._rows_pos
cols, rows = self._cols, self._rows
if not col_pos or not row_pos:
return 0
if x >= col_pos[-1]:
ix = len(cols) - 1
else:
ix = 0
for val in col_pos[1:]:
if x < val:
break
ix += 1
if y >= row_pos[-1]:
iy = len(rows) - 1
else:
iy = 0
for val in row_pos[1:]:
if y < val:
break
iy += 1
if not self._fills_from_left_to_right:
ix = len(cols) - ix - 1
if self._fills_from_top_to_bottom:
iy = len(rows) - iy - 1
return (iy * len(cols) + ix) if self._fills_row_first else \
(ix * len(rows) + iy)
[docs] def compute_visible_views(self, data, viewport):
if self._cols_pos is None:
return []
x, y, w, h = viewport
right = x + w
top = y + h
at_idx = self.get_view_index_at
tl, tr, bl, br = sorted((
at_idx((x, y)),
at_idx((right, y)),
at_idx((x, top)),
at_idx((right, top)),
))
n = len(data)
if len({tl, tr, bl, br}) < 4:
# visible area is one row/column
return range(min(n, tl), min(n, br + 1))
indices = []
stride = len(self._cols) if self._fills_row_first else len(self._rows)
if stride:
x_slice = br - bl + 1
indices = chain_from_iterable(
range(min(s, n), min(n, s + x_slice))
for s in range(tl, bl + 1, stride))
return indices
def _calculate_idx_from_a_view_idx(self, n_cols, n_rows, view_idx):
'''returns a tuple of (column-index, row-index) from a view-index'''
if self._fills_row_first:
row_idx, col_idx = divmod(view_idx, n_cols)
else:
col_idx, row_idx = divmod(view_idx, n_rows)
if not self._fills_from_left_to_right:
col_idx = n_cols - col_idx - 1
if not self._fills_from_top_to_bottom:
row_idx = n_rows - row_idx - 1
return (col_idx, row_idx, )
[docs] def goto_view(self, index):
"""Scroll the view to make the specified index visible.
Args:
index (int): The index in the data list to scroll to.
"""
if not self.view_opts or not self.parent:
return
# Calculate grid dimensions
num_items = len(self.parent.data)
cols = self.cols
rows = self.rows
# Limit index to valid range and handle negative indices
index = max(-num_items, min(index, num_items - 1))
if index < 0:
index = num_items + index
# If cols/rows not set, calculate them
if cols is None:
cols = ceil(num_items / rows)
if rows is None:
rows = ceil(num_items / cols)
# Calculate row and column of target index
if self._fills_row_first:
row_idx = index // cols
col_idx = index % cols
else:
col_idx = index // rows
row_idx = index % rows
# Calculate total dimensions
spacing_x, spacing_y = self.spacing
padding_left, padding_top, padding_right, padding_bottom = self.padding
x_padding = padding_left + padding_right
y_padding = padding_top + padding_bottom
viewport_width = self.parent.width
viewport_height = self.parent.height
# Calculate total width and height of the grid
total_width = sum(self._cols) + (cols - 1) * spacing_x + x_padding
total_height = sum(self._rows) + (rows - 1) * spacing_y + y_padding
if self._fills_from_left_to_right:
x_pos = (
padding_left + sum(self._cols[:col_idx]) + col_idx * spacing_x
)
target_scroll_x = max(
0, min(1, x_pos / (total_width - viewport_width))
)
else:
# right to left
col_idx = cols - col_idx - 1
x_pos = (
padding_left
+ sum(self._cols[col_idx + 1 :])
+ (cols - col_idx - 1) * spacing_x
)
# position at left of viewport
x_pos = x_pos - viewport_width + self._cols[col_idx]
target_scroll_x = max(
0, min(1, 1 - (x_pos / (total_width - viewport_width)))
)
if self._fills_from_top_to_bottom:
y_pos = (
padding_top + sum(self._rows[:row_idx]) + row_idx * spacing_y
)
target_scroll_y = max(
0, min(1, 1 - (y_pos / (total_height - viewport_height)))
)
else:
# Calculate base position from bottom
row_idx = rows - row_idx - 1
y_pos = (
padding_top
+ sum(self._rows[row_idx + 1 :])
+ (rows - row_idx - 1) * spacing_y
)
# position at top of viewport
y_pos = y_pos - viewport_height + self._rows[row_idx]
target_scroll_y = max(
0, min(1, y_pos / (total_height - viewport_height))
)
# Adjust scroll position to center big widgets
widget_width = self._cols[col_idx]
widget_height = self._rows[row_idx]
if widget_width > viewport_width:
# center wide widgets
target_scroll_x = max(
0,
min(
1,
target_scroll_x
+ (widget_width - viewport_width)
/ (2 * (total_width - viewport_width)),
),
)
if widget_height > viewport_height:
# center tall widgets
target_scroll_y = max(
0,
min(
1,
target_scroll_y
- (widget_height - viewport_height)
/ (2 * (total_height - viewport_height)),
),
)
# Apply scroll positions
self.parent.scroll_x = target_scroll_x
self.parent.scroll_y = target_scroll_y