#!/usr/bin/env python # License: GPL v3 Copyright: 2018, Kovid Goyal 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, SplitsLayoutOpts from kitty.types import WindowGeometry from kitty.window import EdgeWidths from kitty.window_list import WindowList, reset_group_id_counter from . import BaseTest class Window: def __init__(self, win_id, overlay_for=None, overlay_window_id=None): self.id = win_id self.overlay_for = overlay_for self.overlay_window_id = overlay_window_id self.is_visible_in_layout = True self.geometry = WindowGeometry(0, 0, 0, 0, 0, 0) self.padding = EdgeWidths() self.margin = EdgeWidths() self.focused = False def focus_changed(self, focused): self.focused = focused def effective_border(self): return 1 def effective_padding(self, edge): return 1 def effective_margin(self, edge): return 1 def set_visible_in_layout(self, val): self.is_visible_in_layout = bool(val) def set_geometry(self, geometry): self.geometry = geometry def create_layout(cls, opts=None, border_width=2): if opts is None: opts = defaults ans = cls(1, 1) ans.set_active_window_in_os_window = lambda idx: None ans.swap_windows_in_os_window = lambda a, b: None orig = ans._set_dimensions def set_dimensions(all_windows): orig(all_windows) # we need a non-zero width and height for central lgd.central = Region((0, 0, 0, 0, 1, 1)) ans._set_dimensions = set_dimensions return ans class Tab: def active_window_changed(self): self.current_layout.update_visibility(self.windows) def create_windows(layout, num=5): t = Tab() t.current_layout = layout t.windows = ans = WindowList(t) ans.tab_mem = t reset_group_id_counter() for i in range(num): ans.add_window(Window(i + 1)) ans.set_active_group_idx(0) return ans def utils(self, q, windows): def ids(): return [w.id for w in windows.groups] def visible_ids(): return {gr.id for gr in windows.groups if gr.is_visible_in_layout} def expect_ids(*a): self.assertEqual(tuple(ids()), a) def check_visible(): if q.only_active_window_visible: self.ae(visible_ids(), {windows.active_group.id}) else: self.ae(visible_ids(), {gr.id for gr in windows.groups}) return ids, visible_ids, expect_ids, check_visible class TestLayout(BaseTest): def setUp(self): super().setUp() self.set_options({'tab_bar_style': 'hidden'}) def do_ops_test(self, q): windows = create_windows(q) ids, visible_ids, expect_ids, check_visible = utils(self, q, windows) # Test layout q(windows) self.ae(windows.active_group_idx, 0) expect_ids(*range(1, len(windows)+1)) check_visible() # Test nth_window for i in range(windows.num_groups): q.activate_nth_window(windows, i) self.ae(windows.active_group_idx, i) expect_ids(*range(1, len(windows)+1)) check_visible() # Test next_window for i in range(2 * windows.num_groups): expected = (windows.active_group_idx + 1) % windows.num_groups q.next_window(windows) self.ae(windows.active_group_idx, expected) expect_ids(*range(1, len(windows)+1)) check_visible() # Test move_window windows.set_active_group_idx(0) expect_ids(1, 2, 3, 4, 5) q.move_window(windows, 3) self.ae(windows.active_group_idx, 3) expect_ids(4, 2, 3, 1, 5) check_visible() windows.set_active_group_idx(0) q.move_window(windows, 3) expect_ids(*range(1, len(windows)+1)) check_visible() # Test add_window windows.set_active_group_idx(4) q.add_window(windows, Window(6)) self.ae(windows.num_groups, 6) self.ae(windows.active_group_idx, 5) expect_ids(*range(1, windows.num_groups+1)) check_visible() # Test remove_window prev_window = windows.active_window windows.set_active_group_idx(3) self.ae(windows.active_group_idx, 3) windows.remove_window(windows.active_window) self.ae(windows.active_window, prev_window) check_visible() expect_ids(1, 2, 3, 5, 6) windows.set_active_group_idx(0) to_remove = windows.active_window windows.set_active_group_idx(3) windows.remove_window(to_remove) self.ae(windows.active_group_idx, 3) check_visible() expect_ids(2, 3, 5, 6) # Test set_active_window for i in range(windows.num_groups): windows.set_active_group_idx(i) self.ae(i, windows.active_group_idx) check_visible() # Test def do_overlay_test(self, q): windows = create_windows(q) ids, visible_ids, expect_ids, check_visible = utils(self, q, windows) # Test add_window w = Window(len(windows) + 1) before = windows.active_group_idx overlaid_group = before overlay_window_id = w.id windows.add_window(w, group_of=windows.active_window) self.ae(before, windows.active_group_idx) self.ae(w, windows.active_window) expect_ids(1, 2, 3, 4, 5) check_visible() # Test layout q(windows) expect_ids(1, 2, 3, 4, 5) check_visible() w = Window(len(windows) + 1) windows.add_window(w) expect_ids(1, 2, 3, 4, 5, 6) self.ae(windows.active_group_idx, windows.num_groups - 1) # Test nth_window for i in range(windows.num_groups): q.activate_nth_window(windows, i) self.ae(windows.active_group_idx, i) if i == overlaid_group: self.ae(windows.active_window.id, overlay_window_id) expect_ids(1, 2, 3, 4, 5, 6) check_visible() # Test next_window for i in range(windows.num_groups): expected = (windows.active_group_idx + 1) % windows.num_groups q.next_window(windows) self.ae(windows.active_group_idx, expected) expect_ids(1, 2, 3, 4, 5, 6) check_visible() # Test move_window windows.set_active_group_idx(overlaid_group) expect_ids(1, 2, 3, 4, 5, 6) q.move_window(windows, 3) self.ae(windows.active_group_idx, 3) self.ae(windows.active_window.id, overlay_window_id) expect_ids(4, 2, 3, 1, 5, 6) check_visible() windows.set_active_group_idx(0) q.move_window(windows, 3) expect_ids(1, 2, 3, 4, 5, 6) check_visible() # Test set_active_window for i in range(windows.num_groups): windows.set_active_group_idx(i) self.ae(i, windows.active_group_idx) if i == overlaid_group: self.ae(windows.active_window.id, overlay_window_id) check_visible() # Test remove_window expect_ids(1, 2, 3, 4, 5, 6) windows.set_active_group_idx(overlaid_group) windows.remove_window(overlay_window_id) self.ae(windows.active_group_idx, overlaid_group) self.ae(windows.active_window.id, 1) expect_ids(1, 2, 3, 4, 5, 6) check_visible() def test_layout_operations(self): for layout_class in (Stack, Horizontal, Tall, Grid): q = create_layout(layout_class) self.do_ops_test(q) def test_overlay_layout_operations(self): for layout_class in (Stack, Horizontal, Tall, Grid): q = create_layout(layout_class) self.do_overlay_test(q) def test_splits(self): q = create_layout(Splits) all_windows = create_windows(q, num=0) q.add_window(all_windows, Window(1)) self.ae(all_windows.active_group_idx, 0) q.add_window(all_windows, Window(2), location='vsplit') self.ae(all_windows.active_group_idx, 1) q(all_windows) self.ae(q.pairs_root.pair_for_window(2).horizontal, True) q.add_window(all_windows, Window(3), location='hsplit') self.ae(q.pairs_root.pair_for_window(2).horizontal, False) q.add_window(all_windows, Window(4), location='vsplit') windows = list(all_windows) windows[0].set_geometry(WindowGeometry(0, 0, 10, 20, 0, 0)) windows[1].set_geometry(WindowGeometry(11, 0, 20, 10, 0, 0)) windows[2].set_geometry(WindowGeometry(11, 11, 15, 20, 0, 0)) windows[3].set_geometry(WindowGeometry(16, 11, 20, 20, 0, 0)) self.ae(q.neighbors_for_window(windows[0], all_windows), {'right': [2, 3]}) 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) 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), # layout_dimension must not produce a negative cells_per_window value # which would cause right < left in the resulting window geometry. for length, cell_length, decs in ( (8, 8, [(5, 5)]), # padding (10) > length (8) > cell_length (8) (6, 8, [(4, 4)]), # length < cell_length (0, 8, [(4, 4)]), # zero length (4, 8, [(3, 3)]), # space_needed == length, no room for cells ): result = next(layout_dimension(0, length, cell_length, decs)) self.assertGreaterEqual(result.cells_per_window, 0, f'cells_per_window={result.cells_per_window} < 0 for length={length}, ' f'cell_length={cell_length}, decs={decs}') self.assertGreaterEqual(result.content_size, 0, f'content_size={result.content_size} < 0 for length={length}, ' f'cell_length={cell_length}, decs={decs}') # content_pos must be within [0, length]: right edge = content_pos + content_size <= length self.assertGreaterEqual(result.content_pos, 0) self.assertLessEqual(result.content_pos + result.content_size, length, f'right ({result.content_pos + result.content_size}) > length ({length}) for ' f'cell_length={cell_length}, decs={decs}') def test_drag_resize_target_windows(self): # Helper: call drag_resize_target_windows with given window and edge flags. def drtw(q, all_windows, window, edges): return q.drag_resize_target_windows(window, 0, 0, edges, all_windows) # --- 2-window horizontal split: A | B --- q = create_layout(Splits) all_windows = create_windows(q, num=0) wA = Window(1) q.add_window(all_windows, wA) wB = Window(2) q.add_window(all_windows, wB, location='vsplit') q(all_windows) root = q.pairs_root self.ae(root.horizontal, True) root_id = id(root) # Right edge of A (left of divider): divider belongs to root, A is on one-side d = drtw(q, all_windows, wA, RIGHT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # Left edge of B (right of divider): same divider, same direction d = drtw(q, all_windows, wB, LEFT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # Right edge of B (outer border): direction reversed d = drtw(q, all_windows, wB, RIGHT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, False) # --- 2-window vertical split: A / B --- q = create_layout(Splits) all_windows = create_windows(q, num=0) wA = Window(1) q.add_window(all_windows, wA) wB = Window(2) q.add_window(all_windows, wB, location='hsplit') q(all_windows) root = q.pairs_root self.ae(root.horizontal, False) root_id = id(root) # Bottom edge of A: divider belongs to root, A is in one-side d = drtw(q, all_windows, wA, BOTTOM_EDGE) self.ae(d.vertical_id, root_id) self.ae(d.height_increases_downwards, True) # Top edge of B: same divider d = drtw(q, all_windows, wB, TOP_EDGE) self.ae(d.vertical_id, root_id) self.ae(d.height_increases_downwards, True) # Bottom edge of B (outer border): direction reversed d = drtw(q, all_windows, wB, BOTTOM_EDGE) self.ae(d.vertical_id, root_id) self.ae(d.height_increases_downwards, False) # --- 3-window layout: top_pair(A | B) / C --- # root(vertical) -> one: top_pair(horizontal, one=A, two=B), two: C q = create_layout(Splits) all_windows = create_windows(q, num=0) wA = Window(1) q.add_window(all_windows, wA) wC = Window(3) q.add_window(all_windows, wC, location='hsplit') # C below A (vertical root) all_windows.set_active_window_group_for(wA) wB = Window(2) q.add_window(all_windows, wB, location='vsplit') # B right of A (horizontal top_pair) q(all_windows) root = q.pairs_root self.ae(root.horizontal, False) top_pair = root.one self.assertIsInstance(top_pair, Pair) self.ae(top_pair.horizontal, True) root_id = id(root) top_pair_id = id(top_pair) # Divider between A and B: belongs to top_pair d = drtw(q, all_windows, wA, RIGHT_EDGE) self.ae(d.horizontal_id, top_pair_id) self.ae(d.width_increases_rightwards, True) d = drtw(q, all_windows, wB, LEFT_EDGE) self.ae(d.horizontal_id, top_pair_id) self.ae(d.width_increases_rightwards, True) # Divider between top_pair and C: belongs to root d = drtw(q, all_windows, wA, BOTTOM_EDGE) self.ae(d.vertical_id, root_id) self.ae(d.height_increases_downwards, True) d = drtw(q, all_windows, wB, BOTTOM_EDGE) self.ae(d.vertical_id, root_id) self.ae(d.height_increases_downwards, True) d = drtw(q, all_windows, wC, TOP_EDGE) self.ae(d.vertical_id, root_id) self.ae(d.height_increases_downwards, True) # --- 3-window layout: A | right_pair(B / C) --- # root(horizontal) -> one: A, two: right_pair(vertical, one=B, two=C) q = create_layout(Splits) all_windows = create_windows(q, num=0) wA = Window(1) q.add_window(all_windows, wA) wB = Window(2) q.add_window(all_windows, wB, location='vsplit') # B right of A (horizontal root) wC = Window(3) q.add_window(all_windows, wC, location='hsplit') # C below B (vertical right_pair) q(all_windows) root = q.pairs_root self.ae(root.horizontal, True) right_pair = root.two self.assertIsInstance(right_pair, Pair) self.ae(right_pair.horizontal, False) root_id = id(root) right_pair_id = id(right_pair) # Divider between A and right_pair: A at RIGHT_EDGE -> root d = drtw(q, all_windows, wA, RIGHT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # B at LEFT_EDGE: B is on the leading side of right_pair, border belongs to root d = drtw(q, all_windows, wB, LEFT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # Divider between B and C: belongs to right_pair d = drtw(q, all_windows, wB, BOTTOM_EDGE) self.ae(d.vertical_id, right_pair_id) self.ae(d.height_increases_downwards, True) d = drtw(q, all_windows, wC, TOP_EDGE) self.ae(d.vertical_id, right_pair_id) self.ae(d.height_increases_downwards, True) # --- 4-window layout (bug scenario): left_pair(A/C) | right_pair(B/D) --- # root(horizontal) -> one: left_pair(vertical, one=A, two=C), # two: right_pair(vertical, one=B, two=D) q = create_layout(Splits) all_windows = create_windows(q, num=0) wA = Window(1) q.add_window(all_windows, wA) wB = Window(2) q.add_window(all_windows, wB, location='vsplit') # B right of A all_windows.set_active_window_group_for(wA) wC = Window(3) q.add_window(all_windows, wC, location='hsplit') # C below A all_windows.set_active_window_group_for(wB) wD = Window(4) q.add_window(all_windows, wD, location='hsplit') # D below B q(all_windows) root = q.pairs_root self.ae(root.horizontal, True) left_pair = root.one right_pair = root.two self.assertIsInstance(left_pair, Pair) self.assertIsInstance(right_pair, Pair) self.ae(left_pair.horizontal, False) self.ae(right_pair.horizontal, False) root_id = id(root) left_pair_id = id(left_pair) right_pair_id = id(right_pair) # Bug #1: A at RIGHT_EDGE should give root with correct (rightward) direction d = drtw(q, all_windows, wA, RIGHT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # Bug #2: B at LEFT_EDGE should find root and give correct direction d = drtw(q, all_windows, wB, LEFT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # C at RIGHT_EDGE: same divider between left_pair and right_pair d = drtw(q, all_windows, wC, RIGHT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # D at LEFT_EDGE: same divider d = drtw(q, all_windows, wD, LEFT_EDGE) self.ae(d.horizontal_id, root_id) self.ae(d.width_increases_rightwards, True) # Vertical divider within left_pair (between A and C) d = drtw(q, all_windows, wA, BOTTOM_EDGE) self.ae(d.vertical_id, left_pair_id) self.ae(d.height_increases_downwards, True) d = drtw(q, all_windows, wC, TOP_EDGE) self.ae(d.vertical_id, left_pair_id) self.ae(d.height_increases_downwards, True) # Vertical divider within right_pair (between B and D) d = drtw(q, all_windows, wB, BOTTOM_EDGE) self.ae(d.vertical_id, right_pair_id) self.ae(d.height_increases_downwards, True) d = drtw(q, all_windows, wD, TOP_EDGE) self.ae(d.vertical_id, right_pair_id) self.ae(d.height_increases_downwards, True)