diff --git a/docs/changelog.rst b/docs/changelog.rst index bbab21d3b..65393cab2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -202,6 +202,10 @@ Detailed list of changes 0.49.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Allow dropping a tab containing a single window into another tab's layout, + using the existing window drop preview and preserving running programs. + See :doc:`overview` for details. + - Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`) - Various throughput performance improvements for a 15-35% real world improvement depending on workload diff --git a/docs/overview.rst b/docs/overview.rst index 5273f8e23..ec5e5a2ec 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -183,7 +183,13 @@ in your browser, etc. For details, see :doc:`here `. Additionally, various bits of the kitty UI itself work with the mouse. You can drag and drop tabs in the tab bar to re-order them or move them from one OS -Window to another, or even pop them out into a new OS Window. +Window to another, or even pop them out into a new OS Window. A tab containing a +single window can also be dropped into the content area of another tab to insert +its window at the highlighted edge. In the Splits layout, all four edges are +available. The window and any overlays move together, keeping running programs +alive; the empty source tab is removed. This also works between OS Windows +belonging to the same kitty process. Tabs containing multiple windows keep their +existing tab-moving behavior. You can drag window borders to resize windows. You can double click on empty regions of the tab bar to create new tabs or double click on an existing tab to rename it. diff --git a/kitty/boss.py b/kitty/boss.py index d2068273e..075667bf3 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2149,8 +2149,19 @@ class Boss: in_tab_bar = tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom detach = not in_tab_bar or tab.os_window_id != tm.os_window_id or is_leave change_drag_thumbnail(tab.os_window_id, 1 if detach else 0) + in_content = central.left <= x < central.right and central.top <= y < central.bottom + window = tab.active_window + merge_window = ( + window is not None + and tab.windows.num_groups == 1 + and tm.active_tab is not None + and tm.active_tab is not tab + and in_content + and not is_leave + ) for q in self.all_tab_managers: - is_dest = q is tm and (in_tab_bar or os_window_id != tab.os_window_id) and not is_leave + q.on_window_drop_move(window.id if merge_window and window is not None else 0, merge_window and q is tm, x, y) + is_dest = not merge_window and q is tm and (in_tab_bar or os_window_id != tab.os_window_id) and not is_leave q.on_tab_drop_move(tab_id, is_dest, x, y) window_id, drag_started = get_window_being_dragged()[:2] if window_id and drag_started: @@ -2180,12 +2191,17 @@ class Boss: if (tidb := drop.get(f'application/net.kovidgoyal.kitty-tab-{os.getpid()}')) and (tab := self.tab_for_id(int(tidb))): central, tab_bar = viewport_for_window(os_window_id)[:2] in_tab_bar = tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom - if in_tab_bar or tab.os_window_id != tm.os_window_id: + in_content = central.left <= x < central.right and central.top <= y < central.bottom + window = tab.active_window + if in_content and window is not None and tab.windows.num_groups == 1 and tm.active_tab is not None and tm.active_tab is not tab: + tm.on_window_drop(x, y, window.id) + elif in_tab_bar or tab.os_window_id != tm.os_window_id: tm.on_tab_drop(x, y) else: self._move_tab_to(tab) set_tab_being_dragged() for tm in self.all_tab_managers: + tm.on_window_drop_move() tm.on_tab_drop_move() tm.layout_tab_bar() # ensure tab bar is fully updated return @@ -2245,15 +2261,37 @@ class Boss: return if (tab_id := int((data or {}).get(f'application/net.kovidgoyal.kitty-tab-{os.getpid()}', b'0').decode())) and get_tab_being_dragged()[0] == tab_id: tab = self.tab_for_id(tab_id) - if tab is not None and needs_toplevel_on_wayland: + # The native callback reports was_dropped=False for internal drops. + # Wayland can finish the source before delivering the destination's + # data, so complete its pending UI target unless the drag was canceled. + if not was_canceled and tab is not None and needs_toplevel_on_wayland: for tm in self.all_tab_managers: + target = tm.window_being_dropped + if ( + target is not None + and tab.windows.num_groups == 1 + and (window := tab.active_window) is not None + and (dest_window := self.window_id_map.get(target.window_id)) is not None + ): + if target.quadrant == 5: + self._move_window_to(window, target_tab_id=dest_window.tab_id) + else: + directions: dict[int, Literal['left', 'right', 'top', 'bottom']] = {1: 'left', 2: 'right', 3: 'top', 4: 'bottom'} + self._insert_window_in_direction(window, dest_window, directions.get(target.quadrant, 'right')) + set_tab_being_dragged() + for manager in self.all_tab_managers: + manager.on_window_drop_move() + manager.on_tab_drop_move() + manager.layout_tab_bar() + return if tm.tab_being_dropped: tm.on_tab_drop(0, 0, bypass_move=True) return set_tab_being_dragged() for tm in self.all_tab_managers: + tm.on_window_drop_move() tm.on_tab_drop_move() - if was_dropped and tab is not None: # detach tab into new OS Window + if was_dropped and not was_canceled and tab is not None: # detach tab into new OS Window self._move_tab_to(tab) @ac( @@ -3642,16 +3680,16 @@ class Boss: if src_tab is None or dest_tab is None: return with self.suppress_focus_change_events(): - if src_tab is not dest_tab: - target_tab_id = dest_tab.id - self._move_window_to(window, target_tab_id=target_tab_id) - dest_tab_fresh = self.tab_for_id(dest_tab.id) - if dest_tab_fresh is None: - return - src_tab = dest_tab_fresh - layout = src_tab.current_layout horizontal = direction in ('left', 'right') after = direction in ('right', 'bottom') + if src_tab is not dest_tab: + # Insert directly at the drop target. Attaching at the default + # location first would change existing split proportions. + dest_tab.attach_windows(src_tab.detach_window(window), next_to=dest_window, horizontal=horizontal, after=after) + self._cleanup_tab_after_window_removal(src_tab) + dest_tab.make_active() + return + layout = src_tab.current_layout layout.insert_window_next_to(src_tab.windows, window, dest_window, horizontal, after) src_tab.relayout() diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 53c2f78c5..c5e3a4f14 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1162,7 +1162,9 @@ opt( long_text=""" The threshold distance the mouse must move to start a drag and drop. Dragging works for tabs and windows. You can drag tabs to re-order them, detach -them into new OS Windows or move them to another OS Window. Similarly, +them into new OS Windows or move them to another OS Window. A tab with a single +window can also be dropped into another tab's content area to insert that window +at the highlighted edge, within the same kitty process. Similarly, by dragging the titlebar of a window (see :ac:`toggle_window_title_bars`) you can re-order it in its layout, detach it or move it to another tab. A value of zero disables all dragging. diff --git a/kitty/tabs.py b/kitty/tabs.py index bd06a1465..950d6c756 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -977,10 +977,18 @@ class Tab: # {{{ attach_window(self.os_window_id, self.id, window.id) self._add_window(window, overlay_for=overlay_for) - def attach_windows(self, windows: Iterable[Window]) -> None: + def attach_windows(self, windows: Iterable[Window], *, next_to: Window | None = None, horizontal: bool = True, after: bool = True) -> None: overlay_for: int | None = None for window in windows: - self.attach_window(window, overlay_for) + if overlay_for is None and next_to is not None: + window.change_tab(self) + attach_window(self.os_window_id, self.id, window.id) + self.windows.add_window(window) + self.current_layout.insert_window_next_to(self.windows, window, next_to, horizontal, after) + self.mark_tab_bar_dirty() + self.relayout() + else: + self.attach_window(window, overlay_for) overlay_for = window.id def set_active_window(self, x: Window | int, for_keep_focus: Window | None = None) -> None: @@ -2060,8 +2068,8 @@ class TabManager: # {{{ central = viewport_for_window(self.os_window_id)[0] if not (central.left <= x < central.right and central.top <= y < central.bottom): return None - rel_x = x - central.left - rel_y = y - central.top + # Window geometry is already relative to the OS window. + rel_x, rel_y = x, y if (active_tab := self.active_tab) is None: return None for win in active_tab: @@ -2119,10 +2127,7 @@ class TabManager: # {{{ self._set_drag_target_tab(0) dest_window = self._find_window_at(x, y) if dest_window and dest_window.id != window_id: - from .fast_data_types import viewport_for_window as _vfw - - central = _vfw(self.os_window_id)[0] - rel_y = y - central.top + rel_y = y if dest_window.show_title_bar: from .fast_data_types import cell_size_for_window @@ -2136,7 +2141,7 @@ class TabManager: # {{{ return active_tab = self.active_tab if active_tab is not None: - rel_x = x - central.left + rel_x = x g = dest_window.geometry dx = rel_x - (g.left + g.right) / 2 dy = rel_y - (g.top + g.bottom) / 2 @@ -2189,8 +2194,8 @@ class TabManager: # {{{ if not in_central: return - rel_x = x - central.left - rel_y = y - central.top + # Window geometry is already relative to the OS window. + rel_x, rel_y = x, y if (active_tab := self.active_tab) is None: return diff --git a/kitty_tests/tab_drop.py b/kitty_tests/tab_drop.py new file mode 100644 index 000000000..74bf71975 --- /dev/null +++ b/kitty_tests/tab_drop.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python +# License: GPL v3 Copyright: 2026, kitty contributors + +import os +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +from kitty.boss import Boss +from kitty.layout.splits import Splits +from kitty.tabs import DragOverlayMode, Tab, TabManager + +from .base import BaseTest +from .layout import Window, create_layout, create_windows + + +class TestTabDrop(BaseTest): + def setUp(self): + super().setUp() + self.source = SimpleNamespace(id=1, os_window_id=1, windows=SimpleNamespace(num_groups=1), active_window=SimpleNamespace(id=10)) + self.destination = SimpleNamespace(id=2) + self.tm = SimpleNamespace( + os_window_id=1, + active_tab=self.destination, + on_tab_drop_move=Mock(), + on_window_drop_move=Mock(), + on_window_drop=Mock(), + on_tab_drop=Mock(), + layout_tab_bar=Mock(), + tab_being_dropped=None, + window_being_dropped=None, + ) + self.boss = SimpleNamespace( + os_window_map={1: self.tm}, + all_tab_managers=[self.tm], + tab_for_id=lambda tid: self.source if tid == 1 else None, + _move_tab_to=Mock(), + _move_window_to=Mock(), + _insert_window_in_direction=Mock(), + window_id_map={}, + ) + self.data = {f'application/net.kovidgoyal.kitty-tab-{os.getpid()}': b'1'} + self.region = SimpleNamespace(left=0, right=1000, top=30, bottom=1000) + bar = SimpleNamespace(left=0, right=1000, top=0, bottom=30) + for name, value in ( + ('viewport_for_window', Mock(return_value=(self.region, bar))), + ('get_tab_being_dragged', Mock(return_value=(1, True))), + ('get_window_being_dragged', Mock(return_value=(0, False))), + ('set_tab_being_dragged', Mock()), + ('change_drag_thumbnail', Mock()), + ): + patcher = patch('kitty.boss.' + name, value) + patcher.start() + self.addCleanup(patcher.stop) + setattr(self, name, value) + + def test_single_window_tab_preview_and_drop(self): + Boss.on_drop_move(self.boss, 1, 600, 400, True, False) + self.tm.on_window_drop_move.assert_called_with(10, True, 600, 400) + self.tm.on_tab_drop_move.assert_called_with(1, False, 600, 400) + Boss.on_drop(self.boss, 1, self.data, True, 600, 400) + self.tm.on_window_drop.assert_called_once_with(600, 400, 10) + self.boss._move_tab_to.assert_not_called() + self.tm.on_window_drop_move.assert_called_with() + self.set_tab_being_dragged.assert_called_once_with() + + def test_single_window_tab_cross_os_window(self): + self.source.os_window_id = 9 + Boss.on_drop_move(self.boss, 1, 600, 400, True, False) + self.tm.on_window_drop_move.assert_called_with(10, True, 600, 400) + Boss.on_drop(self.boss, 1, self.data, True, 600, 400) + self.tm.on_window_drop.assert_called_once_with(600, 400, 10) + self.tm.on_tab_drop.assert_not_called() + + def test_tab_bar_drop_keeps_tab_behavior(self): + Boss.on_drop_move(self.boss, 1, 600, 10, True, False) + self.tm.on_window_drop_move.assert_called_with(0, False, 600, 10) + self.tm.on_tab_drop_move.assert_called_with(1, True, 600, 10) + Boss.on_drop(self.boss, 1, self.data, True, 600, 10) + self.tm.on_tab_drop.assert_called_once_with(600, 10) + self.tm.on_window_drop.assert_not_called() + + def test_multi_window_tab_keeps_tab_behavior(self): + self.source.windows.num_groups = 2 + Boss.on_drop_move(self.boss, 1, 600, 400, True, False) + self.tm.on_window_drop_move.assert_called_with(0, False, 600, 400) + Boss.on_drop(self.boss, 1, self.data, True, 600, 400) + self.boss._move_tab_to.assert_called_once_with(self.source) + self.tm.on_window_drop.assert_not_called() + self.source.os_window_id = 9 + Boss.on_drop(self.boss, 1, self.data, True, 600, 400) + self.tm.on_tab_drop.assert_called_once_with(600, 400) + + def test_tab_drop_into_self_keeps_tab_behavior(self): + self.tm.active_tab = self.source + Boss.on_drop_move(self.boss, 1, 600, 400, True, False) + self.tm.on_window_drop_move.assert_called_with(0, False, 600, 400) + Boss.on_drop(self.boss, 1, self.data, True, 600, 400) + self.boss._move_tab_to.assert_called_once_with(self.source) + self.tm.on_window_drop.assert_not_called() + + def test_leaving_clears_window_preview(self): + Boss.on_drop_move(self.boss, 1, 600, 400, True, True) + self.tm.on_window_drop_move.assert_called_with(0, False, 600, 400) + self.tm.on_tab_drop_move.assert_called_with(1, False, 600, 400) + + def test_cancel_clears_preview_without_detaching(self): + for reported_drop in (True, False): + Boss.on_drag_source_finished(self.boss, reported_drop, True, '', 0, self.data, True) + self.tm.on_window_drop_move.assert_called_with() + self.boss._move_tab_to.assert_not_called() + self.boss._insert_window_in_direction.assert_not_called() + + def test_wayland_finish_uses_pending_window_target(self): + target = SimpleNamespace(id=20, tab_id=2) + self.boss.window_id_map[20] = target + for quadrant, direction in enumerate(('left', 'right', 'top', 'bottom'), 1): + self.tm.window_being_dropped = SimpleNamespace(window_id=20, quadrant=quadrant) + Boss.on_drag_source_finished(self.boss, True, False, '', 0, self.data, True) + self.boss._insert_window_in_direction.assert_called_with(self.source.active_window, target, direction) + self.tm.on_window_drop_move.assert_called_with() + self.boss._move_tab_to.assert_not_called() + self.tm.window_being_dropped.quadrant = 5 + Boss.on_drag_source_finished(self.boss, True, False, '', 0, self.data, True) + self.boss._move_window_to.assert_called_once_with(self.source.active_window, target_tab_id=2) + + def test_wayland_internal_drop_finishes_before_data_arrives(self): + target = SimpleNamespace(id=20, tab_id=2) + self.boss.window_id_map[20] = target + self.tm.window_being_dropped = SimpleNamespace(window_id=20, quadrant=1) + # glfw.c reports was_dropped=False while an internal drop destination + # exists, even for a successful drop. Data can arrive after this callback. + Boss.on_drag_source_finished(self.boss, False, False, '', 0, self.data, True) + self.boss._insert_window_in_direction.assert_called_once_with(self.source.active_window, target, 'left') + self.boss._move_tab_to.assert_not_called() + + def test_wayland_internal_tab_reordering_is_preserved(self): + self.tm.tab_being_dropped = SimpleNamespace() + Boss.on_drag_source_finished(self.boss, False, False, '', 0, self.data, True) + self.tm.on_tab_drop.assert_called_once_with(0, 0, bypass_move=True) + self.boss._move_tab_to.assert_not_called() + + def test_completed_drop_is_not_detached_again(self): + self.get_tab_being_dragged.return_value = (0, False) + Boss.on_drag_source_finished(self.boss, True, False, '', 0, self.data, True) + self.boss._move_tab_to.assert_not_called() + + def test_directional_attach_preserves_other_split_weights_and_overlays(self): + for horizontal in (True, False): + for after in (True, False): + with self.subTest(horizontal=horizontal, after=after): + layout = create_layout(Splits) + windows = create_windows(layout, num=0) + for wid in (1, 2, 3): + layout.add_window(windows, Window(wid), location='vsplit') + layout.pairs_root.unserialize({'bias': 0.2, 'one': 1, 'two': {'bias': 0.375, 'one': 2, 'two': 3}}, lambda x: x) + tab = SimpleNamespace(id=2, os_window_id=1, current_layout=layout, windows=windows, mark_tab_bar_dirty=Mock(), relayout=Mock()) + pane, overlay = Window(4), Window(5) + for window in (pane, overlay): + window.change_tab = Mock() + tab._add_window = lambda window, overlay_for: layout.add_window(windows, window, overlay_for=overlay_for) + tab.attach_window = lambda window, overlay_for: Tab.attach_window(tab, window, overlay_for) + with patch('kitty.tabs.attach_window') as native_attach: + Tab.attach_windows(tab, (pane, overlay), next_to=windows.id_map[2], horizontal=horizontal, after=after) + self.ae(native_attach.call_count, 2) + root = layout.pairs_root + self.assertAlmostEqual(root.bias, 0.2) + self.assertAlmostEqual(root.two.bias, 0.375) + split = root.two.one + self.ae(split.horizontal, horizontal) + self.ae((split.one, split.two), (2, 4) if after else (4, 2)) + self.assertAlmostEqual(split.bias, 0.5) + self.assertIs(windows.group_for_window(pane), windows.group_for_window(overlay)) + + def test_cross_tab_directional_insert_uses_direct_attachment(self): + source = SimpleNamespace(detach_window=Mock(return_value=('pane', 'overlay'))) + dest = SimpleNamespace(attach_windows=Mock(), make_active=Mock()) + pane = SimpleNamespace(tabref=lambda: source) + target = SimpleNamespace(tabref=lambda: dest) + boss = SimpleNamespace(suppress_focus_change_events=nullcontext, _cleanup_tab_after_window_removal=Mock()) + for direction, horizontal, after in (('left', True, False), ('right', True, True), ('top', False, False), ('bottom', False, True)): + Boss._insert_window_in_direction(boss, pane, target, direction) + dest.attach_windows.assert_called_with(('pane', 'overlay'), next_to=target, horizontal=horizontal, after=after) + boss._cleanup_tab_after_window_removal.assert_called_with(source) + dest.make_active.assert_called_with() + + +class TestWindowDropCoordinates(BaseTest): + def test_drop_with_offset_tab_bar(self): + for left, top in ((0, 30), (80, 0), (80, 30)): + for direction, quadrant in (('left', 1), ('right', 2), ('top', 3), ('bottom', 4)): + with self.subTest(left=left, top=top, direction=direction): + central = SimpleNamespace(left=left, top=top, right=1000, bottom=800) + bar = SimpleNamespace(left=0, top=0, right=left or 1000, bottom=top or 800) + g = SimpleNamespace(left=left + 5, top=top + 5, right=995, bottom=795) + dest = SimpleNamespace(id=20, geometry=g, show_title_bar=False) + src = SimpleNamespace(id=10, tabref=lambda: None) + tab = MagicMock(current_layout=SimpleNamespace(drag_overlay_mode=DragOverlayMode.free)) + tab.__iter__.return_value = [dest] + tm = SimpleNamespace( + os_window_id=1, + active_tab=tab, + window_drag_over_me=True, + _set_drag_target_tab=Mock(), + _set_drag_target_window=Mock(), + _clear_force_show_title_bars=Mock(), + mark_tab_bar_dirty=Mock(), + ) + tm._find_window_at = lambda x, y: TabManager._find_window_at(tm, x, y) + boss = SimpleNamespace(window_id_map={10: src}, _insert_window_in_direction=Mock(), _move_window_to=Mock()) + x, y = { + 'left': (g.left + 2, (g.top + g.bottom) // 2), + 'right': (g.right - 2, (g.top + g.bottom) // 2), + 'top': ((g.left + g.right) // 2, g.top + 2), + 'bottom': ((g.left + g.right) // 2, g.bottom - 2), + }[direction] + with ( + patch('kitty.fast_data_types.viewport_for_window', return_value=(central, bar)), + patch('kitty.fast_data_types.cell_size_for_window', return_value=(10, 20)), + patch('kitty.tabs.get_options', return_value=SimpleNamespace(window_title_bar='top')), + patch('kitty.tabs.get_boss', return_value=boss), + patch('kitty.tabs.set_window_being_dragged'), + ): + TabManager.on_window_drop_move(tm, 10, True, x, y) + tm._set_drag_target_window.assert_called_with(20, quadrant) + TabManager.on_window_drop(tm, x, y, 10) + boss._insert_window_in_direction.assert_called_once_with(src, dest, direction) + boss._move_window_to.assert_not_called()