From 852fc4a66222adb951baff46f66ad2d818b83b33 Mon Sep 17 00:00:00 2001 From: Mark Stuart Date: Fri, 29 May 2026 19:44:05 +0100 Subject: [PATCH] Add equalize action and equalize_on_close option to Splits layout Adds an `equalize` layout action that redistributes split sizes so each window receives a proportional share of space along each axis. Also adds an `equalize_on_close` layout option that automatically equalizes splits whenever a window is closed, keeping the remaining windows balanced without requiring an explicit key binding. These two features compose well. For example, to keep splits balanced at all times - equalizing on every open and close: enabled_layouts splits:equalize_on_close=true map ctrl+' combine : launch --location=hsplit --cwd=current : layout_action equalize map ctrl+/ combine : launch --location=vsplit --cwd=current : layout_action equalize A standalone key binding for manual rebalancing is also supported: map ctrl+shift+e layout_action equalize --- docs/changelog.rst | 2 + docs/layouts.rst | 14 ++++- kitty/layout/base.py | 3 + kitty/layout/splits.py | 32 ++++++++++- kitty/tabs.py | 5 +- kitty_tests/layout.py | 128 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d0a86b0c2..c8c057413 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -182,6 +182,8 @@ Detailed list of changes - ``kitten @ set-background-image``: Fix ``--layout=configured`` changing layout to centered instead (:iss:`10089`) +- Splits layout: add an ``equalize`` action and an ``equalize_on_close`` option to redistribute split space proportionally (:iss:`3489`) + 0.47.1 [2026-05-28] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/layouts.rst b/docs/layouts.rst index b33feae0d..88b8860ef 100644 --- a/docs/layouts.rst +++ b/docs/layouts.rst @@ -194,6 +194,9 @@ define a few extra key bindings in :file:`kitty.conf`:: # to restore the original layout. map ctrl+shift+up layout_action maximize vertical + # Equalize all splits so that windows share available space proportionally. + map ctrl+shift+e layout_action equalize + Windows can be resized using :ref:`window_resizing`. You can swap the windows in a split using the ``rotate`` action with an argument of ``180`` and rotate @@ -201,9 +204,16 @@ and swap with an argument of ``270``. The ``maximize`` action expands the active window to fill the maximum available space along a single axis while keeping the rest of the layout intact. Use ``maximize horizontal`` to fill the full width and ``maximize vertical`` to fill the full height. Calling it again -restores the original split sizes. +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 one option, ``split_axis`` that controls whether new windows +This layout takes two options. ``equalize_on_close`` automatically equalizes +split sizes whenever a window is closed, keeping remaining windows balanced +without needing an explicit keybinding:: + + enabled_layouts splits:equalize_on_close=true + +``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 ``--location=vsplit``) means when a new split is created the two windows will diff --git a/kitty/layout/base.py b/kitty/layout/base.py index cb957a1f0..62baed205 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -492,6 +492,9 @@ class Layout: def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None: pass + def on_window_removed(self, all_windows: WindowList) -> bool: + return False + def layout_state(self) -> dict[str, Any]: return {} diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 8acd892de..a68928dd3 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -5,6 +5,7 @@ from collections.abc import Collection, Generator, Iterator, Sequence from typing import Any, Optional, TypedDict, Union from kitty.borders import BorderColor +from kitty.conf.utils import to_bool from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE from kitty.types import Edges, NeighborsMap, WindowGeometry, WindowMapper, WindowResizeDragData from kitty.typing_compat import EdgeLiteral, WindowType @@ -13,6 +14,12 @@ from kitty.window_list import WindowGroup, WindowList from .base import BorderLine, DragOverlayMode, Layout, LayoutOpts, blank_rects_for_window, lgd, window_geometry_from_layouts +def child_axis_units(child: 'Pair | int | None', horizontal: bool) -> int: + if isinstance(child, Pair): + return child.count_axis_units(horizontal) + return 1 if child is not None else 0 + + class SerializedPair(TypedDict, total=False): horizontal: bool # default to True if absent bias: float # default to 0.5 if absent @@ -82,6 +89,11 @@ class Pair: if isinstance(self.two, Pair): yield from self.two.self_and_descendants() + def count_axis_units(self, horizontal: bool) -> int: + if self.horizontal != horizontal: + return 1 + return child_axis_units(self.one, horizontal) + child_axis_units(self.two, horizontal) + def pair_for_window(self, window_id: int) -> Optional['Pair']: if self.one == window_id or self.two == window_id: return self @@ -544,6 +556,7 @@ class Pair: class SplitsLayoutOpts(LayoutOpts): default_axis_is_horizontal: bool | None = True + equalize_on_close: bool = False def __init__(self, data: dict[str, str]): q = data.get('split_axis', 'horizontal') @@ -551,9 +564,10 @@ class SplitsLayoutOpts(LayoutOpts): self.default_axis_is_horizontal = None else: self.default_axis_is_horizontal = q == 'horizontal' + self.equalize_on_close = to_bool(data.get('equalize_on_close', 'false')) def serialized(self) -> dict[str, Any]: - return {'default_axis_is_horizontal': self.default_axis_is_horizontal} + return {'default_axis_is_horizontal': self.default_axis_is_horizontal, 'equalize_on_close': self.equalize_on_close} class Splits(Layout): @@ -669,6 +683,20 @@ class Splits(Layout): pair.bias = 0.5 return True + def equalize_biases(self) -> bool: + for pair in self.pairs_root.self_and_descendants(): + left = child_axis_units(pair.one, pair.horizontal) + right = child_axis_units(pair.two, pair.horizontal) + total = left + right + if total > 0: + pair.bias = left / total + return True + + def on_window_removed(self, all_windows: WindowList) -> bool: + if self.layout_opts.equalize_on_close: + return self.equalize_biases() + return False + def minimal_borders(self, all_windows: WindowList) -> Iterator[BorderLine]: groups = tuple(all_windows.iter_all_layoutable_groups()) window_count = len(groups) @@ -850,6 +878,8 @@ class Splits(Layout): maximized_biases[key] = saved_biases self._maximized_biases = maximized_biases return True + elif action_name == 'equalize': + return self.equalize_biases() return None diff --git a/kitty/tabs.py b/kitty/tabs.py index a1026681f..380682b2d 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -841,7 +841,10 @@ class Tab: # {{{ def post_window_removal_update(self) -> None: self.mark_tab_bar_dirty() - self.relayout() + self.relayout() # prunes the closed window from the layout's internal tree + # equalize_on_close rebalances the pruned tree, requiring a second relayout + if self.current_layout.on_window_removed(self.windows): + self.relayout() active_window = self.active_window if active_window: self.title_changed(active_window) diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index fb0802c3f..cf672dd0b 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -5,7 +5,7 @@ from kitty.config import defaults from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE, Region from kitty.layout.base import layout_dimension, lgd from kitty.layout.interface import Grid, Horizontal, Splits, Stack, Tall -from kitty.layout.splits import Pair +from kitty.layout.splits import Pair, SplitsLayoutOpts from kitty.types import WindowGeometry from kitty.window import EdgeWidths from kitty.window_list import WindowList, reset_group_id_counter @@ -325,6 +325,132 @@ class TestLayout(BaseTest): self.assertTrue(result) self.ae(root.bias, root_bias_before) + def test_splits_equalize(self): + q = create_layout(Splits) + all_windows = create_windows(q, num=0) + w1 = Window(1) + q.add_window(all_windows, w1) + w2 = Window(2) + q.add_window(all_windows, w2, location='vsplit') + w3 = Window(3) + q.add_window(all_windows, w3, location='vsplit') + # Tree: root(H) -> w1, Pair(H) -> w2, w3 + # Proportional equalize: root.bias=1/3, inner.bias=0.5 + root = q.pairs_root + inner = root.two if isinstance(root.two, Pair) else root.one + self.assertIsInstance(inner, Pair) + + # Skew biases so equalize has something to fix + root.bias = 0.8 + inner.bias = 0.8 + + result = q.layout_action('equalize', (), all_windows) + self.assertTrue(result) + self.assertAlmostEqual(root.bias, 1 / 3, places=5) + self.assertAlmostEqual(inner.bias, 0.5, places=5) + + # Single window — equalize should still succeed + q2 = create_layout(Splits) + aw2 = create_windows(q2, num=0) + q2.add_window(aw2, Window(10)) + result = q2.layout_action('equalize', (), aw2) + self.assertTrue(result) + + def test_splits_equalize_mixed(self): + # One vsplit then three hsplits, each from the freshly added window: + # root(H) -> w1, inner1(V) -> w2, inner2(V) -> w3, inner3(V) -> w4, w5 + # Equalize should give each of w2-w5 an equal share of the right column. + q = create_layout(Splits) + all_windows = create_windows(q, num=0) + q.add_window(all_windows, Window(1)) + q.add_window(all_windows, Window(2), location='vsplit') + q.add_window(all_windows, Window(3), location='hsplit') + q.add_window(all_windows, Window(4), location='hsplit') + q.add_window(all_windows, Window(5), location='hsplit') + + root = q.pairs_root + inner1 = root.two + self.assertIsInstance(inner1, Pair) + inner2 = inner1.two + self.assertIsInstance(inner2, Pair) + inner3 = inner2.two + self.assertIsInstance(inner3, Pair) + + for pair in root.self_and_descendants(): + pair.bias = 0.9 + + result = q.layout_action('equalize', (), all_windows) + self.assertTrue(result) + self.assertAlmostEqual(root.bias, 0.5, places=5) # w1 vs right column: 1:1 + self.assertAlmostEqual(inner1.bias, 1/4, places=5) # w2 vs [w3,w4,w5]: 1:3 + self.assertAlmostEqual(inner2.bias, 1/3, places=5) # w3 vs [w4,w5]: 1:2 + self.assertAlmostEqual(inner3.bias, 0.5, places=5) # w4 vs w5: 1:1 + + def test_splits_equalize_after_remove(self): + # 1 vsplit + 2 hsplits: root(H) -> w1, inner1(V) -> w2, inner2(V) -> w3, w4 + q = create_layout(Splits) + all_windows = create_windows(q, num=0) + w1, w2, w3, w4 = Window(1), Window(2), Window(3), Window(4) + q.add_window(all_windows, w1) + q.add_window(all_windows, w2, location='vsplit') + q.add_window(all_windows, w3, location='hsplit') + q.add_window(all_windows, w4, location='hsplit') + + root = q.pairs_root + inner1 = root.two + inner2 = inner1.two + self.assertIsInstance(inner1, Pair) + self.assertIsInstance(inner2, Pair) + + result = q.layout_action('equalize', (), all_windows) + self.assertTrue(result) + self.assertAlmostEqual(root.bias, 0.5, places=5) # w1 vs right column: 1:1 + self.assertAlmostEqual(inner1.bias, 1/3, places=5) # w2 vs [w3,w4]: 1:2 → RHS in thirds + self.assertAlmostEqual(inner2.bias, 0.5, places=5) # w3 vs w4: 1:1 + + # Remove w4 — inner2 collapses: inner1.two becomes grp_w3 leaf + g4 = all_windows.group_for_window(w4) + q.remove_windows(g4.id) + + self.assertNotIsInstance(inner1.two, Pair) # collapsed to a leaf + + result = q.layout_action('equalize', (), all_windows) + self.assertTrue(result) + self.assertAlmostEqual(root.bias, 0.5, places=5) # w1 vs right column: 1:1 + self.assertAlmostEqual(inner1.bias, 0.5, places=5) # w2 vs w3 top/bottom: 1:1 + + def test_splits_equalize_on_close(self): + q = create_layout(Splits) + q.layout_opts = SplitsLayoutOpts({'equalize_on_close': 'true'}) + all_windows = create_windows(q, num=0) + w1, w2, w3 = Window(1), Window(2), Window(3) + q.add_window(all_windows, w1) + q.add_window(all_windows, w2, location='vsplit') + q.add_window(all_windows, w3, location='vsplit') + + root = q.pairs_root + root.bias = 0.9 + inner = root.two if isinstance(root.two, Pair) else root.one + self.assertIsInstance(inner, Pair) + inner.bias = 0.9 + + g3 = all_windows.group_for_window(w3) + q.remove_windows(g3.id) + + result = q.on_window_removed(all_windows) + self.assertTrue(result) + self.assertAlmostEqual(root.bias, 0.5, places=5) + + # equalize_on_close=false (default) must not trigger equalization + q2 = create_layout(Splits) + aw2 = create_windows(q2, num=0) + q2.add_window(aw2, Window(10)) + q2.add_window(aw2, Window(11), location='vsplit') + q2.pairs_root.bias = 0.9 + result = q2.on_window_removed(aw2) + self.assertFalse(result) + self.assertAlmostEqual(q2.pairs_root.bias, 0.9, places=5) + def test_layout_dimension_no_negative_cells(self): # Regression test for issue #9946: when window padding exceeds the # available space (e.g. after maximize sets a window to minimum width),