Merge branch 'splits-proportional' of https://github.com/xiaobai050/kitty

# Conflicts:
#	kitty_tests/layout.py
This commit is contained in:
Kovid Goyal 2026-09-16 09:23:31 +05:30
commit 791614ce93
4 changed files with 223 additions and 5 deletions

View file

@ -202,6 +202,10 @@ Detailed list of changes
0.49.0 [future]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Splits layout: Add an optional proportional sizing policy that preserves
adjusted window weights when adding, closing or repositioning windows.
See :doc:`layouts` for configuration.
- 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

View file

@ -207,12 +207,26 @@ width and ``maximize vertical`` to fill the full height. Calling it again
restores the original split sizes. The ``equalize`` action redistributes space
so that all windows along each split axis receive an equal share.
This layout takes two options. ``equalize_on_window_close`` automatically equalizes
This layout takes three options. ``equalize_on_window_close`` automatically equalizes
split sizes whenever a window is closed, keeping remaining windows balanced
without needing an explicit keybinding::
enabled_layouts splits:equalize_on_window_close=true
``proportional`` preserves the relative sizes of siblings along the same split
axis when adding, removing or repositioning windows. A new window receives the
same weight as the window being split, and the siblings are scaled to fit.
For example, splitting either of two equally sized side-by-side windows makes
three equally sized windows. Manually adjusted proportions are preserved when
windows are closed. Splits along the other axis keep their relative sizes::
enabled_layouts splits:proportional=true
This option is disabled by default. An explicit :option:`launch --bias` overrides
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>`.
``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

@ -127,6 +127,51 @@ class Pair:
return q
return None
def window_weights(self) -> dict[int, float]:
weights: dict[int, float] = {}
def walk(child: Pair | int | None, weight: float) -> None:
if isinstance(child, Pair):
if child.is_redundant:
walk(child.one or child.two, weight)
else:
walk(child.one, weight * child.bias)
walk(child.two, weight * (1 - child.bias))
elif child is not None:
weights[child] = weight
walk(self, 1.0)
return weights
def set_window_weights(self, weights: dict[int, float]) -> None:
def tune(child: Pair | int | None) -> float:
if isinstance(child, Pair):
one, two = tune(child.one), tune(child.two)
if one > 0 and two > 0:
child.bias = one / (one + two)
return one + two
return weights.get(child, 0.0) if child is not None else 0.0
tune(self)
def preserve_weights_on_removal(self, removed: Collection[int]) -> bool:
# Each perpendicular subtree is one unit along this split axis. If
# some of its windows survive, retain its full share on this axis.
def tune(child: Pair | int | None, weight: float) -> float:
if isinstance(child, Pair):
if child.horizontal != self.horizontal:
return weight if child.preserve_weights_on_removal(removed) else 0.0
if child.is_redundant:
return tune(child.one or child.two, weight)
one = tune(child.one, weight * child.bias)
two = tune(child.two, weight * (1 - child.bias))
if one > 0 and two > 0:
child.bias = one / (one + two)
return one + two
return weight if child is not None and child not in removed else 0.0
return tune(self, 1.0) > 0
def remove_windows(self, window_ids: Collection[int]) -> None:
if isinstance(self.one, int) and self.one in window_ids:
self.one = None
@ -545,6 +590,7 @@ class Pair:
class SplitsLayoutOpts(LayoutOpts):
default_axis_is_horizontal: bool | None = True
equalize_on_close: bool = False
proportional: bool = False
def __init__(self, data: dict[str, str]):
q = data.get('split_axis', 'horizontal')
@ -553,11 +599,13 @@ class SplitsLayoutOpts(LayoutOpts):
else:
self.default_axis_is_horizontal = q == 'horizontal'
self.equalize_on_close = to_bool(data.get('equalize_on_window_close', 'n'))
self.proportional = to_bool(data.get('proportional', 'n'))
def serialized(self) -> dict[str, str]:
return {
'split_axis': 'auto' if self.default_axis_is_horizontal is None else ('horizontal' if self.default_axis_is_horizontal else 'vertical'),
'equalize_on_window_close': 'y' if self.equalize_on_close else 'n',
'proportional': 'y' if self.proportional else 'n',
}
@ -588,6 +636,8 @@ class Splits(Layout):
def remove_windows(self, *windows_to_remove: int) -> None:
root = self.pairs_root
if self.layout_opts.proportional:
root.preserve_weights_on_removal(frozenset(windows_to_remove))
for pair in root.self_and_descendants():
pair.remove_windows(windows_to_remove)
root.collapse_redundant_pairs()
@ -596,6 +646,25 @@ class Splits(Layout):
if isinstance(q, Pair):
self.pairs_root = q
def proportional_container(self, group_id: int, horizontal: bool) -> Pair | None:
root = self.pairs_root
pair = root.pair_for_window(group_id)
if pair is None or pair.horizontal != horizontal:
return None
while (parent := pair.parent(root)) is not None and parent.horizontal == horizontal:
pair = parent
return pair
def split_and_add(self, pair: Pair, group_id: int, new_id: int, horizontal: bool, after: bool, bias: float | None = None) -> None:
container = self.proportional_container(group_id, horizontal) if self.layout_opts.proportional and bias is None else None
weights = container.window_weights() if container is not None else {}
parent = pair.split_and_add(group_id, new_id, horizontal, after)
if bias is not None:
parent.bias = bias if parent.one == new_id else (1 - bias)
elif container is not None and (weight := weights.get(group_id, 0.0)) > 0:
weights[new_id] = weight
container.set_window_weights(weights)
def do_layout(self, windows: WindowList) -> None:
groups = tuple(windows.iter_all_layoutable_groups())
root = self.pairs_root
@ -642,9 +711,7 @@ class Splits(Layout):
wheight = aw.geometry.bottom - aw.geometry.top
horizontal = wwidth >= wheight
target_group = all_windows.add_window(window, next_to=aw, before=not after)
parent_pair = pair.split_and_add(group_id, target_group.id, horizontal, after)
if bias is not None:
parent_pair.bias = bias if parent_pair.one == target_group.id else (1 - bias)
self.split_and_add(pair, group_id, target_group.id, horizontal, after, bias)
return
all_windows.add_window(window)
g = all_windows.group_for_window(window)
@ -747,7 +814,7 @@ class Splits(Layout):
# Re-insert next to dest
pair = self.pairs_root.pair_for_window(dest_wg.id)
if pair is not None:
pair.split_and_add(dest_wg.id, src_wg.id, horizontal, after)
self.split_and_add(pair, dest_wg.id, src_wg.id, horizontal, after)
else:
self.pairs_root.balanced_add(src_wg.id)

View file

@ -969,3 +969,136 @@ class TestSplitBorderResize(BaseTest):
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)
class TestProportionalSplits(BaseTest):
def make_layout(self, shape=None, num=3, **options):
q = create_layout(Splits)
q.layout_opts = SplitsLayoutOpts({'proportional': 'yes', **options})
windows = create_windows(q, num=0)
for i in range(1, num + 1 if shape else 2):
q.add_window(windows, Window(i), location='vsplit')
if shape:
q.pairs_root.unserialize(shape, lambda x: x)
return q, windows
def check_weights(self, q, expected):
# Observe the serialized layout, independently of the sizing helpers.
actual = {}
def walk(node, size):
if isinstance(node, int):
actual[node] = size
elif 'two' not in node:
walk(node['one'], size)
else:
bias = node.get('bias', 0.5)
walk(node['one'], size * bias)
walk(node['two'], size * (1 - bias))
walk(q.pairs_root.serialize(), 1.0)
self.ae(actual.keys(), expected.keys())
for wid, size in expected.items():
self.assertAlmostEqual(actual[wid], size)
def test_proportional_add_equal_siblings(self):
for location in ('vsplit', 'hsplit', None):
for target in (1, 2):
with self.subTest(location=location, target=target):
q, windows = self.make_layout()
q.add_window(windows, Window(2), location=location)
windows.set_active_window_group_for(windows.id_map[target])
q.add_window(windows, Window(3), location=location)
self.check_weights(q, {1: 1 / 3, 2: 1 / 3, 3: 1 / 3})
q.add_window(windows, Window(4), location=location)
self.check_weights(q, dict.fromkeys((1, 2, 3, 4), 0.25))
def test_proportional_default_unchanged(self):
q, windows = self.make_layout(proportional='no')
q.add_window(windows, Window(2), location='vsplit')
q.add_window(windows, Window(3), location='vsplit')
self.check_weights(q, {1: 0.5, 2: 0.25, 3: 0.25})
def test_proportional_add_preserves_adjusted_weights(self):
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}})
q.add_window(windows, Window(4), location='vsplit', next_to=windows.id_map[2])
self.check_weights(q, {1: 2 / 13, 2: 3 / 13, 3: 5 / 13, 4: 3 / 13})
def test_proportional_explicit_bias_wins(self):
q, windows = self.make_layout()
q.add_window(windows, Window(2), location='vsplit')
q.add_window(windows, Window(3), location='vsplit', bias=80)
self.check_weights(q, {1: 0.5, 2: 0.1, 3: 0.4})
def test_proportional_close_preserves_survivors(self):
for removed in (1, 2, 3):
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}})
sizes = {1: 0.2, 2: 0.3, 3: 0.5}
sizes.pop(removed)
total = sum(sizes.values())
windows.remove_window(windows.id_map[removed])
q.remove_windows(removed)
self.check_weights(q, {k: v / total for k, v in sizes.items()})
def test_proportional_close_multiple(self):
q, windows = self.make_layout()
for i in range(2, 6):
q.add_window(windows, Window(i), location='vsplit')
q.remove_windows(1, 3, 5)
self.check_weights(q, {2: 0.5, 4: 0.5})
def test_proportional_batch_close_mixed_axes(self):
for horizontal in (True, False):
shape = {
'horizontal': horizontal,
'one': 1,
'two': {'horizontal': horizontal, 'one': {'horizontal': not horizontal, 'one': 2, 'two': 3}, 'two': 4},
}
q, windows = self.make_layout(shape, num=4)
q.remove_windows(1, 2)
# The surviving center pane keeps its column's width, even though
# the other pane in that column was removed at the same time.
self.check_weights(q, {3: 0.5, 4: 0.5})
def test_proportional_mixed_axes(self):
q, windows = self.make_layout({'bias': 0.4, 'one': 1, 'two': {'horizontal': False, 'bias': 0.25, 'one': 2, 'two': 3}})
q.add_window(windows, Window(4), location='hsplit', next_to=windows.id_map[2])
self.check_weights(q, {1: 0.4, 2: 0.12, 3: 0.36, 4: 0.12})
q.remove_windows(2)
self.check_weights(q, {1: 0.4, 3: 0.45, 4: 0.15})
q.remove_windows(4)
self.check_weights(q, {1: 0.4, 3: 0.6})
def test_proportional_perpendicular_subtree_unchanged(self):
q, windows = self.make_layout({'bias': 0.4, 'one': 1, 'two': {'horizontal': False, 'bias': 0.25, 'one': 2, 'two': 3}})
q.add_window(windows, Window(4), location='vsplit', next_to=windows.id_map[1])
self.check_weights(q, {1: 2 / 7, 4: 2 / 7, 2: 3 / 28, 3: 9 / 28})
def test_proportional_reposition(self):
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}})
q.insert_window_next_to(windows, windows.id_map[3], windows.id_map[1], True, False)
self.check_weights(q, {1: 2 / 7, 2: 3 / 7, 3: 2 / 7})
self.ae(list(q.pairs_root.all_window_ids()), [3, 1, 2])
before = q.layout_state()
q.insert_window_next_to(windows, windows.id_map[1], windows.id_map[1], True, False)
self.ae(q.layout_state(), before)
def test_proportional_overlay_does_not_change_weights(self):
q, windows = self.make_layout()
q.add_window(windows, Window(2), location='vsplit')
before = q.layout_state()
q.add_window(windows, Window(10), overlay_for=2)
self.ae(q.layout_state(), before)
q.add_window(windows, Window(3), location='vsplit')
self.check_weights(q, {1: 1 / 3, 2: 1 / 3, 3: 1 / 3})
self.assertIs(windows.group_for_window(windows.id_map[10]), windows.group_for_window(windows.id_map[2]))
def test_proportional_option_roundtrip_and_equalize(self):
q, windows = self.make_layout({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}}, equalize_on_window_close='yes')
options = SplitsLayoutOpts(q.layout_opts.serialized())
self.assertTrue(options.proportional)
self.assertTrue(options.equalize_on_close)
windows.remove_window(windows.id_map[1])
q.remove_windows(1)
self.assertTrue(q.on_window_removed(windows))
self.check_weights(q, {2: 0.5, 3: 0.5})