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`)
- 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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -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
<launch --location>` is not specified. A value of ``horizontal`` (same as
``--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:
pass
def on_window_removed(self, all_windows: WindowList) -> bool:
return False
def layout_state(self) -> dict[str, Any]:
return {}

View File

@@ -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

View File

@@ -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)

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.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),