Allow dropping single-window tabs into another tab layout

This commit is contained in:
白鸽 2026-09-14 19:17:54 +08:00
parent a54b409785
commit 7faf714da2
6 changed files with 240 additions and 16 deletions

View file

@ -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

View file

@ -183,7 +183,13 @@ in your browser, etc. For details, see :doc:`here <open_actions>`.
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.

View file

@ -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()

View file

@ -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.

View file

@ -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:

169
kitty_tests/tab_drop.py Normal file
View file

@ -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()