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
This commit is contained in:
Mark Stuart
2026-05-29 19:44:05 +01:00
parent 2b7d8af55a
commit 852fc4a662
6 changed files with 179 additions and 5 deletions

View File

@@ -182,6 +182,8 @@ Detailed list of changes
- ``kitten @ set-background-image``: Fix ``--layout=configured`` changing layout to centered instead (:iss:`10089`) - ``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] 0.47.1 [2026-05-28]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -194,6 +194,9 @@ define a few extra key bindings in :file:`kitty.conf`::
# to restore the original layout. # to restore the original layout.
map ctrl+shift+up layout_action maximize vertical 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 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 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 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 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 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 are placed into vertical or horizontal splits when a :option:`--location
<launch --location>` is not specified. A value of ``horizontal`` (same as <launch --location>` is not specified. A value of ``horizontal`` (same as
``--location=vsplit``) means when a new split is created the two windows will ``--location=vsplit``) means when a new split is created the two windows will

View File

@@ -492,6 +492,9 @@ class Layout:
def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None: def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None:
pass pass
def on_window_removed(self, all_windows: WindowList) -> bool:
return False
def layout_state(self) -> dict[str, Any]: def layout_state(self) -> dict[str, Any]:
return {} return {}

View File

@@ -5,6 +5,7 @@ from collections.abc import Collection, Generator, Iterator, Sequence
from typing import Any, Optional, TypedDict, Union from typing import Any, Optional, TypedDict, Union
from kitty.borders import BorderColor 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.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE
from kitty.types import Edges, NeighborsMap, WindowGeometry, WindowMapper, WindowResizeDragData from kitty.types import Edges, NeighborsMap, WindowGeometry, WindowMapper, WindowResizeDragData
from kitty.typing_compat import EdgeLiteral, WindowType 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 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): class SerializedPair(TypedDict, total=False):
horizontal: bool # default to True if absent horizontal: bool # default to True if absent
bias: float # default to 0.5 if absent bias: float # default to 0.5 if absent
@@ -82,6 +89,11 @@ class Pair:
if isinstance(self.two, Pair): if isinstance(self.two, Pair):
yield from self.two.self_and_descendants() 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']: def pair_for_window(self, window_id: int) -> Optional['Pair']:
if self.one == window_id or self.two == window_id: if self.one == window_id or self.two == window_id:
return self return self
@@ -544,6 +556,7 @@ class Pair:
class SplitsLayoutOpts(LayoutOpts): class SplitsLayoutOpts(LayoutOpts):
default_axis_is_horizontal: bool | None = True default_axis_is_horizontal: bool | None = True
equalize_on_close: bool = False
def __init__(self, data: dict[str, str]): def __init__(self, data: dict[str, str]):
q = data.get('split_axis', 'horizontal') q = data.get('split_axis', 'horizontal')
@@ -551,9 +564,10 @@ class SplitsLayoutOpts(LayoutOpts):
self.default_axis_is_horizontal = None self.default_axis_is_horizontal = None
else: else:
self.default_axis_is_horizontal = q == 'horizontal' 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]: 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): class Splits(Layout):
@@ -669,6 +683,20 @@ class Splits(Layout):
pair.bias = 0.5 pair.bias = 0.5
return True 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]: def minimal_borders(self, all_windows: WindowList) -> Iterator[BorderLine]:
groups = tuple(all_windows.iter_all_layoutable_groups()) groups = tuple(all_windows.iter_all_layoutable_groups())
window_count = len(groups) window_count = len(groups)
@@ -850,6 +878,8 @@ class Splits(Layout):
maximized_biases[key] = saved_biases maximized_biases[key] = saved_biases
self._maximized_biases = maximized_biases self._maximized_biases = maximized_biases
return True return True
elif action_name == 'equalize':
return self.equalize_biases()
return None return None

View File

@@ -841,7 +841,10 @@ class Tab: # {{{
def post_window_removal_update(self) -> None: def post_window_removal_update(self) -> None:
self.mark_tab_bar_dirty() 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 active_window = self.active_window
if active_window: if active_window:
self.title_changed(active_window) self.title_changed(active_window)

View File

@@ -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.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE, Region
from kitty.layout.base import layout_dimension, lgd from kitty.layout.base import layout_dimension, lgd
from kitty.layout.interface import Grid, Horizontal, Splits, Stack, Tall 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.types import WindowGeometry
from kitty.window import EdgeWidths from kitty.window import EdgeWidths
from kitty.window_list import WindowList, reset_group_id_counter from kitty.window_list import WindowList, reset_group_id_counter
@@ -325,6 +325,132 @@ class TestLayout(BaseTest):
self.assertTrue(result) self.assertTrue(result)
self.ae(root.bias, root_bias_before) 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): def test_layout_dimension_no_negative_cells(self):
# Regression test for issue #9946: when window padding exceeds the # Regression test for issue #9946: when window padding exceeds the
# available space (e.g. after maximize sets a window to minimum width), # available space (e.g. after maximize sets a window to minimum width),