diff --git a/docs/layouts.rst b/docs/layouts.rst index aa41d4da6..b33feae0d 100644 --- a/docs/layouts.rst +++ b/docs/layouts.rst @@ -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 diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index e331c0a73..f99ba70ab 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -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 diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 3f045c5c3..ad2c45f12 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -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)