From cced6bb355a4758f5147baa65406b8dd7b435b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B8=BD?= <18717730+xiaobai050@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:17:53 +0800 Subject: [PATCH 1/2] Splits: Add optional proportional sizing --- docs/changelog.rst | 4 ++ docs/layouts.rst | 16 +++++- kitty/layout/splits.py | 69 ++++++++++++++++++++++-- kitty_tests/layout.py | 120 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f19e5af15..d7c684ea5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/docs/layouts.rst b/docs/layouts.rst index c4288d666..488d86b4a 100644 --- a/docs/layouts.rst +++ b/docs/layouts.rst @@ -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 `. + ``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/layout/splits.py b/kitty/layout/splits.py index 66683f976..429968b09 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -127,6 +127,33 @@ 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 remove_windows(self, window_ids: Collection[int]) -> None: if isinstance(self.one, int) and self.one in window_ids: self.one = None @@ -545,6 +572,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 +581,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 +618,15 @@ class Splits(Layout): def remove_windows(self, *windows_to_remove: int) -> None: root = self.pairs_root + plans: dict[tuple[bool, frozenset[int]], dict[int, float]] = {} + if self.layout_opts.proportional: + for group_id in windows_to_remove: + pair = root.pair_for_window(group_id) + if pair is not None and (container := self.proportional_container(group_id, pair.horizontal)) is not None: + weights = container.window_weights() + survivors = frozenset(weights).difference(windows_to_remove) + if len(survivors) > 1: + plans[container.horizontal, survivors] = weights for pair in root.self_and_descendants(): pair.remove_windows(windows_to_remove) root.collapse_redundant_pairs() @@ -595,6 +634,30 @@ class Splits(Layout): q = root.one or root.two if isinstance(q, Pair): self.pairs_root = q + for (horizontal, survivors), weights in plans.items(): + for pair in self.pairs_root.self_and_descendants(): + if pair.horizontal == horizontal and frozenset(pair.all_window_ids()) == survivors: + pair.set_window_weights(weights) + break + + 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()) @@ -642,9 +705,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 +808,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) diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 3e9b08d58..3902c43cf 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -855,3 +855,123 @@ 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 TestProportionalSplits(BaseTest): + def make_layout(self, shape=None, **options): + q = create_layout(Splits) + q.layout_opts = SplitsLayoutOpts({'proportional': 'yes', **options}) + windows = create_windows(q, num=0) + for i in range(1, 4 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_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}) From 82085561a0dc6c7b1ef4353abea5b10efc18bcee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B8=BD?= <18717730+xiaobai050@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:07:57 +0800 Subject: [PATCH 2/2] Splits: Preserve perpendicular shares on batch removal --- kitty/layout/splits.py | 32 +++++++++++++++++++------------- kitty_tests/layout.py | 17 +++++++++++++++-- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 429968b09..ad2de08ba 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -154,6 +154,24 @@ class Pair: 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 @@ -618,15 +636,8 @@ class Splits(Layout): def remove_windows(self, *windows_to_remove: int) -> None: root = self.pairs_root - plans: dict[tuple[bool, frozenset[int]], dict[int, float]] = {} if self.layout_opts.proportional: - for group_id in windows_to_remove: - pair = root.pair_for_window(group_id) - if pair is not None and (container := self.proportional_container(group_id, pair.horizontal)) is not None: - weights = container.window_weights() - survivors = frozenset(weights).difference(windows_to_remove) - if len(survivors) > 1: - plans[container.horizontal, survivors] = weights + 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() @@ -634,11 +645,6 @@ class Splits(Layout): q = root.one or root.two if isinstance(q, Pair): self.pairs_root = q - for (horizontal, survivors), weights in plans.items(): - for pair in self.pairs_root.self_and_descendants(): - if pair.horizontal == horizontal and frozenset(pair.all_window_ids()) == survivors: - pair.set_window_weights(weights) - break def proportional_container(self, group_id: int, horizontal: bool) -> Pair | None: root = self.pairs_root diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 3902c43cf..e12a2db9c 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -858,11 +858,11 @@ class TestLayout(BaseTest): class TestProportionalSplits(BaseTest): - def make_layout(self, shape=None, **options): + 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, 4 if shape else 2): + 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) @@ -933,6 +933,19 @@ class TestProportionalSplits(BaseTest): 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])