diff --git a/docs/changelog.rst b/docs/changelog.rst index 69cc0ce04..0d82444fc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -202,6 +202,8 @@ Detailed list of changes 0.49.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Splits layout: Fix dragging a border resizing the wrong split in nested layouts. + - Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`) - Various throughput performance improvements for a 15-35% real world improvement depending on workload diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 66683f976..10e6590fb 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -879,62 +879,36 @@ class Splits(Layout): edges: int, all_windows: WindowList, ) -> WindowResizeDragData: - is_right, is_bottom = bool(edges & RIGHT_EDGE), bool(edges & BOTTOM_EDGE) - is_leading_edge = not (is_right or is_bottom) - ans = WindowResizeDragData(None, is_right, None, is_bottom) - if (wg := all_windows.group_for_window(click_window)) is None or (pair := self.pairs_root.pair_for_window(wg.id)) is None: + right, bottom = bool(edges & RIGHT_EDGE), bool(edges & BOTTOM_EDGE) + ans = WindowResizeDragData(None, right, None, bottom) + group = all_windows.group_for_window(click_window) + if group is None: + return ans + path = self.pairs_root.find_window_in_tree(group.id) + if path is None: return ans - pair_parent_map = {} - for p in self.pairs_root.self_and_descendants(): - if isinstance(p.one, Pair): - pair_parent_map[p.one] = p - if isinstance(p.two, Pair): - pair_parent_map[p.two] = p - p = pair - def size_increases_forwards(p: Pair) -> bool: - in_leading_half = not p.is_group_on_second(wg.id) - return is_leading_edge != in_leading_half + def target(horizontal: bool, trailing: bool) -> tuple[int | None, bool]: + fallback = None + for pair, in_one in reversed(path): + if pair.is_redundant or pair.horizontal != horizontal: + continue + if fallback is None: + fallback = id(pair) + # The right/bottom edge of the first child, or left/top edge + # of the second child, belongs to this divider. Otherwise keep + # climbing, even across ancestors with the same split axis. + if in_one == trailing: + return id(pair), True + # Preserve native resizing from the outside edge of a layout. + return fallback, False - def ancestor_with_neighboring_border_of_same_orientation(p: Pair) -> Pair | None: - horizontal = bool(edges & (LEFT_EDGE | RIGHT_EDGE)) - while q := pair_parent_map.get(p): - if q.horizontal == horizontal: - if q.between_borders: - return q - break - p = q - return None - - def pair_or_parent(p: Pair) -> tuple[Pair, bool]: - in_leading_half = not p.is_group_on_second(wg.id) - if is_leading_edge == in_leading_half and p is pair and (parent := ancestor_with_neighboring_border_of_same_orientation(p)): - # special case for leading edge of one or trailing edge of two with parent being same orientation - return parent, True - return p, size_increases_forwards(p) - - while ans.horizontal_id is None or ans.vertical_id is None: - if p.is_redundant: - continue - if ans.horizontal_id is None and p.horizontal: - new_p, fwd = pair_or_parent(p) - p = new_p - if not p.horizontal and ans.vertical_id is None: - # pair_or_parent redirected to a vertical pair; use it for vertical resize - ans = ans._replace(vertical_id=id(p), height_increases_downwards=fwd) - else: - ans = ans._replace(horizontal_id=id(p), width_increases_rightwards=fwd) - if ans.vertical_id is None and not p.horizontal: - new_p, fwd = pair_or_parent(p) - p = new_p - if p.horizontal and ans.horizontal_id is None: - # pair_or_parent redirected to a horizontal pair; use it for horizontal resize - ans = ans._replace(horizontal_id=id(p), width_increases_rightwards=fwd) - else: - ans = ans._replace(vertical_id=id(p), height_increases_downwards=fwd) - if (parent := pair_parent_map.get(p)) is None: - break - p = parent + if edges & (LEFT_EDGE | RIGHT_EDGE): + horizontal_id, forwards = target(True, right) + ans = ans._replace(horizontal_id=horizontal_id, width_increases_rightwards=forwards) + if edges & (TOP_EDGE | BOTTOM_EDGE): + vertical_id, forwards = target(False, bottom) + ans = ans._replace(vertical_id=vertical_id, height_increases_downwards=forwards) return ans def layout_state(self) -> dict[str, Any]: diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 3e9b08d58..f63d83094 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -855,3 +855,117 @@ class TestLayout(BaseTest): d = drtw(q, all_windows, wD, TOP_EDGE) self.ae(d.vertical_id, right_pair_id) self.ae(d.height_increases_downwards, True) + + +class TestSplitBorderResize(BaseTest): + def setUp(self): + super().setUp() + self.set_options({'tab_bar_style': 'hidden'}) + + def make_layout(self, shape): + layout = create_layout(Splits) + windows = create_windows(layout, num=0) + for i in range(1, 5): + layout.add_window(windows, Window(i)) + layout.pairs_root.unserialize(shape, lambda x: x) + layout(windows) + return layout, windows + + def test_left_center_stack_right_after_resize(self): + layout, windows = self.make_layout( + { + 'bias': 1 / 3, + 'one': 1, + 'two': {'one': {'horizontal': False, 'one': 2, 'two': 3}, 'two': 4}, + } + ) + root = layout.pairs_root + right_pair = root.two + center = right_pair.one + ws = {w.id: w for w in windows} + # Resize right first, then alternate both sides of the left and right + # dividers and both halves of the central horizontal divider. + for _ in range(3): + for wid, edge, expected in ( + (4, LEFT_EDGE, right_pair), + (2, LEFT_EDGE, root), + (3, LEFT_EDGE, root), + (1, RIGHT_EDGE, root), + (2, RIGHT_EDGE, right_pair), + (3, RIGHT_EDGE, right_pair), + (2, BOTTOM_EDGE, center), + (3, TOP_EDGE, center), + ): + for increment in (0.04, -0.03): + with self.subTest(wid=wid, edge=edge, increment=increment): + data = layout.drag_resize_target_windows(ws[wid], 0, 0, edge, windows) + horizontal = bool(edge & (LEFT_EDGE | RIGHT_EDGE)) + target = data.horizontal_id if horizontal else data.vertical_id + forwards = data.width_increases_rightwards if horizontal else data.height_increases_downwards + self.assertEqual(target, id(expected)) + self.assertTrue(forwards) + before = {id(p): p.bias for p in root.self_and_descendants()} + self.assertTrue(layout.drag_resize_window(windows, target, increment, horizontal)) + layout(windows) + for p in root.self_and_descendants(): + self.assertAlmostEqual( + p.bias, + before[id(p)] + (increment if p is expected else 0), + ) + + def test_all_rendered_internal_borders(self): + # All 5 binary tree shapes with 4 leaves, with every combination of + # horizontal/vertical ancestors: 40 distinct arrangements. + def shapes(ids): + if len(ids) == 1: + yield ids[0] + return + for n in range(1, len(ids)): + for one in shapes(ids[:n]): + for two in shapes(ids[n:]): + for horizontal in (True, False): + yield { + 'horizontal': horizontal, + 'bias': 0.39, + 'one': one, + 'two': two, + } + + count = 0 + for shape in shapes((1, 2, 3, 4)): + layout, windows = self.make_layout(shape) + ws = {w.id: w for w in windows} + for pair in layout.pairs_root.self_and_descendants(): + if pair.is_redundant: + continue + for half in pair.between_borders: + for border in half: + trailing = border.window_id > 0 + edge = (RIGHT_EDGE if trailing else LEFT_EDGE) if pair.horizontal else (BOTTOM_EDGE if trailing else TOP_EDGE) + with self.subTest(shape=shape, wid=border.window_id, edge=edge): + data = layout.drag_resize_target_windows(ws[abs(border.window_id)], 0, 0, edge, windows) + target = data.horizontal_id if pair.horizontal else data.vertical_id + forwards = data.width_increases_rightwards if pair.horizontal else data.height_increases_downwards + self.assertEqual(target, id(pair)) + self.assertTrue(forwards) + count += 1 + self.assertEqual(count, 120) + + def test_corner_axes_independent_and_single_pane(self): + layout, windows = self.make_layout( + { + 'one': 1, + 'two': {'one': {'horizontal': False, 'one': 2, 'two': 3}, 'two': 4}, + } + ) + ws = {w.id: w for w in windows} + data = layout.drag_resize_target_windows(ws[2], 0, 0, LEFT_EDGE | BOTTOM_EDGE, windows) + self.assertEqual(data.horizontal_id, id(layout.pairs_root)) + self.assertEqual(data.vertical_id, id(layout.pairs_root.two.one)) + self.assertTrue(data.width_increases_rightwards) + self.assertTrue(data.height_increases_downwards) + layout.pairs_root = Pair() + layout.pairs_root.one = 1 + data = layout.drag_resize_target_windows(ws[1], 0, 0, LEFT_EDGE | TOP_EDGE, windows) + self.assertIsNone(data.horizontal_id) + self.assertIsNone(data.vertical_id)