mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-06 01:05:48 +02:00
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:
@@ -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]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user