diff --git a/docs/changelog.rst b/docs/changelog.rst index 9e629b3fe..2715f723a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -199,17 +199,15 @@ you use a decent Wayland compositor. Detailed list of changes ------------------------------------- -Unreleased +0.50.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Splits layout: Dragging a divider now resizes only its adjacent regions, consistently after rearranging windows. Fix nested dividers not tracking mouse movement correctly, including when returning from a minimum size. -- Custom shaders: Fix starting a second kitty instance failing to build - shaders while another instance is building them, or after a previous - instance was killed while building them. Now instances wait for each other - to finish instead. +- Splits layout: Fix windows in a nested split overlapping their neighbour by a + couple of pixels when the split is resized down to its minimum size. 0.49.0 [2026-09-21] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/boss.py b/kitty/boss.py index bd83f23db..071f5ee13 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2782,12 +2782,6 @@ class Boss: if w is not None and tab is not None: tab.new_special_window(self.create_special_window_for_show_error(title, msg, w.id), copy_colors_from=w) - def show_custom_shader_errors(self) -> None: - errors = load_shader_programs.custom_shader_errors - if errors: - load_shader_programs.custom_shader_errors = [] - self.show_error(_('Failed to load custom shaders'), '\n\n'.join(errors)) - @ac('mk', 'Create a new marker') def create_marker(self) -> None: w = self.window_for_dispatch or self.active_window @@ -2883,6 +2877,13 @@ class Boss: return False def drag_resize_update(self, x: float, y: float) -> None: + # Truncate towards zero rather than flooring, so that the pointer has to + # travel a full cell away from where the drag started before anything moves + # and coming back to the start always restores the original layout exactly. + # last_step_* counts the cells actually applied, which is not necessarily the + # number requested, since the layout stops at minimum sizes. Accumulating the + # applied amount keeps the divider locked to the pointer when it comes back + # out of a minimum, instead of leaving it lagging by however much was refused. if not (r := self.drag_resize_of_window) or not (tab := self.tab_for_id(r.tab_id)): return if (h := r.data.horizontal_id) is not None: @@ -2890,14 +2891,14 @@ class Boss: step_x = int((x - r.initial_x) / r.cell_width) * mult dx = step_x - r.last_step_x if dx != 0: - if applied := tab.drag_resize_window(h, float(dx), True): + if applied := tab.drag_resize_window(h, dx, True): self.drag_resize_of_window = r = r._replace(last_step_x=r.last_step_x + applied) if (v := r.data.vertical_id) is not None: mult = 1 if r.data.height_increases_downwards else -1 step_y = int((y - r.initial_y) / r.cell_height) * mult dy = step_y - r.last_step_y if dy != 0: - if applied := tab.drag_resize_window(v, float(dy), False): + if applied := tab.drag_resize_window(v, dy, False): self.drag_resize_of_window = r._replace(last_step_y=r.last_step_y + applied) def drag_resize_end(self) -> None: @@ -3505,7 +3506,6 @@ class Boss: w.report_color_scheme_preference_if_wanted() w.refresh(reload_all_gpu_data=True) load_shader_programs.recompile_if_needed() - self.show_custom_shader_errors() @ac( 'misc', diff --git a/kitty/layout/base.py b/kitty/layout/base.py index 82cfd0794..5aec09217 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -325,7 +325,13 @@ class Layout: return self.apply_bias(idx, increment, all_windows, is_horizontal) def drag_resize_window(self, all_windows: WindowList, window_id: int, increment: float, is_horizontal: bool = True) -> float: - """Resize by a number of cells, returning the number actually applied.""" + """Resize by a number of cells, returning the number of cells actually applied. + + Layouts that can apply an increment only partially should override this and + report what they really did, so that the caller can keep the dragged divider + locked to the pointer. This generic implementation has no way to tell how much + apply_bias() clamped, so it reports all or nothing. + """ increment_as_percent = self.bias_increment_for_cell(all_windows, is_horizontal) * increment return increment if self.modify_size_of_window(all_windows, window_id, increment_as_percent, is_horizontal) else 0.0 diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 180660891..e43e03024 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -335,9 +335,9 @@ class Pair: min_w2 = self.two.minimum_width(id_window_map) if isinstance(self.two, Pair) else lgd.cell_width w1 = max(min_w1, int(self.bias * width) - bw) w2 = width - w1 - bw2 - if w2 < min_w2 and w1 >= min_w1 + bw2: + if w2 < min_w2 and width - min_w2 - bw2 >= min_w1: w2 = min_w2 - w1 = width - w2 + w1 = width - w2 - bw2 bleft = left + w1 self.first_extent = Edges(left, top, left + w1, top + height) if isinstance(self.one, Pair): @@ -373,9 +373,9 @@ class Pair: min_h2 = self.two.minimum_height(id_window_map) if isinstance(self.two, Pair) else lgd.cell_height h1 = max(min_h1, int(self.bias * height) - bw) h2 = height - h1 - bw2 - if h2 < min_h2 and h1 >= min_h1 + bw2: + if h2 < min_h2 and height - min_h2 - bw2 >= min_h1: h2 = min_h2 - h1 = height - h2 + h1 = height - h2 - bw2 btop = top + h1 self.first_extent = Edges(left, top, left + width, top + h1) if isinstance(self.one, Pair): @@ -408,7 +408,12 @@ class Pair: self.apply_window_geometry(self.two, geom, id_window_map, layout_object) def move_divider(self, pixels: int) -> int: - """Resize adjacent same-axis units, keeping the other dividers in place.""" + """Move this divider, keeping every other divider along the same axis in place. + + Only the two units adjacent to this divider change size. A unit that is a + perpendicular subtree is resized as a whole, so dividers nested inside it + that happen to run along this axis do move, proportionally. + """ if self.is_redundant or not pixels or self.width <= 0 or self.height <= 0: return 0 horizontal = self.horizontal @@ -1035,7 +1040,9 @@ class Splits(Layout): def drag_resize_window(self, all_windows: WindowList, window_id: int, increment: float, is_horizontal: bool = True) -> float: self._set_dimensions(all_windows) for pair in self.pairs_root.self_and_descendants(): - if id(pair) == window_id and pair.horizontal == is_horizontal: + if id(pair) == window_id: + if pair.horizontal != is_horizontal: + break cell = lgd.cell_width if is_horizontal else lgd.cell_height return pair.move_divider(round(increment * cell)) / cell if cell > 0 else 0.0 return 0.0 diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 5a81b8ff6..81ad10101 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -883,25 +883,50 @@ class TestLayout(BaseTest): self.ae(d.height_increases_downwards, True) -class TestSplitBorderResize(BaseTest): +class BaseSplitGeometryTest(BaseTest): + # These tests stub out Layout._set_dimensions, so they have to put back the lgd + # singleton, which is global state shared with every other layout test. def setUp(self): super().setUp() self.set_options({'tab_bar_style': 'hidden'}) saved = vars(lgd).copy() self.addCleanup(lambda: (vars(lgd).clear(), vars(lgd).update(saved))) + def stub_dimensions(self, layout, central, minimal=True): + def dimensions(all_windows): + lgd.central = central + lgd.cell_width, lgd.cell_height = 10, 20 + lgd.draw_minimal_borders = minimal + + layout._set_dimensions = dimensions + + def check_extents_fit(self, layout): + # Every pair must divide its area exactly between its two halves and the + # borders between them, otherwise windows overlap their neighbours. + for p in layout.pairs_root.self_and_descendants(): + if p.is_redundant: + continue + with self.subTest(pair=repr(p)): + if p.horizontal: + one = p.first_extent.right - p.first_extent.left + two = p.second_extent.right - p.second_extent.left + self.ae(one + two + 2 * p.border_width, p.width) + self.ae(p.second_extent.right, p.left + p.width) + else: + one = p.first_extent.bottom - p.first_extent.top + two = p.second_extent.bottom - p.second_extent.top + self.ae(one + two + 2 * p.border_width, p.height) + self.ae(p.second_extent.bottom, p.top + p.height) + + +class TestSplitBorderResize(BaseSplitGeometryTest): def make_layout(self, shape): layout = create_layout(Splits) windows = create_windows(layout, num=0) for i in range(1, 5): layout.add_window(windows, Window(i)) layout.pairs_root.unserialize(shape, lambda x: x) - - def dimensions(all_windows): - lgd.central = Region((0, 0, 1499, 1099, 1500, 1100)) - lgd.cell_width, lgd.cell_height = 10, 20 - - layout._set_dimensions = dimensions + self.stub_dimensions(layout, Region((0, 0, 1499, 1099, 1500, 1100))) layout(windows) return layout, windows @@ -1007,29 +1032,17 @@ class TestSplitBorderResize(BaseTest): self.assertIsNone(data.vertical_id) -class TestSplitDragGeometry(BaseTest): - def setUp(self): - super().setUp() - self.set_options({'tab_bar_style': 'hidden'}) - saved = vars(lgd).copy() - self.addCleanup(lambda: (vars(lgd).clear(), vars(lgd).update(saved))) - - def make_layout(self, shape, minimal=True): +class TestSplitDragGeometry(BaseSplitGeometryTest): + def make_layout(self, shape, minimal=True, num=4): from types import SimpleNamespace from kitty.tabs import Tab as RealTab layout = create_layout(Splits) layout.layout_opts = SplitsLayoutOpts({'proportional': 'yes'}) - windows = create_windows(layout, num=4) + windows = create_windows(layout, num=num) layout.pairs_root.unserialize(shape, lambda x: x) - - def dimensions(all_windows): - lgd.central = Region((19, 47, 1518, 1146, 1500, 1100)) - lgd.cell_width, lgd.cell_height = 10, 20 - lgd.draw_minimal_borders = minimal - - layout._set_dimensions = dimensions + self.stub_dimensions(layout, Region((19, 47, 1518, 1146, 1500, 1100)), minimal) tab = SimpleNamespace(current_layout=layout, windows=windows, relayout=lambda: layout(windows)) tab.drag_resize_window = lambda *args: RealTab.drag_resize_window(tab, *args) tab.relayout() @@ -1042,17 +1055,44 @@ class TestSplitDragGeometry(BaseTest): if not p.is_redundant } + def fixed_dividers(self, pair): + # The dividers move_divider() promises to leave alone: every divider outside + # the dragged pair's region, plus the same-axis dividers inside it. Same-axis + # dividers nested inside a perpendicular unit are excluded, that unit is + # resized as a whole so they scale with it. + inside = set() + + def walk(p): + if not isinstance(p, Pair): + return + if p.is_redundant: + return walk(p.one or p.two) + if p.horizontal != pair.horizontal: + return + inside.add(id(p)) + walk(p.one) + walk(p.two) + + walk(pair) + descendants = {id(p) for p in pair.self_and_descendants()} + return lambda pid: pid not in descendants or pid in inside + def check_drags(self, layout, tab): for pair in layout.pairs_root.self_and_descendants(): if pair.is_redundant: continue cell = lgd.cell_width if pair.horizontal else lgd.cell_height + is_fixed = self.fixed_dividers(pair) for steps in (3, -5, 2): before = self.positions(layout) self.assertTrue(tab.drag_resize_window(id(pair), steps, pair.horizontal)) after = self.positions(layout) + self.check_extents_fit(layout) for pid, position in before.items(): - self.ae(after[pid] - position, steps * cell if pid == id(pair) else 0) + if pid == id(pair): + self.ae(after[pid] - position, steps * cell) + elif is_fixed(pid): + self.ae(after[pid], position) def test_split_drag_adjacent_geometry(self): shapes = ( @@ -1061,6 +1101,9 @@ class TestSplitDragGeometry(BaseTest): {'one': {'one': {'one': 1, 'two': 2}, 'two': 3}, 'two': 4}, {'horizontal': False, 'bias': 0.35, 'one': 4, 'two': {'one': 1, 'two': {'one': 2, 'two': 3}}}, {'one': 1, 'two': {'one': {'horizontal': False, 'one': 2, 'two': 3}, 'two': 4}}, + # same-axis dividers nested inside a perpendicular unit + {'one': 1, 'two': {'horizontal': False, 'one': {'bias': 0.6, 'one': 2, 'two': 3}, 'two': 4}}, + {'horizontal': False, 'bias': 0.5, 'one': {'one': {'horizontal': False, 'bias': 0.6, 'one': 1, 'two': 2}, 'two': 3}, 'two': 4}, ) def transpose(node): @@ -1103,6 +1146,20 @@ class TestSplitDragGeometry(BaseTest): self.assertTrue(tab.drag_resize_window(id(root), -1, True)) self.ae(self.positions(layout)[id(root)], after[id(root)] - lgd.cell_width) + def test_split_drag_nested_minimum_no_overlap(self): + # Dragging a divider until a nested perpendicular unit is squeezed to its + # minimum must not leave that unit's halves overflowing the area they were + # given, which would overlap them with the neighbouring window. + shape = {'horizontal': False, 'bias': 0.5, 'one': {'one': {'horizontal': False, 'bias': 0.6, 'one': 1, 'two': 2}, 'two': 3}, 'two': 4} + for minimal in (True, False): + with self.subTest(minimal=minimal): + layout, windows, tab = self.make_layout(shape, minimal) + root = layout.pairs_root + tab.drag_resize_window(id(root), -1000, False) + self.check_extents_fit(layout) + inner = root.one.one + self.ae(inner.second_extent.bottom, inner.top + inner.height) + def test_split_drag_corner_and_subcell_motion(self): from types import SimpleNamespace diff --git a/kitty_tests/ssh.py b/kitty_tests/ssh.py index 649e40343..484f9418d 100644 --- a/kitty_tests/ssh.py +++ b/kitty_tests/ssh.py @@ -167,10 +167,7 @@ env COLORTERM # coreutils >= 9.12 has env(1) shell-quote values written to a terminal, # so accept both the raw and the shell-quoted form of the value. expected_tset = tset.replace('$A', 'AAA') - pty.wait_till(lambda: ( - f'TSET={expected_tset}' in pty.screen_contents() - or f'TSET={shlex.quote(expected_tset)}' in pty.screen_contents() - )) + pty.wait_till(lambda: f'TSET={expected_tset}' in pty.screen_contents() or f'TSET={shlex.quote(expected_tset)}' in pty.screen_contents()) self.assertNotIn('COLORTERM', pty.screen_contents()) pty.wait_till(lambda: '/cwd' in pty.screen_contents()) self.assertTrue(pty.is_echo_on())