diff --git a/docs/changelog.rst b/docs/changelog.rst index f19e5af15..47ae4eb0a 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 99a2be457..dab807310 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2148,8 +2148,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: @@ -2179,12 +2190,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 @@ -2244,15 +2260,34 @@ 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: + if was_dropped and 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( @@ -3638,16 +3673,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..f2f4abd2c 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: diff --git a/kitty_tests/tab_drop.py b/kitty_tests/tab_drop.py new file mode 100644 index 000000000..6c8085b53 --- /dev/null +++ b/kitty_tests/tab_drop.py @@ -0,0 +1,169 @@ +#!/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 Mock, patch + +from kitty.boss import Boss +from kitty.layout.splits import Splits +from kitty.tabs import Tab + +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_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()