diff --git a/docs/changelog.rst b/docs/changelog.rst index 0990d9a90..a3b98625c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -199,6 +199,13 @@ you use a decent Wayland compositor. Detailed list of changes ------------------------------------- +Unreleased +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Splits layout: Dragging a divider now resizes only its adjacent regions, + consistently after rearranging windows. Fix nested dividers not tracking + mouse movement correctly, including when returning from a minimum size. + 0.49.0 [2026-09-21] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/layouts.rst b/docs/layouts.rst index 488d86b4a..aaf4c0b84 100644 --- a/docs/layouts.rst +++ b/docs/layouts.rst @@ -227,6 +227,11 @@ proportional sizing for that new window. If ``equalize_on_window_close`` is also enabled, closing a window equalizes the layout instead of preserving its weights. The option is saved as part of a :doc:`session `. +Dragging a divider resizes only the two adjacent regions along its axis. Other +dividers in the same row or column stay in place, including after repositioning +windows. Nested dividers follow the mouse in character-cell steps relative to +their own region, and stop when an adjacent region reaches its minimum size. + ``split_axis`` controls whether new windows are placed into vertical or horizontal splits when a :option:`--location ` is not specified. A value of ``horizontal`` (same as diff --git a/kitty/boss.py b/kitty/boss.py index fe7460f25..51f9b9b0d 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -16,7 +16,6 @@ from dataclasses import dataclass from functools import partial from gettext import gettext as _ from gettext import ngettext -from math import floor from time import sleep from typing import ( TYPE_CHECKING, @@ -2882,18 +2881,18 @@ class Boss: return if (h := r.data.horizontal_id) is not None: mult = 1 if r.data.width_increases_rightwards else -1 - step_x = floor((x - r.initial_x) / r.cell_width) * mult + step_x = int((x - r.initial_x) / r.cell_width) * mult dx = step_x - r.last_step_x if dx != 0: - if tab.drag_resize_window(h, float(dx), True): - self.drag_resize_of_window = r._replace(last_step_x=step_x) + if applied := tab.drag_resize_window(h, float(dx), True): + self.drag_resize_of_window = r = r._replace(last_step_x=r.last_step_x + applied) if (v := r.data.vertical_id) is not None: mult = 1 if r.data.height_increases_downwards else -1 - step_y = floor((y - r.initial_y) / r.cell_height) * mult + step_y = int((y - r.initial_y) / r.cell_height) * mult dy = step_y - r.last_step_y if dy != 0: - if tab.drag_resize_window(v, float(dy), False): - self.drag_resize_of_window = r._replace(last_step_y=step_y) + if applied := tab.drag_resize_window(v, float(dy), False): + self.drag_resize_of_window = r._replace(last_step_y=r.last_step_y + applied) def drag_resize_end(self) -> None: if tab := self.tab_for_id(self.drag_resize_of_window.tab_id): diff --git a/kitty/layout/base.py b/kitty/layout/base.py index 4675cb656..82cfd0794 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -324,7 +324,10 @@ class Layout: return False return self.apply_bias(idx, increment, all_windows, is_horizontal) - drag_resize_window = modify_size_of_window + def drag_resize_window(self, all_windows: WindowList, window_id: int, increment: float, is_horizontal: bool = True) -> float: + """Resize by a number of cells, returning the number actually applied.""" + increment_as_percent = self.bias_increment_for_cell(all_windows, is_horizontal) * increment + return increment if self.modify_size_of_window(all_windows, window_id, increment_as_percent, is_horizontal) else 0.0 def parse_layout_opts(self, layout_opts: str | None = None) -> LayoutOpts: data: dict[str, str] = {} diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 9eaa2231f..180660891 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -407,6 +407,53 @@ class Pair: geom = window_geometry_from_layouts(xl, yl) self.apply_window_geometry(self.two, geom, id_window_map, layout_object) + def move_divider(self, pixels: int) -> int: + """Resize adjacent same-axis units, keeping the other dividers in place.""" + if self.is_redundant or not pixels or self.width <= 0 or self.height <= 0: + return 0 + horizontal = self.horizontal + cell = lgd.cell_width if horizontal else lgd.cell_height + + def minimum_size(child: Pair | int | None) -> int: + if not isinstance(child, Pair): + return cell if child is not None else 0 + if child.is_redundant: + return minimum_size(child.one or child.two) + one, two = minimum_size(child.one), minimum_size(child.two) + return one + two + 2 * child.border_width if child.horizontal == horizontal else max(one, two) + + def shrink_room(child: Pair | int | None, extent: Edges, trailing: bool) -> int: + # A perpendicular subtree is one unit in the enclosing splitter. + # Only the unit touching this divider may give up space. + while isinstance(child, Pair) and child.horizontal == horizontal and not child.is_redundant: + extent = child.second_extent if trailing else child.first_extent + child = child.two if trailing else child.one + size = extent.right - extent.left if horizontal else extent.bottom - extent.top + return max(0, size - minimum_size(child)) + + pixels = max(-shrink_room(self.one, self.first_extent, True), min(pixels, shrink_room(self.two, self.second_extent, False))) + if not pixels: + return 0 + + def update(pair: Pair, start: int, size: int) -> None: + divider = (pair.first_extent.right if horizontal else pair.first_extent.bottom) + pair.border_width + if pair is self: + divider += pixels + # layout_pair truncates to integer pixels. Use the middle of that + # pixel so floating-point rounding cannot shift a fixed divider. + pair.bias = (divider - start + 0.5) / size + for child, child_start, child_size in ( + (pair.one, start, divider - start - pair.border_width), + (pair.two, divider + pair.border_width, start + size - divider - pair.border_width), + ): + if isinstance(child, Pair) and child.horizontal == horizontal and not child.is_redundant: + old_start, old_size = (child.left, child.width) if horizontal else (child.top, child.height) + if (child_start, child_size) != (old_start, old_size): + update(child, child_start, child_size) + + update(self, self.left if horizontal else self.top, self.width if horizontal else self.height) + return pixels + def edge_border(self, which: int, id_group_map: dict[int, WindowGroup]) -> Iterator[tuple[int, int, int]]: mult = 1 if which & (RIGHT_EDGE | BOTTOM_EDGE) else -1 @@ -985,15 +1032,13 @@ class Splits(Layout): return None - def drag_resize_window(self, all_windows: WindowList, window_id: int, increment: float, is_horizontal: bool = True) -> bool: + def drag_resize_window(self, all_windows: WindowList, window_id: int, increment: float, is_horizontal: bool = True) -> float: + self._set_dimensions(all_windows) for pair in self.pairs_root.self_and_descendants(): - if id(pair) == window_id: - new_bias = max(0, min(pair.bias + increment, 1)) - if new_bias != pair.bias: - pair.bias = new_bias - return True - break - return False + if id(pair) == window_id and pair.horizontal == is_horizontal: + cell = lgd.cell_width if is_horizontal else lgd.cell_height + return pair.move_divider(round(increment * cell)) / cell if cell > 0 else 0.0 + return 0.0 def drag_resize_target_windows( self, diff --git a/kitty/tabs.py b/kitty/tabs.py index 5b58b9671..8a7c0f3f2 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -675,9 +675,8 @@ class Tab: # {{{ return None return 'Could not resize' - def drag_resize_window(self, object_id: int, increment: float, is_horizontal: bool) -> bool: - increment_as_percent = self.current_layout.bias_increment_for_cell(self.windows, is_horizontal) * increment - if resized := self.current_layout.drag_resize_window(self.windows, object_id, increment_as_percent, is_horizontal): + def drag_resize_window(self, object_id: int, increment: float, is_horizontal: bool) -> float: + if resized := self.current_layout.drag_resize_window(self.windows, object_id, increment, is_horizontal): self.relayout() return resized diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index b710ce6c4..5a81b8ff6 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -887,6 +887,8 @@ class TestSplitBorderResize(BaseTest): def setUp(self): super().setUp() self.set_options({'tab_bar_style': 'hidden'}) + saved = vars(lgd).copy() + self.addCleanup(lambda: (vars(lgd).clear(), vars(lgd).update(saved))) def make_layout(self, shape): layout = create_layout(Splits) @@ -894,6 +896,12 @@ class TestSplitBorderResize(BaseTest): for i in range(1, 5): layout.add_window(windows, Window(i)) layout.pairs_root.unserialize(shape, lambda x: x) + + def dimensions(all_windows): + lgd.central = Region((0, 0, 1499, 1099, 1500, 1100)) + lgd.cell_width, lgd.cell_height = 10, 20 + + layout._set_dimensions = dimensions layout(windows) return layout, windows @@ -922,7 +930,7 @@ class TestSplitBorderResize(BaseTest): (2, BOTTOM_EDGE, center), (3, TOP_EDGE, center), ): - for increment in (0.04, -0.03): + for increment in (3, -3): 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)) @@ -930,14 +938,16 @@ class TestSplitBorderResize(BaseTest): 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()} + + def positions(): + return {id(p): p.first_extent.right if p.horizontal else p.first_extent.bottom for p in root.self_and_descendants()} + + before = positions() 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), - ) + cell = lgd.cell_width if horizontal else lgd.cell_height + for pid, position in positions().items(): + self.ae(position - before[pid], increment * cell if pid == target else 0) def test_all_rendered_internal_borders(self): # All 5 binary tree shapes with 4 leaves, with every combination of @@ -997,6 +1007,139 @@ class TestSplitBorderResize(BaseTest): self.assertIsNone(data.vertical_id) +class TestSplitDragGeometry(BaseTest): + def setUp(self): + super().setUp() + self.set_options({'tab_bar_style': 'hidden'}) + saved = vars(lgd).copy() + self.addCleanup(lambda: (vars(lgd).clear(), vars(lgd).update(saved))) + + def make_layout(self, shape, minimal=True): + from types import SimpleNamespace + + from kitty.tabs import Tab as RealTab + + layout = create_layout(Splits) + layout.layout_opts = SplitsLayoutOpts({'proportional': 'yes'}) + windows = create_windows(layout, num=4) + layout.pairs_root.unserialize(shape, lambda x: x) + + def dimensions(all_windows): + lgd.central = Region((19, 47, 1518, 1146, 1500, 1100)) + lgd.cell_width, lgd.cell_height = 10, 20 + lgd.draw_minimal_borders = minimal + + layout._set_dimensions = dimensions + tab = SimpleNamespace(current_layout=layout, windows=windows, relayout=lambda: layout(windows)) + tab.drag_resize_window = lambda *args: RealTab.drag_resize_window(tab, *args) + tab.relayout() + return layout, windows, tab + + def positions(self, layout): + return { + id(p): (p.first_extent.right if p.horizontal else p.first_extent.bottom) + p.border_width + for p in layout.pairs_root.self_and_descendants() + if not p.is_redundant + } + + def check_drags(self, layout, tab): + for pair in layout.pairs_root.self_and_descendants(): + if pair.is_redundant: + continue + cell = lgd.cell_width if pair.horizontal else lgd.cell_height + for steps in (3, -5, 2): + before = self.positions(layout) + self.assertTrue(tab.drag_resize_window(id(pair), steps, pair.horizontal)) + after = self.positions(layout) + for pid, position in before.items(): + self.ae(after[pid] - position, steps * cell if pid == id(pair) else 0) + + def test_split_drag_adjacent_geometry(self): + shapes = ( + {'bias': 0.3, 'one': 1, 'two': {'bias': 0.4, 'one': 2, 'two': {'one': 3, 'two': 4}}}, + {'one': {'bias': 0.6, 'one': 1, 'two': 2}, 'two': {'one': 3, 'two': 4}}, + {'one': {'one': {'one': 1, 'two': 2}, 'two': 3}, 'two': 4}, + {'horizontal': False, 'bias': 0.35, 'one': 4, 'two': {'one': 1, 'two': {'one': 2, 'two': 3}}}, + {'one': 1, 'two': {'one': {'horizontal': False, 'one': 2, 'two': 3}, 'two': 4}}, + ) + + def transpose(node): + if isinstance(node, int): + return node + return {**node, 'horizontal': not node.get('horizontal', True), 'one': transpose(node['one']), 'two': transpose(node['two'])} + + for shape in shapes: + for oriented in (shape, transpose(shape)): + for minimal in (True, False): + with self.subTest(shape=oriented, minimal=minimal): + layout, windows, tab = self.make_layout(oriented, minimal) + self.check_drags(layout, tab) + + def test_split_drag_after_reposition_and_restore(self): + shape = {'horizontal': False, 'one': 4, 'two': {'bias': 0.3, 'one': 1, 'two': {'bias': 0.6, 'one': 2, 'two': 3}}} + layout, windows, tab = self.make_layout(shape) + ws = {w.id: w for w in windows} + self.check_drags(layout, tab) + layout.insert_window_next_to(windows, ws[3], ws[1], True, False) + tab.relayout() + self.ae(list(layout.pairs_root.two.all_window_ids()), [3, 1, 2]) + self.check_drags(layout, tab) + saved = {**layout.layout_state(), 'opts': layout.layout_opts.serialized()} + self.assertTrue(layout.set_layout_state(saved, lambda x: x)) + tab.relayout() + self.check_drags(layout, tab) + + def test_split_drag_minimum_size(self): + layout, windows, tab = self.make_layout({'one': 1, 'two': {'one': 2, 'two': {'one': 3, 'two': 4}}}) + root = layout.pairs_root + before = self.positions(layout) + self.assertTrue(tab.drag_resize_window(id(root), 10000, True)) + after = self.positions(layout) + for pid in before: + if pid != id(root): + self.ae(after[pid], before[pid]) + self.assertGreaterEqual(root.two.first_extent.right - root.two.first_extent.left, lgd.cell_width) + self.assertFalse(tab.drag_resize_window(id(root), 1, True)) + self.assertTrue(tab.drag_resize_window(id(root), -1, True)) + self.ae(self.positions(layout)[id(root)], after[id(root)] - lgd.cell_width) + + def test_split_drag_corner_and_subcell_motion(self): + from types import SimpleNamespace + + from kitty.boss import Boss + from kitty.types import WindowResizeDrag, WindowResizeDragData + + layout, windows, tab = self.make_layout({'one': 1, 'two': {'one': {'horizontal': False, 'one': 2, 'two': 3}, 'two': 4}}) + root = layout.pairs_root + data = WindowResizeDragData(id(root), True, id(root.two.one), True) + state = WindowResizeDrag(is_active=True, cell_width=10, cell_height=20, initial_x=300, initial_y=400, data=data) + boss = SimpleNamespace(drag_resize_of_window=state, tab_for_id=lambda _: tab) + before = self.positions(layout) + for x, y in ((301, 401), (299, 399), (300, 400)): + Boss.drag_resize_update(boss, x, y) + self.ae(self.positions(layout), before) + Boss.drag_resize_update(boss, 330, 440) + moved = self.positions(layout) + self.ae(moved[id(root)] - before[id(root)], 30) + self.ae(moved[id(root.two.one)] - before[id(root.two.one)], 40) + Boss.drag_resize_update(boss, 330, 440) + self.ae(self.positions(layout), moved) + Boss.drag_resize_update(boss, 300, 400) + self.ae(self.positions(layout), before) + + # Overshooting a minimum size must not detach the divider from the + # pointer when the pointer comes back into the allowed region. + boss.drag_resize_of_window = state._replace(data=data._replace(vertical_id=None)) + Boss.drag_resize_update(boss, 10300, 400) + limited = self.positions(layout) + Boss.drag_resize_update(boss, 9300, 400) + self.ae(self.positions(layout), limited) + Boss.drag_resize_update(boss, 330, 400) + self.ae(self.positions(layout)[id(root)], before[id(root)] + 30) + Boss.drag_resize_update(boss, 300, 400) + self.ae(self.positions(layout), before) + + class TestProportionalSplits(BaseTest): def make_layout(self, shape=None, num=3, **options): q = create_layout(Splits)