Implement maximize layout action for splits layout

Fixes #9629
Fixes #9630
This commit is contained in:
copilot-swe-agent[bot] 2026-03-09 09:26:14 +00:00 committed by Kovid Goyal
parent 26cf36dd40
commit 551acca0e4
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
3 changed files with 181 additions and 2 deletions

View file

@ -184,10 +184,24 @@ define a few extra key bindings in :file:`kitty.conf`::
# window's size.
map ctrl+. layout_action bias 80
# Maximize the active window along the horizontal axis (fill full width),
# keeping other windows visible in their vertical positions. Press again to
# restore the original layout.
map ctrl+shift+right layout_action maximize horizontal
# Maximize the active window along the vertical axis (fill full height),
# keeping other windows visible in their horizontal positions. Press again
# to restore the original layout.
map ctrl+shift+up layout_action maximize vertical
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
and swap with an argument of ``270``.
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.
This layout takes one option, ``split_axis`` that controls whether new windows
are placed into vertical or horizontal splits when a :option:`--location

View file

@ -502,6 +502,44 @@ class Pair:
return False
return self.two.is_group_on_second(gid)
def find_window_in_tree(self, window_id: int) -> 'list[tuple[Pair, bool]] | None':
# Returns list of (pair, is_in_one) from self down to the pair containing window_id.
if self.one == window_id:
return [(self, True)]
if self.two == window_id:
return [(self, False)]
if isinstance(self.one, Pair):
path = self.one.find_window_in_tree(window_id)
if path is not None:
return [(self, True)] + path
if isinstance(self.two, Pair):
path = self.two.find_window_in_tree(window_id)
if path is not None:
return [(self, False)] + path
return None
def path_from_root(self, target: 'Pair') -> 'list[str] | None':
if self is target:
return []
if isinstance(self.one, Pair):
sub = self.one.path_from_root(target)
if sub is not None:
return ['one'] + sub
if isinstance(self.two, Pair):
sub = self.two.path_from_root(target)
if sub is not None:
return ['two'] + sub
return None
def pair_at_path(self, path: 'list[str]') -> 'Pair | None':
current: Pair = self
for step in path:
child = current.one if step == 'one' else current.two
if not isinstance(child, Pair):
return None
current = child
return current
class SplitsLayoutOpts(LayoutOpts):
@ -749,6 +787,46 @@ class Splits(Layout):
if pair is not None:
pair.set_bias(wg.id, bias)
return True
elif action_name == 'maximize':
args = args or ('horizontal',)
axis = args[0]
is_horizontal = axis == 'horizontal'
wg = all_windows.active_group
if wg is not None:
key = (wg.id, is_horizontal)
maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = getattr(self, '_maximized_biases', {})
if key in maximized_biases:
# Already maximized along this axis for this window — toggle back
current_pair_ids = {id(p) for p in self.pairs_root.self_and_descendants()}
for pair_ref, saved_bias in maximized_biases.pop(key):
if id(pair_ref) in current_pair_ids:
pair_ref.bias = saved_bias
self._maximized_biases = maximized_biases
return True
else:
# Undo any existing maximize along the same axis (different window)
stale_keys = [k for k in maximized_biases if k[1] == is_horizontal]
if stale_keys:
current_pair_ids = {id(p) for p in self.pairs_root.self_and_descendants()}
for k in stale_keys:
for pair_ref, saved_bias in maximized_biases.pop(k):
if id(pair_ref) in current_pair_ids:
pair_ref.bias = saved_bias
# Maximize: set biases along the path to give maximum space to active window
# Only adjust pairs whose split axis matches the requested direction:
# horizontal maximize expands width (affects horizontal/side-by-side splits),
# vertical maximize expands height (affects vertical/top-bottom splits).
tree_path = self.pairs_root.find_window_in_tree(wg.id)
if tree_path is not None:
saved_biases: list[tuple[Pair, float]] = []
for pair, is_in_one in tree_path:
if pair.horizontal == is_horizontal and not pair.is_redundant:
saved_biases.append((pair, pair.bias))
pair.bias = 1.0 if is_in_one else 0.0
if saved_biases:
maximized_biases[key] = saved_biases
self._maximized_biases = maximized_biases
return True
return None
@ -818,7 +896,25 @@ class Splits(Layout):
return ans
def layout_state(self) -> dict[str, Any]:
return {'pairs': self.pairs_root.serialize()}
ans: dict[str, Any] = {'pairs': self.pairs_root.serialize()}
maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = getattr(self, '_maximized_biases', {})
if maximized_biases:
serialized_maximized = []
for (window_id, is_horizontal), saved_biases_list in maximized_biases.items():
entries = []
for pair_ref, saved_bias in saved_biases_list:
path = self.pairs_root.path_from_root(pair_ref)
if path is not None:
entries.append({'path': path, 'bias': saved_bias})
if entries:
serialized_maximized.append({
'window_id': window_id,
'is_horizontal': is_horizontal,
'saved_biases': entries,
})
if serialized_maximized:
ans['maximized'] = serialized_maximized
return ans
def set_layout_state(self, layout_state: dict[str, Any], map_group_id: WindowMapper) -> bool:
new_root = Pair()
@ -827,5 +923,21 @@ class Splits(Layout):
if before == frozenset(new_root.all_window_ids()):
self.pairs_root = new_root
self.layout_opts = SplitsLayoutOpts(layout_state['opts'])
if 'maximized' in layout_state:
maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = {}
for entry in layout_state['maximized']:
new_window_id = map_group_id(entry['window_id'])
if new_window_id is None:
continue
is_horizontal: bool = entry['is_horizontal']
saved_biases_list: list[tuple[Pair, float]] = []
for saved in entry['saved_biases']:
pair = new_root.pair_at_path(saved['path'])
if pair is not None:
saved_biases_list.append((pair, saved['bias']))
if saved_biases_list:
maximized_biases[(new_window_id, is_horizontal)] = saved_biases_list
if maximized_biases:
self._maximized_biases = maximized_biases
return True
return False

View file

@ -5,6 +5,7 @@ from kitty.config import defaults
from kitty.fast_data_types import Region
from kitty.layout.base import lgd
from kitty.layout.interface import Grid, Horizontal, Splits, Stack, Tall
from kitty.layout.splits import Pair
from kitty.types import WindowGeometry
from kitty.window import EdgeWidths
from kitty.window_list import WindowList, reset_group_id_counter
@ -271,3 +272,55 @@ class TestLayout(BaseTest):
self.ae(q.neighbors_for_window(windows[1], all_windows), {'left': [1], 'bottom': [3, 4]})
self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2]})
self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'top': [2]})
def test_splits_maximize(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='hsplit')
# Layout: w1 | (w2 above w3) — horizontal split at root, vertical split on right
root = q.pairs_root
# root is horizontal, containing w1 and [w2/w3 vertical pair]
self.ae(root.horizontal, True)
right_pair = root.two if isinstance(root.two, Pair) else root.one
self.assertIsInstance(right_pair, Pair)
# Focus window 3 (bottom-right)
all_windows.set_active_group_idx(all_windows.groups.index(all_windows.group_for_window(w3)))
# Save original biases
root_bias_before = root.bias
right_pair_bias_before = right_pair.bias
# maximize vertical (fill full height) — affects vertical (horizontal==False) pairs
result = q.layout_action('maximize', ('vertical',), all_windows)
self.assertTrue(result)
# right_pair is vertical (horizontal==False) so its bias should be 0.0 (w3 is in 'two')
self.ae(right_pair.bias, 0.0)
# root is horizontal so its bias should be unchanged
self.ae(root.bias, root_bias_before)
# _maximized_biases should track w3's vertical maximize
self.assertIn((all_windows.active_group.id, False), q._maximized_biases)
# Toggle back
result = q.layout_action('maximize', ('vertical',), all_windows)
self.assertTrue(result)
self.ae(right_pair.bias, right_pair_bias_before)
self.ae(getattr(q, '_maximized_biases', {}), {})
# maximize horizontal (fill full width) — affects horizontal pairs
result = q.layout_action('maximize', ('horizontal',), all_windows)
self.assertTrue(result)
# root is horizontal, w3 is under root.two (right side), so bias should be 0.0
self.ae(root.bias, 0.0)
# right_pair is vertical, so unchanged
self.ae(right_pair.bias, right_pair_bias_before)
# Toggle back
result = q.layout_action('maximize', ('horizontal',), all_windows)
self.assertTrue(result)
self.ae(root.bias, root_bias_before)