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
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. # window's size.
map ctrl+. layout_action bias 80 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 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
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 This layout takes one option, ``split_axis`` that 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

View File

@@ -502,6 +502,44 @@ class Pair:
return False return False
return self.two.is_group_on_second(gid) 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): class SplitsLayoutOpts(LayoutOpts):
@@ -749,6 +787,46 @@ class Splits(Layout):
if pair is not None: if pair is not None:
pair.set_bias(wg.id, bias) pair.set_bias(wg.id, bias)
return True 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 return None
@@ -818,7 +896,25 @@ class Splits(Layout):
return ans return ans
def layout_state(self) -> dict[str, Any]: 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: def set_layout_state(self, layout_state: dict[str, Any], map_group_id: WindowMapper) -> bool:
new_root = Pair() new_root = Pair()
@@ -827,5 +923,21 @@ class Splits(Layout):
if before == frozenset(new_root.all_window_ids()): if before == frozenset(new_root.all_window_ids()):
self.pairs_root = new_root self.pairs_root = new_root
self.layout_opts = SplitsLayoutOpts(layout_state['opts']) 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 True
return False return False

View File

@@ -5,6 +5,7 @@ from kitty.config import defaults
from kitty.fast_data_types import Region from kitty.fast_data_types import Region
from kitty.layout.base import lgd from kitty.layout.base import 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.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
@@ -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[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[2], all_windows), {'left': [1], 'right': [4], 'top': [2]})
self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], '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)