Merge branch 'feature/tab-to-split' of https://github.com/xiaobai050/kitty

This commit is contained in:
Kovid Goyal 2026-09-15 20:03:11 +05:30
commit bde35f3dd0
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
6 changed files with 308 additions and 25 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

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

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

228
kitty_tests/tab_drop.py Normal file
View file

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