diff --git a/kivy/tests/test_uix_gridlayout.py b/kivy/tests/test_uix_gridlayout.py index 4e0adafd2..2a2df165d 100644 --- a/kivy/tests/test_uix_gridlayout.py +++ b/kivy/tests/test_uix_gridlayout.py @@ -4,6 +4,7 @@ uix.gridlayout tests ''' import unittest +import pytest from kivy.tests.common import GraphicUnitTest from kivy.uix.gridlayout import GridLayout @@ -52,5 +53,59 @@ class UixGridLayoutTest(GraphicUnitTest): self.render(gl) +@pytest.mark.parametrize( + "n_cols, n_rows, orientation, expectation", [ + (2, 3, 'lr-tb', [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]), + (2, 3, 'lr-bt', [(0, 2), (1, 2), (0, 1), (1, 1), (0, 0), (1, 0)]), + (2, 3, 'rl-tb', [(1, 0), (0, 0), (1, 1), (0, 1), (1, 2), (0, 2)]), + (2, 3, 'rl-bt', [(1, 2), (0, 2), (1, 1), (0, 1), (1, 0), (0, 0)]), + (2, 3, 'tb-lr', [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]), + (2, 3, 'tb-rl', [(1, 0), (1, 1), (1, 2), (0, 0), (0, 1), (0, 2)]), + (2, 3, 'bt-lr', [(0, 2), (0, 1), (0, 0), (1, 2), (1, 1), (1, 0)]), + (2, 3, 'bt-rl', [(1, 2), (1, 1), (1, 0), (0, 2), (0, 1), (0, 0)]), + ] +) +def test_create_col_and_row_index_iter( + n_cols, n_rows, orientation, expectation): + from kivy.uix.gridlayout import _create_col_and_row_index_iter + index_iter = _create_col_and_row_index_iter(n_cols, n_rows, orientation) + assert expectation == list(index_iter) + + +@pytest.mark.parametrize("orientation", [ + 'lr-tb', 'lr-bt', 'rl-tb', 'rl-bt', + 'tb-lr', 'tb-rl', 'bt-lr', 'bt-rl', +]) +def test_create_col_and_row_index_iter2(orientation): + from kivy.uix.gridlayout import _create_col_and_row_index_iter + index_iter = _create_col_and_row_index_iter(1, 1, orientation) + assert [(0, 0)] == list(index_iter) + + +@pytest.mark.parametrize( + "n_cols, n_rows, orientation, n_children, expectation", [ + (3, None, 'lr-tb', 4, [(0, 15), (10, 15), (20, 15), (0, 0)]), + (3, None, 'lr-bt', 4, [(0, 0), (10, 0), (20, 0), (0, 15)]), + (3, None, 'rl-tb', 4, [(20, 15), (10, 15), (0, 15), (20, 0)]), + (3, None, 'rl-bt', 4, [(20, 0), (10, 0), (0, 0), (20, 15)]), + (None, 3, 'tb-lr', 4, [(0, 20), (0, 10), (0, 0), (15, 20)]), + (None, 3, 'tb-rl', 4, [(15, 20), (15, 10), (15, 0), (0, 20)]), + (None, 3, 'bt-lr', 4, [(0, 0), (0, 10), (0, 20), (15, 0)]), + (None, 3, 'bt-rl', 4, [(15, 0), (15, 10), (15, 20), (0, 0)]), + ] +) +def test_children_pos(n_cols, n_rows, orientation, n_children, expectation): + from kivy.uix.widget import Widget + from kivy.uix.gridlayout import GridLayout + gl = GridLayout( + cols=n_cols, rows=n_rows, orientation=orientation, + pos=(0, 0), size=(30, 30)) + for __ in range(n_children): + gl.add_widget(Widget()) + gl.do_layout() + actual_layout = [tuple(c.pos) for c in reversed(gl.children)] + assert actual_layout == expectation + + if __name__ == '__main__': unittest.main() diff --git a/kivy/uix/gridlayout.py b/kivy/uix/gridlayout.py index 05fee80cd..363826ea8 100644 --- a/kivy/uix/gridlayout.py +++ b/kivy/uix/gridlayout.py @@ -93,8 +93,9 @@ from kivy.logger import Logger from kivy.uix.layout import Layout from kivy.properties import NumericProperty, BooleanProperty, DictProperty, \ BoundedNumericProperty, ReferenceListProperty, VariableListProperty, \ - ObjectProperty, StringProperty + ObjectProperty, StringProperty, OptionProperty from math import ceil +from itertools import accumulate, product def nmax(*args): @@ -251,6 +252,27 @@ class GridLayout(Layout): only. ''' + orientation = OptionProperty('lr-tb', options=( + 'lr-tb', 'tb-lr', 'rl-tb', 'tb-rl', 'lr-bt', 'bt-lr', 'rl-bt', + 'bt-rl')) + '''Orientation of the layout. + + :attr:`orientation` is an :class:`~kivy.properties.OptionProperty` and + defaults to 'lr-tb'. + + Valid orientations are 'lr-tb', 'tb-lr', 'rl-tb', 'tb-rl', 'lr-bt', + 'bt-lr', 'rl-bt' and 'bt-rl'. + + .. versionadded:: 2.0.0 + + .. note:: + + 'lr' means Left to Right. + 'rl' means Right to Left. + 'tb' means Top to Bottom. + 'bt' means Bottom to Top. + ''' + def __init__(self, **kwargs): self._cols = self._rows = None super(GridLayout, self).__init__(**kwargs) @@ -268,6 +290,7 @@ class GridLayout(Layout): fbind('children', update) fbind('size', update) fbind('pos', update) + fbind('orientation', update) def get_max_widgets(self): if self.cols and self.rows: @@ -283,6 +306,10 @@ class GridLayout(Layout): raise GridLayoutException( 'Too many children in GridLayout. Increase rows/cols!') + @property + def _fills_row_first(self): + return self.orientation[0] in 'lr' + def _init_rows_cols_sizes(self, count): # the goal here is to calculate the minimum size of every cols/rows # and determine if they have stretch or not @@ -295,9 +322,18 @@ class GridLayout(Layout): Logger.warning('%r have no cols or rows set, ' 'layout is not triggered.' % self) return + if current_cols is None: + if self._fills_row_first: + Logger.warning( + 'Being asked to fill row-first, but a number of columns ' + 'is not defined. You might get an unexpected result.') current_cols = int(ceil(count / float(current_rows))) elif current_rows is None: + if not self._fills_row_first: + Logger.warning( + 'Being asked to fill column-first, but a number of rows ' + 'is not defined. You might get an unexpected result.') current_rows = int(ceil(count / float(current_cols))) current_cols = max(1, current_cols) @@ -315,6 +351,8 @@ class GridLayout(Layout): self._rows_sh = [None] * current_rows self._rows_sh_min = [None] * current_rows self._rows_sh_max = [None] * current_rows + self._col_and_row_indices = tuple(_create_col_and_row_index_iter( + current_cols, current_rows, self.orientation)) # update minimum size from the dicts items = (i for i in self.cols_minimum.items() if i[0] < len(cols)) @@ -333,13 +371,12 @@ class GridLayout(Layout): cols_sh_max, rows_sh_max = self._cols_sh_max, self._rows_sh_max # calculate minimum size for each columns and rows - n_cols = len(cols) has_bound_y = has_bound_x = False - for i, child in enumerate(reversed(self.children)): + for child, (col, row) in zip(reversed(self.children), + self._col_and_row_indices): (shw, shh), (w, h) = child.size_hint, child.size shw_min, shh_min = child.size_hint_min shw_max, shh_max = child.size_hint_max - row, col = divmod(i, n_cols) # compute minimum size / maximum stretch needed if shw is None: @@ -480,24 +517,40 @@ class GridLayout(Layout): rows[index] += stretch_h * row_stretch / rows_weight def _iterate_layout(self, count): - selfx = self.x - padding_left = self.padding[0] - padding_top = self.padding[1] + orientation = self.orientation + padding = self.padding spacing_x, spacing_y = self.spacing - i = count - 1 - y = self.top - padding_top cols = self._cols - for row_height in self._rows: - x = selfx + padding_left - for col_width in cols: - if i < 0: - break + x_list = list(accumulate(( + self.x + padding[0], + *(col_width + spacing_x for col_width in cols[:-1])))) + if 'rl' in orientation: + cols = reversed(cols) + x_list.reverse() - yield i, x, y - row_height, col_width, row_height - i = i - 1 - x = x + col_width + spacing_x - y -= row_height + spacing_y + rows = self._rows + reversed_rows = list(reversed(rows)) + y_list = list(accumulate(( + self.y + padding[3], + *(row_height + spacing_y for row_height in reversed_rows[:-1])))) + if 'tb' in orientation: + y_list.reverse() + else: + rows = reversed_rows + + if self._fills_row_first: + for i, (y, x), (row_height, col_width) in zip( + reversed(range(count)), + product(y_list, x_list), + product(rows, cols)): + yield i, x, y, col_width, row_height + else: + for i, (x, y), (col_width, row_height) in zip( + reversed(range(count)), + product(x_list, y_list), + product(cols, rows)): + yield i, x, y, col_width, row_height def do_layout(self, *largs): children = self.children @@ -542,3 +595,19 @@ class GridLayout(Layout): c.width = w else: c.size = (w, h) + + +def _create_col_and_row_index_iter(n_cols, n_rows, orientation): + col_indices = list(range(n_cols)) + if 'rl' in orientation: + col_indices.reverse() + row_indices = list(range(n_rows)) + if 'bt' in orientation: + row_indices.reverse() + + if orientation[0] in 'rl': + return ( + (col_index, row_index) + for row_index, col_index in product(row_indices, col_indices)) + else: + return product(col_indices, row_indices) diff --git a/kivy/uix/recyclegridlayout.py b/kivy/uix/recyclegridlayout.py index 8bfd51110..90a2ab588 100644 --- a/kivy/uix/recyclegridlayout.py +++ b/kivy/uix/recyclegridlayout.py @@ -39,16 +39,17 @@ class RecycleGridLayout(RecycleLayout, GridLayout): 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] + # !! bottom-to-top, the opposite of the other attributes. self._rows_count = rows_count = [defaultdict(int) for _ in rows] # calculate minimum size for each columns and rows - n_cols = len(cols) + col_and_row_indices = self._col_and_row_indices has_bound_y = has_bound_x = False for i, opt in enumerate(self.view_opts): (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'] - row, col = divmod(i, n_cols) + col, row = col_and_row_indices[i] if shw is None: cols_count[col][w] += 1 @@ -84,7 +85,7 @@ class RecycleGridLayout(RecycleLayout, GridLayout): 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_count) + col_and_row_indices = self._col_and_row_indices # this can be further improved to reduce re-comp, but whatever... for index, widget, (w, h), (wn, hn), sh, shn, sh_min, shn_min, \ @@ -97,7 +98,7 @@ class RecycleGridLayout(RecycleLayout, GridLayout): (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 - row, col = divmod(index, n_cols) + col, row = col_and_row_indices[index] if w != wn: col_w = cols[col] @@ -205,26 +206,35 @@ class RecycleGridLayout(RecycleLayout, GridLayout): break iy += 1 - # gridlayout counts from left to right, top to down - iy = len(rows) - iy - 1 - return iy * len(cols) + ix + ori = self.orientation + if 'rl' in ori: + ix = len(cols) - ix - 1 + if 'tb' in ori: + iy = len(rows) - iy - 1 + return (iy * len(cols) + ix) if self._fills_row_first else \ + (ix * len(rows) + iy) def compute_visible_views(self, data, viewport): if self._cols_pos is None: return [] x, y, w, h = viewport - # gridlayout counts from left to right, top to down + right = x + w + top = y + h at_idx = self.get_view_index_at - bl = at_idx((x, y)) - br = at_idx((x + w, y)) - tl = at_idx((x, y + h)) + # 'tl' is not actually 'top-left' unless 'orientation' is 'lr-tb'. + # But we can pretend it is. Same for 'bl' and 'br'. + tl, __, bl, br = sorted(( + at_idx((x, y)), + at_idx((right, y)), + at_idx((x, top)), + at_idx((right, top)), + )) + n = len(data) - indices = [] - row = len(self._cols) - if row: + stride = len(self._cols) if self._fills_row_first else len(self._rows) + if stride: x_slice = br - bl + 1 - for s in range(tl, bl + 1, row): + for s in range(tl, bl + 1, stride): indices.extend(range(min(s, n), min(n, s + x_slice))) - return indices