mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-20 20:22:10 +00:00
Splits: Resize adjacent regions consistently when dragging dividers
This commit is contained in:
parent
7894d6bc03
commit
2faf4b051c
7 changed files with 227 additions and 26 deletions
|
|
@ -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]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue