From 59c963c4813f13686199755cdca5bd83e62216d8 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 8 Mar 2026 20:56:38 -0400 Subject: [PATCH] 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 --- glfw/cocoa_window.m | 9 +- kitty/boss.py | 105 +++++++++++++++++- kitty/fast_data_types.pyi | 2 + kitty/glfw.c | 11 +- kitty/layout/base.py | 3 +- kitty/layout/splits.py | 22 ++++ kitty/mouse.c | 14 ++- kitty/options/definition.py | 10 ++ kitty/options/parse.py | 3 + kitty/options/types.py | 2 + kitty/state.c | 16 +++ kitty/state.h | 4 + kitty/tabs.py | 214 +++++++++++++++++++++++++++++++++--- kitty/window.py | 3 +- kitty/window_list.py | 2 + 15 files changed, 391 insertions(+), 29 deletions(-) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index fd6c53572..078570e3e 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -909,14 +909,15 @@ static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWw 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 @@ update_drop_state(_GLFWwindow *window, size_t mime_count) { 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; } diff --git a/kitty/boss.py b/kitty/boss.py index 52f9658e6..e7f350355 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -95,6 +95,7 @@ from .fast_data_types import ( 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 @@ from .fast_data_types import ( 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 @@ class Boss: 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 @@ class Boss: 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 @@ class Boss: 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 @@ class Boss: 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 @@ class Boss: 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 @@ class Boss: 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 diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index a7908e2bc..9bb66ab95 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -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 diff --git a/kitty/glfw.c b/kitty/glfw.c index 7daa39f3c..451cdfdd5 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -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; diff --git a/kitty/layout/base.py b/kitty/layout/base.py index 3e5d0b946..bbe95b53a 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -371,7 +371,8 @@ class Layout: # 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 diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index e331c0a73..40e4c6a06 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -684,6 +684,28 @@ class Splits(Layout): 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',) diff --git a/kitty/mouse.c b/kitty/mouse.c index c61aaa3cf..1726e5c66 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -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); diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 35845be98..f4d5a3a5c 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1550,6 +1550,16 @@ opt('window_title_bar_align', 'center', 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() # }}} diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 2c541a988..ef7810907 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1529,6 +1529,9 @@ class Parser: 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) diff --git a/kitty/options/types.py b/kitty/options/types.py index 3502d2fce..5fec0b971 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -504,6 +504,7 @@ option_names = ( '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 diff --git a/kitty/state.c b/kitty/state.c index b324ef234..8ab486b8c 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -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), diff --git a/kitty/state.h b/kitty/state.h index 04f045251..279f93be8 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -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; diff --git a/kitty/tabs.py b/kitty/tabs.py index 7174bd744..a5b4584ac 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -33,6 +33,7 @@ from .fast_data_types import ( 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 @@ from .fast_data_types import ( 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 @@ class Tab: # {{{ 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 @@ class TabManager: # {{{ 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 @@ class TabManager: # {{{ 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 diff --git a/kitty/window.py b/kitty/window.py index b53f7ce66..567d89112 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -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 @@ class Window: 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, diff --git a/kitty/window_list.py b/kitty/window_list.py index bcc8b834f..b0de5684f 100644 --- a/kitty/window_list.py +++ b/kitty/window_list.py @@ -166,6 +166,8 @@ class WindowGroup: 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] = {}