From 7faf714da21b634de6bd4c13b74e3dbdd5cbf233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B8=BD?= <18717730+xiaobai050@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:17:54 +0800 Subject: [PATCH 1/2] Allow dropping single-window tabs into another tab layout --- docs/changelog.rst | 4 + docs/overview.rst | 8 +- kitty/boss.py | 59 ++++++++++--- kitty/options/definition.py | 4 +- kitty/tabs.py | 12 ++- kitty_tests/tab_drop.py | 169 ++++++++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 kitty_tests/tab_drop.py 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() From 120cad0cba06b3f5fd02b558765f23911b9c4320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B8=BD?= <18717730+xiaobai050@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:07:58 +0800 Subject: [PATCH 2/2] Fix native tab drop completion and offset hit testing --- kitty/boss.py | 5 +++- kitty/tabs.py | 15 ++++------ kitty_tests/tab_drop.py | 63 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index dab807310..c1be47cfd 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2260,7 +2260,10 @@ 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 was_dropped and not was_canceled and 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 ( diff --git a/kitty/tabs.py b/kitty/tabs.py index f2f4abd2c..950d6c756 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -2068,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: @@ -2127,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 @@ -2144,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 @@ -2197,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 index 6c8085b53..74bf71975 100644 --- a/kitty_tests/tab_drop.py +++ b/kitty_tests/tab_drop.py @@ -4,11 +4,11 @@ import os from contextlib import nullcontext from types import SimpleNamespace -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from kitty.boss import Boss from kitty.layout.splits import Splits -from kitty.tabs import Tab +from kitty.tabs import DragOverlayMode, Tab, TabManager from .base import BaseTest from .layout import Window, create_layout, create_windows @@ -124,6 +124,22 @@ class TestTabDrop(BaseTest): 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) @@ -167,3 +183,46 @@ class TestTabDrop(BaseTest): 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()