Add draggable window title bars

Implements drag-to-reorder for window title bars, following up on the
merged window title bar feature (#9450) and the design discussion in #9619.

- Drag a title bar and drop on another title bar to swap positions
- Drop on a window body quadrant (left/right/top/bottom) to insert as
  a directional split; Splits layout uses insert_window_next_to(), other
  layouts fall back to move_window_to_group()
- Drop on a tab bar tab to move the window into that tab
- Drop on another OS window to move into its active tab
- Drop outside kitty to detach into a new OS window
- Tab bar highlights the hovered tab during a window drag, mirroring
  how the destination window title bar is highlighted
- toggle_window_title_bars action temporarily force-shows title bars
  for drag-to-reorder when they are normally hidden, auto-hiding after
  the drag completes
- window_title_bar_drag_threshold option (default 5px) controls how far
  the mouse must move before a drag is initiated; 0 disables dragging

MIME type follows the same convention as tab dragging:
application/net.kovidgoyal.kitty-window-{PID}

Ref: #9619
This commit is contained in:
mcrmck 2026-03-08 20:56:38 -04:00
parent db5453c291
commit 59c963c481
15 changed files with 391 additions and 29 deletions

View file

@ -909,14 +909,15 @@ - (instancetype)initWithGlfwWindow:(_GLFWwindow *)initWindow
self.identifier = @"kitty-content-view";
[self updateTrackingAreas];
char tab_mime[64];
char tab_mime[64], window_mime[64];
snprintf(tab_mime, sizeof(tab_mime), "application/net.kovidgoyal.kitty-tab-%d", getpid());
snprintf(window_mime, sizeof(window_mime), "application/net.kovidgoyal.kitty-window-%d", getpid());
NSMutableArray *types = [NSMutableArray arrayWithObjects:
NSPasteboardTypeFileURL, NSPasteboardTypeString, NSPasteboardTypeURL, NSPasteboardTypeColor,
NSPasteboardTypeFont, NSPasteboardTypeHTML, NSPasteboardTypePDF, NSPasteboardTypePNG,
NSPasteboardTypeRTF, NSPasteboardTypeSound, NSPasteboardTypeTIFF,
UTTypeData.identifier, UTTypeItem.identifier, UTTypeContent.identifier,
mime_to_uti(tab_mime),
mime_to_uti(tab_mime), mime_to_uti(window_mime),
nil];
// Add file promise types
[types addObjectsFromArray:[NSFilePromiseReceiver readableDraggedTypes]];
@ -1591,6 +1592,10 @@ - (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
for (size_t i = 0; i < d->mimes_count; i++)
_glfwPlatformRequestDropData(window, d->mimes[i]);
}
// Restore first-responder status after native DnD; the drag operation can
// displace the content view from first responder, silently breaking keyboard
// input even though osw->is_focused remains true.
[window->ns.object makeFirstResponder:window->ns.view];
return YES;
}

View file

@ -95,6 +95,7 @@
get_options,
get_os_window_size,
get_tab_being_dragged,
get_window_being_dragged,
glfw_get_monitor_workarea,
global_font_size,
grab_keyboard,
@ -120,6 +121,7 @@
set_os_window_size,
set_os_window_title,
set_tab_being_dragged,
set_window_being_dragged,
start_drag_with_data,
thread_write,
toggle_fullscreen,
@ -1389,9 +1391,9 @@ def start(self, first_os_window_id: int, startup_sessions: Iterable[Session]) ->
run_update_check(get_options().update_check_interval * 60 * 60)
self.update_check_started = True
def handle_window_title_bar_mouse(self, os_window_id: int, window_id: int, button: int, modifiers: int, action: int) -> None:
def handle_window_title_bar_mouse(self, os_window_id: int, window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
if tm := self.os_window_map.get(os_window_id):
tm.handle_window_title_bar_mouse(window_id, button, modifiers, action)
tm.handle_window_title_bar_mouse(window_id, x, y, button, modifiers, action)
def handle_tab_bar_mouse(self, os_window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
if tm := self.os_window_map.get(os_window_id):
@ -1925,6 +1927,10 @@ def on_drop_move(self, os_window_id: int, x: int, y: int, from_self: bool, is_le
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_tab_drop_move(tab_id, is_dest, x, y)
window_id, drag_started = get_window_being_dragged()[:2]
if window_id and drag_started:
for q in self.all_tab_managers:
q.on_window_drop_move(window_id, not is_leave, x, y)
def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bool, x: int, y: int) -> None:
if isinstance(drop, int):
@ -1937,6 +1943,14 @@ def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bo
return
if (tm := self.os_window_map.get(os_window_id)) is None:
return
window_mime_key = f'application/net.kovidgoyal.kitty-window-{os.getpid()}'
if (widb := drop.get(window_mime_key)):
window_id = int(widb)
tm.on_window_drop(x, y, window_id)
set_window_being_dragged()
for q in self.all_tab_managers:
q.on_window_drop_move()
return
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
@ -1967,6 +1981,27 @@ def on_drag_source_finished(
self, was_dropped: bool, was_canceled: bool, accepted_mime_type: str, action: int, data: dict[str, bytes] | None,
needs_toplevel_on_wayland: bool
) -> None:
window_mime_key = f'application/net.kovidgoyal.kitty-window-{os.getpid()}'
if (wid_bytes := (data or {}).get(window_mime_key)):
window_id = int(wid_bytes.decode())
if get_window_being_dragged()[0] == window_id:
# Drop was not handled by on_drop (e.g. dropped outside kitty or on Wayland)
for tm in self.all_tab_managers:
for tab in tm:
if tab.force_show_title_bars:
tab.force_show_title_bars = False
tab.relayout()
set_window_being_dragged()
for tm in self.all_tab_managers:
tm.on_window_drop_move()
if was_dropped and not was_canceled:
if (window := self.window_id_map.get(window_id)):
src_tab = window.tabref()
src_tm = self.os_window_map.get(src_tab.os_window_id) if src_tab else None
total_windows = sum(len(t) for t in src_tm) if src_tm else 0
if total_windows > 1:
self._move_window_to(window, target_os_window_id='new')
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 and (tab := self.tab_for_id(tab_id)):
if needs_toplevel_on_wayland:
@ -3232,6 +3267,46 @@ def _move_window_to(
self._cleanup_tab_after_window_removal(src_tab)
target_tab.make_active()
def _swap_windows(self, window_a: Window, window_b: Window) -> None:
tab = window_a.tabref()
if tab is None or tab is not window_b.tabref():
return
wg_b = tab.windows.group_for_window(window_b)
if wg_b is None:
return
with self.suppress_focus_change_events():
tab.windows.set_active_window_group_for(window_a)
tab.current_layout.move_window_to_group(tab.windows, wg_b.id)
tab.relayout()
def _insert_window_in_direction(
self, window: Window, dest_window: Window,
direction: str
) -> None:
src_tab = window.tabref()
dest_tab = dest_window.tabref()
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 hasattr(layout, 'insert_window_next_to'):
layout.insert_window_next_to(src_tab.windows, window, dest_window, horizontal, after)
else:
wg_dest = src_tab.windows.group_for_window(dest_window)
if wg_dest:
src_tab.windows.set_active_window_group_for(window)
layout.move_window_to_group(src_tab.windows, wg_dest.id)
src_tab.relayout()
def _move_tab_to(self, tab: Tab | None = None, target_os_window_id: int | None = None) -> Tab | None:
tab = tab or self.active_tab
if tab is None:
@ -3323,6 +3398,32 @@ def format_tab_title(tab: Tab) -> str:
chosen
)
@ac('win', '''
Temporarily show window title bars to allow drag-to-reorder
When window title bars are hidden (because :opt:`window_title_bar_min_windows`
is not met), this action forces them temporarily visible so that they can be
dragged to reorder windows. After any drag operation completes, the bars are
automatically hidden again. Press again to cancel before dragging.
Has no effect on tabs where title bars are already naturally visible.
Map an action to this, then press it before dragging a window title bar.
''')
def toggle_window_title_bars(self) -> None:
tm = self.active_tab_manager
if tm is None:
return
opts = get_options()
min_w = opts.window_title_bar_min_windows
currently_forced = any(t.force_show_title_bars for t in tm)
for t in tm:
visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True))
naturally_visible = min_w > 0 and visible >= min_w
if not naturally_visible:
t.force_show_title_bars = not currently_forced
t.relayout()
@ac('win', '''
Detach a window, moving it to another tab or OS Window

View file

@ -1833,6 +1833,8 @@ def change_drag_thumbnail(os_window_id: int, idx: int = -1) -> None: ...
def draw_single_line_of_text(os_window_id: int, text: str, fg: int, bg: int, width: int, padding_y: int = 2) -> bytes: ...
def set_tab_being_dragged(tab_id: int = 0, drag_started: bool = False, x: float = 0, y: float = 0) -> None: ...
def get_tab_being_dragged() -> tuple[int, bool, float, float]: ...
def set_window_being_dragged(window_id: int = 0, drag_started: bool = False, x: float = 0.0, y: float = 0.0) -> None: ...
def get_window_being_dragged() -> tuple[int, bool, float, float]: ...
def request_callback_with_thumbnail(
callback: str, os_window_id: int, window_id: int = 0, include_tab_bar: bool = False,
scale: float = 0.25, max_width: int = 480

View file

@ -664,12 +664,16 @@ window_focus_callback(GLFWwindow *w, int focused) {
}
#define TAB_DRAG_MIME_NUMBER 400
#define WINDOW_DRAG_MIME_NUMBER 401
static int
is_droppable_mime(const char *mime) {
static char tab_mime[64] = {0};
if (!tab_mime[0]) snprintf(tab_mime, sizeof(tab_mime), "application/net.kovidgoyal.kitty-tab-%d", getpid());
if (strcmp(mime, tab_mime) == 0) return TAB_DRAG_MIME_NUMBER;
static char window_mime[64] = {0};
if (!window_mime[0]) snprintf(window_mime, sizeof(window_mime), "application/net.kovidgoyal.kitty-window-%d", getpid());
if (strcmp(mime, window_mime) == 0) return WINDOW_DRAG_MIME_NUMBER;
if (strcmp(mime, "text/uri-list") == 0) return 3;
if (strcmp(mime, "text/plain;charset=utf-8") == 0) return 2;
if (strcmp(mime, "text/plain") == 0) return 1;
@ -2891,8 +2895,11 @@ start_drag_with_data(PyObject *self UNUSED, PyObject *args, PyObject *kw) {
GLFWDragSourceItem *item = items + num++;
item->mime_type = PyUnicode_AsUTF8(key);
item->optional_data = PyBytes_AS_STRING(value); item->data_size = PyBytes_GET_SIZE(value);
if (global_state.is_wayland && is_droppable_mime(item->mime_type) == TAB_DRAG_MIME_NUMBER)
needs_toplevel_on_wayland = true;
if (global_state.is_wayland) {
int mime_num = is_droppable_mime(item->mime_type);
if (mime_num == TAB_DRAG_MIME_NUMBER || mime_num == WINDOW_DRAG_MIME_NUMBER)
needs_toplevel_on_wayland = true;
}
}
free_drag_source();
global_state.drag_source.is_active = true;

View file

@ -371,7 +371,8 @@ def __call__(self, all_windows: WindowList) -> None:
# Set show_title_bar flag on each visible window before layout
min_windows = get_options().window_title_bar_min_windows
visible_groups = tuple(all_windows.iter_all_layoutable_groups(only_visible=True))
show_title_bar = min_windows > 0 and len(visible_groups) >= min_windows
force_show = getattr(all_windows, '_force_show_title_bars', False)
show_title_bar = force_show or (min_windows > 0 and len(visible_groups) >= min_windows)
for wg in visible_groups:
for w in wg.windows:
w.show_title_bar = show_title_bar

View file

@ -684,6 +684,28 @@ def move_window_to_group(self, all_windows: WindowList, group: int) -> bool:
self.pairs_root.swap_windows(before.id, after.id)
return moved
def insert_window_next_to(
self,
all_windows: WindowList,
window: 'WindowType',
next_to: 'WindowType',
horizontal: bool,
after: bool
) -> None:
"""Reposition an existing window as a split adjacent to next_to."""
src_wg = all_windows.group_for_window(window)
dest_wg = all_windows.group_for_window(next_to)
if src_wg is None or dest_wg is None or src_wg.id == dest_wg.id:
return
# Remove from current position in pairs_root
self.remove_windows(src_wg.id)
# Re-insert next to dest
pair = self.pairs_root.pair_for_window(dest_wg.id)
if pair is not None:
self.pairs_root.split_and_add(dest_wg.id, src_wg.id, horizontal, after)
else:
self.pairs_root.balanced_add(src_wg.id)
def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None:
if action_name == 'rotate':
args = args or ('90',)

View file

@ -893,8 +893,10 @@ HANDLER(handle_event) {
static void
handle_window_title_bar_mouse(Window *w, int button, int modifiers, int action) {
OSWindow *osw = global_state.callback_os_window;
if (osw && button > -1) {
call_boss(handle_window_title_bar_mouse, "KKiii", osw->id, w->id, button, modifiers, action);
if (!osw) return;
if (button > -1 || global_state.window_being_dragged.id) {
call_boss(handle_window_title_bar_mouse, "KKddiii",
osw->id, w->id, osw->mouse_x, osw->mouse_y, button, modifiers, action);
}
}
@ -1268,9 +1270,13 @@ mouse_event(const int button, int modifiers, int action) {
mouse_cursor_shape = POINTER_POINTER;
handle_tab_bar_mouse(button, modifiers, action);
debug("handled by tab bar\n");
} else if (r.in_title_bar && r.window) {
} else if ((r.in_title_bar && r.window) || global_state.window_being_dragged.id) {
mouse_cursor_shape = POINTER_POINTER;
handle_window_title_bar_mouse(r.window, button, modifiers, action);
Window *tw = r.window;
if (!tw && global_state.window_being_dragged.id) {
tw = window_for_window_id(global_state.window_being_dragged.id);
}
if (tw) handle_window_title_bar_mouse(tw, button, modifiers, action);
debug("handled by window title bar\n");
} else if (r.window_border) {
debug("window border: %s window id: %llu\n", border_name(r.window_border), w ? w->id : 0);

View file

@ -1550,6 +1550,16 @@
choices=('left', 'center', 'right'),
long_text='Horizontal alignment of the text in window title bars.'
)
opt('window_title_bar_drag_threshold', '5',
option_type='positive_int',
long_text='''
Pixel distance the mouse must move before a window title bar drag begins.
Zero disables dragging. Drop on a title bar swaps positions; drop on a
window body inserts in the quadrant direction (left/right/top/bottom).
Drop on the tab bar moves the window to that tab; drop outside kitty
detaches it to a new OS window.
''')
egr() # }}}

View file

@ -1529,6 +1529,9 @@ def window_title_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None:
choices_for_window_title_bar_align = choices_for_tab_bar_align
def window_title_bar_drag_threshold(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['window_title_bar_drag_threshold'] = positive_int(val)
def window_title_bar_inactive_background(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['window_title_bar_inactive_background'] = to_color_or_none(val)

View file

@ -504,6 +504,7 @@
'window_title_bar_active_background',
'window_title_bar_active_foreground',
'window_title_bar_align',
'window_title_bar_drag_threshold',
'window_title_bar_inactive_background',
'window_title_bar_inactive_foreground',
'window_title_bar_min_windows',
@ -705,6 +706,7 @@ class Options:
window_title_bar_active_background: kitty.fast_data_types.Color | None = None
window_title_bar_active_foreground: kitty.fast_data_types.Color | None = None
window_title_bar_align: choices_for_window_title_bar_align = 'center'
window_title_bar_drag_threshold: int = 5
window_title_bar_inactive_background: kitty.fast_data_types.Color | None = None
window_title_bar_inactive_foreground: kitty.fast_data_types.Color | None = None
window_title_bar_min_windows: int = 0

View file

@ -1551,6 +1551,20 @@ get_tab_being_dragged(PyObject *self UNUSED, PyObject *args UNUSED) {
}
#undef tbd
#define wbd global_state.window_being_dragged
static PyObject*
set_window_being_dragged(PyObject *self UNUSED, PyObject *args) {
zero_at_ptr(&wbd);
if (!PyArg_ParseTuple(args, "|Kpdd", &wbd.id, &wbd.drag_started, &wbd.x, &wbd.y)) return NULL;
Py_RETURN_NONE;
}
static PyObject*
get_window_being_dragged(PyObject *self UNUSED, PyObject *args UNUSED) {
return Py_BuildValue("KOdd", wbd.id, wbd.drag_started ? Py_True : Py_False, wbd.x, wbd.y);
}
#undef wbd
static PyObject*
request_callback_with_thumbnail(PyObject *self UNUSED, PyObject *args) {
unsigned long long os_window_id, window_id = 0;
@ -1579,6 +1593,8 @@ static PyMethodDef module_methods[] = {
M(request_callback_with_thumbnail, METH_VARARGS),
M(set_tab_being_dragged, METH_VARARGS),
M(get_tab_being_dragged, METH_NOARGS),
M(set_window_being_dragged, METH_VARARGS),
M(get_window_being_dragged, METH_NOARGS),
MW(update_pointer_shape, METH_VARARGS),
MW(current_os_window, METH_NOARGS),
MW(next_window_id, METH_NOARGS),

View file

@ -405,6 +405,10 @@ typedef struct GlobalState {
id_type id; bool drag_started;
double x, y;
} tab_being_dragged;
struct {
id_type id; bool drag_started;
double x, y;
} window_being_dragged;
struct {
uint32_t texture_id, framebuffer_id, texture_generation;
int width, height;

View file

@ -33,6 +33,7 @@
get_click_interval,
get_options,
get_tab_being_dragged,
get_window_being_dragged,
is_tab_bar_visible,
last_focused_os_window_id,
mark_tab_bar_dirty,
@ -48,6 +49,7 @@
set_active_window,
set_redirect_keys_to_overlay,
set_tab_being_dragged,
set_window_being_dragged,
start_drag_with_data,
swap_tabs,
sync_os_window_title,
@ -145,6 +147,7 @@ class Tab: # {{{
inactive_fg: int | None = None
inactive_bg: int | None = None
confirm_close_window_id: int = 0
force_show_title_bars: bool = False
num_of_windows_with_progress: int = 0
total_progress: int = 0
has_indeterminate_progress: bool = False
@ -461,7 +464,9 @@ def on_bell(self, window: Window) -> None:
def relayout(self) -> None:
if self.allow_relayouts:
if self.windows:
self.windows._force_show_title_bars = self.force_show_title_bars
self.current_layout(self.windows)
self.windows._force_show_title_bars = False
self.relayout_borders()
def relayout_borders(self) -> None:
@ -1115,6 +1120,10 @@ class TabBeingDropped(NamedTuple):
last_drop_move_x: int = -1
class WindowBeingDropped(NamedTuple):
window_id: int # the window whose title bar is currently highlighted as a drop target
class TabManager: # {{{
confirm_close_window_id: int = 0
@ -1122,6 +1131,8 @@ class TabManager: # {{{
total_progress: int = 0
has_indeterminate_progress: bool = False
tab_being_dropped: TabBeingDropped | None = None
window_being_dropped: WindowBeingDropped | None = None
window_drag_target_tab_id: int = 0
def __init__(self, os_window_id: int, args: CLIOptions, wm_class: str, wm_name: str, startup_session: SessionType | None = None):
self.os_window_id = os_window_id
@ -1576,9 +1587,10 @@ def tab_bar_data(self) -> Sequence[TabBarData]:
if drag_started:
tab_being_dragged_from_here = self.tab_for_id(dragged_tab_id) is not None
if self.tab_being_dropped is None:
wdtt = self.window_drag_target_tab_id
if tab_being_dragged_from_here:
return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar if t.id != dragged_tab_id)
return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar)
return tuple(t.data_for_tab_bar(t is at or t.id == wdtt) for t in self.tabs_to_be_shown_in_tab_bar if t.id != dragged_tab_id)
return tuple(t.data_for_tab_bar(t is at or t.id == wdtt) for t in self.tabs_to_be_shown_in_tab_bar)
tmap = {t.id:t for t in self.tabs}
at = self.active_tab
ans = []
@ -1755,30 +1767,198 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
if len(self.recent_mouse_events) > 5:
self.recent_mouse_events.popleft()
def handle_window_title_bar_mouse(self, window_id: int, button: int, modifiers: int, action: int) -> None:
def handle_window_title_bar_mouse(self, window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
now = monotonic()
boss = get_boss()
if button == -1: # motion event
dragged_window_id, drag_started, start_x, start_y = get_window_being_dragged()
if dragged_window_id and not drag_started:
threshold = get_options().window_title_bar_drag_threshold
dist_sq = (x - start_x)**2 + (y - start_y)**2
if threshold and dist_sq > threshold * threshold:
set_window_being_dragged(dragged_window_id, True, start_x, start_y)
self.start_window_drag(dragged_window_id)
return
if button == GLFW_MOUSE_BUTTON_LEFT:
if action == GLFW_PRESS:
if (w := boss.window_id_map.get(window_id)) is not None:
get_boss().set_active_window(w, switch_os_window_if_needed=True)
elif action == GLFW_RELEASE and len(self.recent_title_bar_mouse_events) > 2:
ci = get_click_interval()
prev, prev2 = self.recent_title_bar_mouse_events[-1], self.recent_title_bar_mouse_events[-2]
if (
prev.button == button and prev2.button == button and
prev.action == GLFW_PRESS and prev2.action == GLFW_RELEASE and
prev.tab_id == window_id and prev2.tab_id == window_id and
now - prev.at <= ci and now - prev2.at <= 2 * ci
): # double click on window title bar
if (w := boss.window_id_map.get(window_id)) is not None:
w.set_window_title()
self.recent_title_bar_mouse_events.clear()
return
boss.set_active_window(w, switch_os_window_if_needed=True)
threshold = get_options().window_title_bar_drag_threshold
if threshold:
set_window_being_dragged(window_id, False, x, y)
elif action == GLFW_RELEASE:
dragged_window_id, drag_started = get_window_being_dragged()[:2]
set_window_being_dragged()
if not drag_started and len(self.recent_title_bar_mouse_events) > 2:
ci = get_click_interval()
prev, prev2 = self.recent_title_bar_mouse_events[-1], self.recent_title_bar_mouse_events[-2]
if (
prev.button == button and prev2.button == button and
prev.action == GLFW_PRESS and prev2.action == GLFW_RELEASE and
prev.tab_id == window_id and prev2.tab_id == window_id and
now - prev.at <= ci and now - prev2.at <= 2 * ci
): # double click on window title bar
if (w := boss.window_id_map.get(window_id)) is not None:
w.set_window_title()
self.recent_title_bar_mouse_events.clear()
return
self.recent_title_bar_mouse_events.append(TabMouseEvent(button, modifiers, action, now, window_id))
if len(self.recent_title_bar_mouse_events) > 5:
self.recent_title_bar_mouse_events.popleft()
def start_window_drag(self, window_id: int) -> None:
boss = get_boss()
if (w := boss.window_id_map.get(window_id)) is None:
set_window_being_dragged()
return
opts = get_options()
title = str(w.title or '')
fg = color_as_int(opts.window_title_bar_active_foreground or opts.active_tab_foreground)
bg = color_as_int(opts.window_title_bar_active_background or opts.active_tab_background)
thumb_width = 480
title_pixels = draw_single_line_of_text(self.os_window_id, title, 0xff000000 | fg, 0xff000000 | bg, thumb_width)
title_height = len(title_pixels) // (thumb_width * 4)
thumbnails = ((title_pixels, thumb_width, title_height),)
drag_data = {f'application/net.kovidgoyal.kitty-window-{os.getpid()}': str(window_id).encode()}
try:
start_drag_with_data(self.os_window_id, drag_data, thumbnails)
except OSError as e:
log_error(f'Failed to start window drag: {e}')
set_window_being_dragged()
self._clear_force_show_title_bars()
def _set_drag_target_tab(self, tab_id: int) -> None:
if self.window_drag_target_tab_id == tab_id:
return
self.window_drag_target_tab_id = tab_id
self.mark_tab_bar_dirty()
def _clear_force_show_title_bars(self) -> None:
boss = get_boss()
for tm in boss.all_tab_managers:
tm._set_drag_target_window(0)
tm._set_drag_target_tab(0)
for tab in tm:
if tab.force_show_title_bars:
tab.force_show_title_bars = False
tab.relayout()
def _find_window_at(self, x: int, y: int) -> 'Window | None':
from .fast_data_types import viewport_for_window
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
if (active_tab := self.active_tab) is None:
return None
for win in active_tab:
g = win.geometry
if g.left <= rel_x < g.right and g.top <= rel_y < g.bottom:
return win
return None
def _set_drag_target_window(self, window_id: int) -> None:
"""Highlight window_id's title bar as the drop target; 0 clears."""
boss = get_boss()
prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0
if prev_id == window_id:
return
if prev_id and (prev_w := boss.window_id_map.get(prev_id)):
prev_w.is_drag_target = False
if prev_w._title_bar_screen is not None:
tab = prev_w.tabref()
prev_w.update_title_bar(is_active=tab is not None and tab.active_window is prev_w)
if window_id and (new_w := boss.window_id_map.get(window_id)):
new_w.is_drag_target = True
new_w.update_title_bar(is_active=True)
self.window_being_dropped = WindowBeingDropped(window_id=window_id)
else:
self.window_being_dropped = None
def on_window_drop_move(self, window_id: int = 0, is_dest: bool = False, x: int = 0, y: int = 0) -> None:
if not is_dest:
self._set_drag_target_window(0)
self._set_drag_target_tab(0)
return
from .fast_data_types import viewport_for_window
tab_bar = viewport_for_window(self.os_window_id)[1]
if tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom:
self._set_drag_target_window(0)
self._set_drag_target_tab(self.tab_bar.tab_id_at(x))
return
self._set_drag_target_tab(0)
dest_window = self._find_window_at(x, y)
target_id = dest_window.id if (dest_window and dest_window.id != window_id) else 0
self._set_drag_target_window(target_id)
def on_window_drop(self, x: int, y: int, window_id: int) -> None:
from .fast_data_types import cell_size_for_window, viewport_for_window
boss = get_boss()
w = boss.window_id_map.get(window_id)
if w is None:
return
self._clear_force_show_title_bars()
set_window_being_dragged()
central, tab_bar = viewport_for_window(self.os_window_id)[:2]
# Case 1: Drop on tab bar → move to that tab
in_tab_bar = tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom
if in_tab_bar:
if (tab_id := self.tab_bar.tab_id_at(x)) and (dest_tab := self.tab_for_id(tab_id)):
boss._move_window_to(w, target_tab_id=dest_tab.id)
return
# Case 2: Drop in central area
in_central = central.left <= x < central.right and central.top <= y < central.bottom
if not in_central:
return
rel_x = x - central.left
rel_y = y - central.top
if (active_tab := self.active_tab) is None:
return
dest_window = None
dest_in_title_bar = False
opts = get_options()
cw, ch = cell_size_for_window(self.os_window_id)
for win in active_tab:
g = win.geometry
if opts.window_title_bar == 'top':
tb_top, tb_bottom = g.top, g.top + ch
else:
tb_top, tb_bottom = g.bottom - ch, g.bottom
if g.left <= rel_x < g.right and g.top <= rel_y < g.bottom:
dest_window = win
dest_in_title_bar = getattr(win, 'show_title_bar', False) and (tb_top <= rel_y < tb_bottom)
break
if dest_window is None or dest_window.id == window_id:
# Dropped on empty space or self; if different tab, move there
if active_tab is not w.tabref():
boss._move_window_to(w, target_tab_id=active_tab.id)
return
if dest_in_title_bar:
if w.tabref() is dest_window.tabref():
# Same tab: swap positions
boss._swap_windows(w, dest_window)
else:
# Cross-tab title bar drop: move to the destination tab
boss._move_window_to(w, target_tab_id=active_tab.id)
else:
# Quadrant-based directional insert
g = dest_window.geometry
win_cx = (g.left + g.right) / 2
win_cy = (g.top + g.bottom) / 2
dx = rel_x - win_cx
dy = rel_y - win_cy
direction: str = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top')
boss._insert_window_in_direction(w, dest_window, direction)
def update_progress(self) -> None:
self.num_of_windows_with_progress = 0
self.total_progress = 0

View file

@ -660,6 +660,7 @@ class Window:
created_in_session_name: str = ''
serialized_id: int = 0
show_title_bar: bool = False # must be set before calling set_geometry
is_drag_target: bool = False # highlight this window's title bar as a drop target
@classmethod
@contextmanager
@ -1069,7 +1070,7 @@ def update_title_bar(self, is_active: bool = False) -> None:
data = WindowTitleData(
title=self.title or '',
is_active=is_active,
is_active=is_active or self.is_drag_target,
window_id=self.id,
tab_id=self.tab_id,
needs_attention=self.needs_attention,

View file

@ -166,6 +166,8 @@ def is_visible_in_layout(self) -> bool:
class WindowList:
_force_show_title_bars: bool = False
def __init__(self, tab: TabType) -> None:
self.all_windows: list[WindowType] = []
self.id_map: dict[int, WindowType] = {}