Splits: Resize adjacent regions consistently when dragging dividers

This commit is contained in:
白鸽 2026-09-22 17:09:21 +08:00
parent 7894d6bc03
commit 2faf4b051c
7 changed files with 227 additions and 26 deletions

View file

@ -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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -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 <sessions>`.
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
<launch --location>` is not specified. A value of ``horizontal`` (same as

View file

@ -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):

View file

@ -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] = {}

View file

@ -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,

View file

@ -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

View file

@ -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)