From 59c963c4813f13686199755cdca5bd83e62216d8 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 8 Mar 2026 20:56:38 -0400 Subject: [PATCH 01/18] 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] = {} From 2622a8c6fbe27335cf2ebb67f9791e93f658f20e Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 8 Mar 2026 21:16:52 -0400 Subject: [PATCH 02/18] Fix mypy error: rename loop variable to avoid conflict with walrus assignment --- kitty/boss.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index e7f350355..2818e2174 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1987,10 +1987,10 @@ class Boss: 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() + for t in tm: + if t.force_show_title_bars: + t.force_show_title_bars = False + t.relayout() set_window_being_dragged() for tm in self.all_tab_managers: tm.on_window_drop_move() From 49f6c926e04fca95213c5d7cb5f480aa18d70f71 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Tue, 17 Mar 2026 23:26:55 -0400 Subject: [PATCH 03/18] Fix window drag-drop: force-show title bars and add new-tab drop target - start_window_drag now force-shows title bars on all tabs during a drag so the user can actually drop onto them to trigger a swap. The cleanup code to clear force_show_title_bars already existed but the corresponding set was never written. - on_window_drop_move now only highlights a destination window's title bar when the cursor is actually within the title bar row, so the visual feedback accurately reflects what will happen on drop (swap vs split). - Dropping a window onto the empty space in the tab bar (past the last tab) now creates a new tab and moves the window into it. --- kitty/tabs.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index 0be40ddbe..8384b62d1 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1828,6 +1828,14 @@ class TabManager: # {{{ set_window_being_dragged() return opts = get_options() + if opts.window_title_bar != 'none': + min_w = opts.window_title_bar_min_windows + for tm in boss.all_tab_managers: + for t in tm: + visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True)) + if not (min_w > 0 and visible >= min_w): + t.force_show_title_bars = True + t.relayout() 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) @@ -1905,7 +1913,20 @@ class TabManager: # {{{ 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 + if dest_window and dest_window.id != window_id and dest_window.show_title_bar: + from .fast_data_types import cell_size_for_window + _, ch = cell_size_for_window(self.os_window_id) + g = dest_window.geometry + opts = get_options() + tb_top = g.top if opts.window_title_bar == 'top' else g.bottom - ch + tb_bottom = tb_top + ch + from .fast_data_types import viewport_for_window + central = viewport_for_window(self.os_window_id)[0] + rel_y = y - central.top + in_title_bar = tb_top <= rel_y < tb_bottom + target_id = dest_window.id if in_title_bar else 0 + else: + target_id = 0 self._set_drag_target_window(target_id) def on_window_drop(self, x: int, y: int, window_id: int) -> None: @@ -1923,6 +1944,8 @@ class TabManager: # {{{ 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) + else: + boss._move_window_to(w, target_tab_id='new') return # Case 2: Drop in central area From 39820e79ff78b4cb76c45765cfc03d4b4b82da93 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Tue, 17 Mar 2026 23:36:03 -0400 Subject: [PATCH 04/18] Fix mypy error: remove invalid 'none' check on window_title_bar option --- kitty/tabs.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index 8384b62d1..65ffe3225 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1828,14 +1828,13 @@ class TabManager: # {{{ set_window_being_dragged() return opts = get_options() - if opts.window_title_bar != 'none': - min_w = opts.window_title_bar_min_windows - for tm in boss.all_tab_managers: - for t in tm: - visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True)) - if not (min_w > 0 and visible >= min_w): - t.force_show_title_bars = True - t.relayout() + min_w = opts.window_title_bar_min_windows + for tm in boss.all_tab_managers: + for t in tm: + visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True)) + if not (min_w > 0 and visible >= min_w): + t.force_show_title_bars = True + t.relayout() 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) From 2c81a69aadb4193290fcba814df926d3499deac9 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Wed, 18 Mar 2026 00:30:37 -0400 Subject: [PATCH 05/18] Add drag-and-drop quadrant preview overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a window title-bar drag, render a semi-transparent tint over the destination window to preview the drop outcome: - Full-window tint (quadrant=5) when hovering any window in swap-based layouts (Tall, Stack, Fat, etc.) or over a title bar — swap is the result so the whole window is highlighted. - Half-window directional tint (quadrant 1-4) when hovering in the Splits layout — a real directional insert happens so only the target half is highlighted. - 150ms fade-in; clears immediately on drop or drag exit. Implementation follows the visual_bell TINT_PROGRAM infrastructure: two new Screen fields (start_drag_overlay_at, drag_overlay_quadrant), draw_drag_preview_overlay() in shaders.c called from draw_cells() after both render paths, set_window_drag_overlay() C→Python bridge in state.c, and the same child-monitor.c needs_render + set_maximum_wait hooks that visual_bell uses to keep frames firing during animation. Layout detection uses hasattr(layout, 'insert_window_next_to') — the same guard _insert_window_in_direction uses internally — so the overlay always matches the actual drop behavior. --- kitty/child-monitor.c | 2 ++ kitty/fast_data_types.pyi | 3 +++ kitty/screen.h | 2 ++ kitty/shaders.c | 31 +++++++++++++++++++++++++ kitty/state.c | 20 ++++++++++++++++ kitty/tabs.py | 48 +++++++++++++++++++++++---------------- 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index fbf655ab9..ee609ea25 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -811,6 +811,7 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int * } if (send_cell_data_to_gpu(WD.vao_idx, WD.screen, os_window)) needs_render = true; if (WD.screen->start_visual_bell_at != 0) needs_render = true; + if (WD.screen->start_drag_overlay_at != 0) needs_render = true; // Prepare window title bar screen data for GPU WindowRenderData *trd = &w->window_title_render_data; if (trd->screen && trd->geometry.bottom > trd->geometry.top && trd->geometry.right > trd->geometry.left) { @@ -877,6 +878,7 @@ render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, co if (is_active_window) active_window = w; draw_cells(&WD, os_window, is_active_window, false, num_of_visible_windows == 1, w); if (WD.screen->start_visual_bell_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT); + if (WD.screen->start_drag_overlay_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT); WindowRenderData *trd = &w->window_title_render_data; if (trd->screen && trd->geometry.right > trd->geometry.left && trd->geometry.bottom > trd->geometry.top) draw_cells(trd, os_window, i == tab->active_window, true, false, NULL); diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 9bb66ab95..7b99c1660 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1536,6 +1536,9 @@ def spawn( pass +def set_window_drag_overlay(os_window_id: int, tab_id: int, window_id: int, quadrant: int) -> None: ... + + def set_window_padding(os_window_id: int, tab_id: int, window_id: int, left: int, top: int, right: int, bottom: int) -> None: pass diff --git a/kitty/screen.h b/kitty/screen.h index 092aeaaf2..317b534c0 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -129,6 +129,8 @@ typedef struct { ScreenModes modes, saved_modes; ColorProfile *color_profile; monotonic_t start_visual_bell_at; + monotonic_t start_drag_overlay_at; // 0 = inactive + uint8_t drag_overlay_quadrant; // 1=left 2=right 3=top 4=bottom 0=none uint8_t *write_buf; size_t write_buf_sz, write_buf_used; diff --git a/kitty/shaders.c b/kitty/shaders.c index 1e5d4c8fd..d35f36a40 100644 --- a/kitty/shaders.c +++ b/kitty/shaders.c @@ -787,6 +787,36 @@ draw_visual_bell(const UIRenderData *ui) { #undef COLOR } +static void +draw_drag_preview_overlay(const UIRenderData *ui) { + Screen *screen = ui->screen; + if (!screen->start_drag_overlay_at || !screen->drag_overlay_quadrant) return; + const monotonic_t elapsed = monotonic() - screen->start_drag_overlay_at; + const monotonic_t fade_ms = ms_to_monotonic_t(150ll); + float intensity = elapsed >= fade_ms ? 1.0f : (float)elapsed / (float)fade_ms; + GLfloat left = -1.f, top = 1.f, right = 1.f, bottom = -1.f; + switch (screen->drag_overlay_quadrant) { + case 1: right = 0.f; break; // left half + case 2: left = 0.f; break; // right half + case 3: bottom = 0.f; break; // top half + case 4: top = 0.f; break; // bottom half + case 5: break; // full window (swap) + default: return; + } + bind_program(TINT_PROGRAM); + float a = intensity * 0.25f; +#define COLOR(name, fallback) colorprofile_to_color_with_fallback(screen->color_profile, \ + screen->color_profile->overridden.name, screen->color_profile->configured.name, \ + screen->color_profile->overridden.fallback, screen->color_profile->configured.fallback) + color_type hint = !IS_SPECIAL_COLOR(highlight_bg) ? COLOR(visual_bell_color, highlight_bg) : COLOR(visual_bell_color, default_fg); +#undef COLOR +#define C(shift) (srgb_color((hint >> shift) & 0xFF) * a) + glUniform4f(tint_program_layout.uniforms.tint_color, C(16), C(8), C(0), a); +#undef C + glUniform4f(tint_program_layout.uniforms.edges, left, top, right, bottom); + draw_quad(true, 0); +} + static bool has_scrollbar(Window *w, Screen *screen) { if (screen->linebuf != screen->main_linebuf || !screen->historybuf->count) return false; @@ -1209,6 +1239,7 @@ draw_cells(const WindowRenderData *srd, OSWindow *os_window, bool is_active_wind ui.screen_left, ui.screen_top, ui.screen_width, ui.screen_height, ui.full_framebuffer_height); if (ui.os_window->needs_layers) draw_cells_with_layers(&ui, srd->vao_idx); else draw_cells_without_layers(&ui, srd->vao_idx); + draw_drag_preview_overlay(&ui); restore_viewport(); } // }}} diff --git a/kitty/state.c b/kitty/state.c index e378dcc2a..11a2adc8c 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -864,6 +864,25 @@ PYWRAP1(set_tab_bar_render_data) { Py_RETURN_NONE; } +PYWRAP1(set_window_drag_overlay) { + id_type os_window_id, tab_id, window_id; + int quadrant; + PA("KKKi", &os_window_id, &tab_id, &window_id, &quadrant); + WITH_WINDOW(os_window_id, tab_id, window_id) + Screen *s = window->render_data.screen; + if (s) { + if (quadrant == 0) { + s->start_drag_overlay_at = 0; + s->drag_overlay_quadrant = 0; + } else if (s->drag_overlay_quadrant != (uint8_t)quadrant) { + s->start_drag_overlay_at = monotonic(); + s->drag_overlay_quadrant = (uint8_t)quadrant; + } + } + END_WITH_WINDOW + Py_RETURN_NONE; +} + PYWRAP1(set_window_title_bar_render_data) { WindowGeometry g = {0}; id_type os_window_id, tab_id, window_id; @@ -1632,6 +1651,7 @@ static PyMethodDef module_methods[] = { MW(set_tab_bar_render_data, METH_VARARGS), MW(set_window_title_bar_render_data, METH_VARARGS), MW(set_window_render_data, METH_VARARGS), + MW(set_window_drag_overlay, METH_VARARGS), MW(set_window_padding, METH_VARARGS), MW(viewport_for_window, METH_VARARGS), MW(cell_size_for_window, METH_VARARGS), diff --git a/kitty/tabs.py b/kitty/tabs.py index 65ffe3225..dc62919f3 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1124,6 +1124,7 @@ class TabBeingDropped(NamedTuple): class WindowBeingDropped(NamedTuple): window_id: int # the window whose title bar is currently highlighted as a drop target + quadrant: int = 0 # 0=none, 1=left, 2=right, 3=top, 4=bottom, 5=full(swap) class TabManager: # {{{ @@ -1881,21 +1882,26 @@ class TabManager: # {{{ 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.""" + def _set_drag_target_window(self, window_id: int, quadrant: int = 0) -> None: + """Highlight window_id's title bar as the drop target; 0 clears. quadrant!=0 shows quadrant overlay instead.""" + from .fast_data_types import set_window_drag_overlay boss = get_boss() prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0 - if prev_id == window_id: + prev_quadrant = self.window_being_dropped.quadrant if self.window_being_dropped else 0 + if prev_id == window_id and prev_quadrant == quadrant: return if prev_id and (prev_w := boss.window_id_map.get(prev_id)): prev_w.is_drag_target = False + set_window_drag_overlay(self.os_window_id, prev_w.tab_id, prev_id, 0) 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) + if quadrant == 5: + new_w.is_drag_target = True + new_w.update_title_bar(is_active=True) + set_window_drag_overlay(self.os_window_id, new_w.tab_id, window_id, quadrant) + self.window_being_dropped = WindowBeingDropped(window_id=window_id, quadrant=quadrant) else: self.window_being_dropped = None @@ -1912,21 +1918,23 @@ class TabManager: # {{{ return self._set_drag_target_tab(0) dest_window = self._find_window_at(x, y) - if dest_window and dest_window.id != window_id and dest_window.show_title_bar: - from .fast_data_types import cell_size_for_window - _, ch = cell_size_for_window(self.os_window_id) - g = dest_window.geometry - opts = get_options() - tb_top = g.top if opts.window_title_bar == 'top' else g.bottom - ch - tb_bottom = tb_top + ch - from .fast_data_types import viewport_for_window - central = viewport_for_window(self.os_window_id)[0] - rel_y = y - central.top - in_title_bar = tb_top <= rel_y < tb_bottom - target_id = dest_window.id if in_title_bar else 0 + if dest_window and dest_window.id != window_id: + quadrant = 5 + active_tab = self.active_tab + if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'): + from .fast_data_types import viewport_for_window as _vfw + central = _vfw(self.os_window_id)[0] + rel_x = x - central.left + rel_y = y - central.top + g = dest_window.geometry + dx = rel_x - (g.left + g.right) / 2 + dy = rel_y - (g.top + g.bottom) / 2 + quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4} + direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') + quadrant = quad_map[direction] + self._set_drag_target_window(dest_window.id, quadrant) else: - target_id = 0 - self._set_drag_target_window(target_id) + self._set_drag_target_window(0) 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 From 2bf4da97241ce30dd6bff9d86af8a6038549e2ac Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 20 Mar 2026 01:16:59 -0400 Subject: [PATCH 06/18] Fix: full-window overlay for title bar hover in Splits layout --- kitty/tabs.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index dc62919f3..fa9bf96db 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1919,20 +1919,29 @@ 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: - quadrant = 5 - active_tab = self.active_tab - if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'): - from .fast_data_types import viewport_for_window as _vfw - central = _vfw(self.os_window_id)[0] - rel_x = x - central.left - rel_y = y - central.top + from .fast_data_types import viewport_for_window as _vfw + central = _vfw(self.os_window_id)[0] + rel_x = x - central.left + rel_y = y - central.top + in_title_bar = False + if dest_window.show_title_bar: + from .fast_data_types import cell_size_for_window + _, ch = cell_size_for_window(self.os_window_id) g = dest_window.geometry - dx = rel_x - (g.left + g.right) / 2 - dy = rel_y - (g.top + g.bottom) / 2 - quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4} - direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') - quadrant = quad_map[direction] - self._set_drag_target_window(dest_window.id, quadrant) + opts = get_options() + tb_top = g.top if opts.window_title_bar == 'top' else g.bottom - ch + in_title_bar = tb_top <= rel_y < tb_top + ch + if not in_title_bar: + active_tab = self.active_tab + if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'): + g = dest_window.geometry + dx = rel_x - (g.left + g.right) / 2 + dy = rel_y - (g.top + g.bottom) / 2 + quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4} + direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') + self._set_drag_target_window(dest_window.id, quad_map[direction]) + return + self._set_drag_target_window(dest_window.id, 5) else: self._set_drag_target_window(0) From 940b8bf1d3eb434208a5200a5812e03b02405631 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 20 Mar 2026 01:23:23 -0400 Subject: [PATCH 07/18] Fix drag overlay: separate title bar highlight from body hover overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quadrant=5: title bar hover — full window overlay + title bar highlight quadrant=6: body hover (non-Splits) — full window overlay only quadrant=1-4: Splits body hover — directional half-window overlay Previously quadrant=5 was used for all full-window cases, causing is_drag_target=True to fire on body hover which incorrectly lit up the title bar highlight whenever hovering any window body. --- kitty/shaders.c | 3 ++- kitty/tabs.py | 31 +++++++++++++++++-------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/kitty/shaders.c b/kitty/shaders.c index d35f36a40..6404b7590 100644 --- a/kitty/shaders.c +++ b/kitty/shaders.c @@ -800,7 +800,8 @@ draw_drag_preview_overlay(const UIRenderData *ui) { case 2: left = 0.f; break; // right half case 3: bottom = 0.f; break; // top half case 4: top = 0.f; break; // bottom half - case 5: break; // full window (swap) + case 5: break; // full window + title bar highlight (title bar hover) + case 6: break; // full window, no title bar highlight (body hover) default: return; } bind_program(TINT_PROGRAM); diff --git a/kitty/tabs.py b/kitty/tabs.py index fa9bf96db..3d330aef3 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1124,7 +1124,7 @@ class TabBeingDropped(NamedTuple): class WindowBeingDropped(NamedTuple): window_id: int # the window whose title bar is currently highlighted as a drop target - quadrant: int = 0 # 0=none, 1=left, 2=right, 3=top, 4=bottom, 5=full(swap) + quadrant: int = 0 # 0=none, 1=left, 2=right, 3=top, 4=bottom, 5=full+titlebar, 6=full class TabManager: # {{{ @@ -1921,27 +1921,30 @@ class TabManager: # {{{ 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_x = x - central.left rel_y = y - central.top - in_title_bar = False if dest_window.show_title_bar: from .fast_data_types import cell_size_for_window _, ch = cell_size_for_window(self.os_window_id) g = dest_window.geometry opts = get_options() tb_top = g.top if opts.window_title_bar == 'top' else g.bottom - ch - in_title_bar = tb_top <= rel_y < tb_top + ch - if not in_title_bar: - active_tab = self.active_tab - if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'): - g = dest_window.geometry - dx = rel_x - (g.left + g.right) / 2 - dy = rel_y - (g.top + g.bottom) / 2 - quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4} - direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') - self._set_drag_target_window(dest_window.id, quad_map[direction]) + if tb_top <= rel_y < tb_top + ch: + # Title bar hover: full window + title bar highlight (swap) + self._set_drag_target_window(dest_window.id, 5) return - self._set_drag_target_window(dest_window.id, 5) + active_tab = self.active_tab + if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'): + # Splits layout body hover: directional half-window overlay + rel_x = x - central.left + g = dest_window.geometry + dx = rel_x - (g.left + g.right) / 2 + dy = rel_y - (g.top + g.bottom) / 2 + quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4} + direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') + self._set_drag_target_window(dest_window.id, quad_map[direction]) + else: + # All other body hover: full window overlay only (reorder/swap, no title bar flash) + self._set_drag_target_window(dest_window.id, 6) else: self._set_drag_target_window(0) From 5c0a4accb383d40bdae9ae092cbca7356d079b8a Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 20 Mar 2026 01:34:01 -0400 Subject: [PATCH 08/18] Fix two bugs: splits layout corruption and overlay stuck on early drop exit splits.py: insert_window_next_to called split_and_add on self.pairs_root instead of on the pair found by pair_for_window. split_and_add only handles direct children, so nested dest windows fell to 'else: self.two = pair', silently replacing an entire subtree. Lost windows were re-added by do_layout, producing phantom panes. tabs.py: on_window_drop returned early (window not found) before calling _clear_force_show_title_bars, leaving the drag overlay stuck on screen. --- kitty/layout/splits.py | 2 +- kitty/tabs.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 245f000b2..05376695d 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -740,7 +740,7 @@ class Splits(Layout): # 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) + pair.split_and_add(dest_wg.id, src_wg.id, horizontal, after) else: self.pairs_root.balanced_add(src_wg.id) diff --git a/kitty/tabs.py b/kitty/tabs.py index 3d330aef3..ed23f19c4 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1858,8 +1858,10 @@ class TabManager: # {{{ self.mark_tab_bar_dirty() def _clear_force_show_title_bars(self) -> None: + log_error('DRAG DEBUG: _clear_force_show_title_bars called') boss = get_boss() for tm in boss.all_tab_managers: + log_error(f'DRAG DEBUG: clearing tm={tm.os_window_id} window_being_dropped={tm.window_being_dropped}') tm._set_drag_target_window(0) tm._set_drag_target_tab(0) for tab in tm: @@ -1888,18 +1890,23 @@ class TabManager: # {{{ boss = get_boss() prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0 prev_quadrant = self.window_being_dropped.quadrant if self.window_being_dropped else 0 + log_error(f'DRAG DEBUG: _set_drag_target_window window_id={window_id} quadrant={quadrant} prev_id={prev_id} prev_quadrant={prev_quadrant}') if prev_id == window_id and prev_quadrant == quadrant: return if prev_id and (prev_w := boss.window_id_map.get(prev_id)): + log_error(f'DRAG DEBUG: clearing overlay on prev_id={prev_id} tab_id={prev_w.tab_id}') prev_w.is_drag_target = False set_window_drag_overlay(self.os_window_id, prev_w.tab_id, prev_id, 0) 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) + elif prev_id: + log_error(f'DRAG DEBUG: prev_id={prev_id} not found in window_id_map — overlay may be stuck!') if window_id and (new_w := boss.window_id_map.get(window_id)): if quadrant == 5: new_w.is_drag_target = True new_w.update_title_bar(is_active=True) + log_error(f'DRAG DEBUG: setting overlay on window_id={window_id} tab_id={new_w.tab_id} quadrant={quadrant}') set_window_drag_overlay(self.os_window_id, new_w.tab_id, window_id, quadrant) self.window_being_dropped = WindowBeingDropped(window_id=window_id, quadrant=quadrant) else: @@ -1953,6 +1960,7 @@ class TabManager: # {{{ boss = get_boss() w = boss.window_id_map.get(window_id) if w is None: + self._clear_force_show_title_bars() return self._clear_force_show_title_bars() set_window_being_dragged() From 8dd4aaf111b7661407af8cff5138756518d4c86a Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 20 Mar 2026 01:37:19 -0400 Subject: [PATCH 09/18] Remove debug logging; clean up on_window_drop clear ordering --- kitty/tabs.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index ed23f19c4..c47136fa6 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1858,10 +1858,8 @@ class TabManager: # {{{ self.mark_tab_bar_dirty() def _clear_force_show_title_bars(self) -> None: - log_error('DRAG DEBUG: _clear_force_show_title_bars called') boss = get_boss() for tm in boss.all_tab_managers: - log_error(f'DRAG DEBUG: clearing tm={tm.os_window_id} window_being_dropped={tm.window_being_dropped}') tm._set_drag_target_window(0) tm._set_drag_target_tab(0) for tab in tm: @@ -1890,23 +1888,18 @@ class TabManager: # {{{ boss = get_boss() prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0 prev_quadrant = self.window_being_dropped.quadrant if self.window_being_dropped else 0 - log_error(f'DRAG DEBUG: _set_drag_target_window window_id={window_id} quadrant={quadrant} prev_id={prev_id} prev_quadrant={prev_quadrant}') if prev_id == window_id and prev_quadrant == quadrant: return if prev_id and (prev_w := boss.window_id_map.get(prev_id)): - log_error(f'DRAG DEBUG: clearing overlay on prev_id={prev_id} tab_id={prev_w.tab_id}') prev_w.is_drag_target = False set_window_drag_overlay(self.os_window_id, prev_w.tab_id, prev_id, 0) 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) - elif prev_id: - log_error(f'DRAG DEBUG: prev_id={prev_id} not found in window_id_map — overlay may be stuck!') if window_id and (new_w := boss.window_id_map.get(window_id)): if quadrant == 5: new_w.is_drag_target = True new_w.update_title_bar(is_active=True) - log_error(f'DRAG DEBUG: setting overlay on window_id={window_id} tab_id={new_w.tab_id} quadrant={quadrant}') set_window_drag_overlay(self.os_window_id, new_w.tab_id, window_id, quadrant) self.window_being_dropped = WindowBeingDropped(window_id=window_id, quadrant=quadrant) else: @@ -1958,11 +1951,10 @@ class TabManager: # {{{ 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() + self._clear_force_show_title_bars() w = boss.window_id_map.get(window_id) if w is None: - self._clear_force_show_title_bars() return - self._clear_force_show_title_bars() set_window_being_dragged() central, tab_bar = viewport_for_window(self.os_window_id)[:2] From b8da1d285f904d7cf0ce314c61f89269499f766b Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 20 Mar 2026 20:48:10 -0400 Subject: [PATCH 10/18] Add '+' tab indicator when dragging a window over empty tab bar space Appends a synthetic TabBarData(tab_id=-1, title='+') to tab_bar_data while a window drag is active. The existing tab highlight machinery (_set_drag_target_tab / mark_tab_bar_dirty) handles hover highlighting with no extra state. Dropping on it falls through the existing new-tab branch in on_window_drop (tab_for_id(-1) returns None). Mark tab bar dirty at drag start so the indicator appears immediately, and again after set_window_being_dragged() on drop so it clears right away. --- kitty/tabs.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index c47136fa6..0b69bbc53 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1594,6 +1594,12 @@ class TabManager: # {{{ self.mark_tab_bar_dirty() removed_tab.destroy() + def _new_tab_drop_indicator(self) -> TabBarData: + return TabBarData( + '+', self.window_drag_target_tab_id == -1, False, -1, self.os_window_id, + 0, 0, '', False, None, None, None, None, 0, 0, 0, '', '', + ) + @property def tab_bar_data(self) -> Sequence[TabBarData]: at = self.active_tab @@ -1601,11 +1607,16 @@ class TabManager: # {{{ dragged_tab_id, drag_started = get_tab_being_dragged()[:2] if drag_started: tab_being_dragged_from_here = self.tab_for_id(dragged_tab_id) is not None + window_drag_active = get_window_being_dragged()[1] 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 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) + tabs = 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) + else: + tabs = 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 window_drag_active: + tabs = tabs + (self._new_tab_drop_indicator(),) + return tabs tmap = {t.id:t for t in self.tabs} at = self.active_tab ans = [] @@ -1831,6 +1842,7 @@ class TabManager: # {{{ opts = get_options() min_w = opts.window_title_bar_min_windows for tm in boss.all_tab_managers: + tm.mark_tab_bar_dirty() for t in tm: visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True)) if not (min_w > 0 and visible >= min_w): @@ -1956,6 +1968,7 @@ class TabManager: # {{{ if w is None: return set_window_being_dragged() + self.mark_tab_bar_dirty() central, tab_bar = viewport_for_window(self.os_window_id)[:2] # Case 1: Drop on tab bar → move to that tab From 412cd30cd2fe4feeff853f99f24bd94ef51b8bf3 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 22 Mar 2026 13:06:30 -0400 Subject: [PATCH 11/18] Fix synthetic '+' tab rendering and make it always-visible as a new-tab button - Skip user tab_title_template for synthetic tabs (tab_id < 0) so custom templates referencing layout_name no longer produce '? +' during drags - Always append the '+' drop indicator to tab_bar_data (not only during window drags), making it a persistent clickable new-tab button - Handle click on the '+' tab (tab_id == -1) in the tab bar mouse handler by calling new_tab() on left-button release --- kitty/tab_bar.py | 2 ++ kitty/tabs.py | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index d42da165b..d52bfa389 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -265,6 +265,8 @@ safe_builtins = { def apply_title_template(draw_data: DrawData, tab: TabBarData, index: int, max_title_length: int = 0) -> str: + if tab.tab_id < 0: + return tab.title # synthetic tab — render title literally, skip user template ta = TabAccessor(tab.tab_id) data = { 'index': index, diff --git a/kitty/tabs.py b/kitty/tabs.py index 0b69bbc53..39815262a 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1607,15 +1607,13 @@ class TabManager: # {{{ dragged_tab_id, drag_started = get_tab_being_dragged()[:2] if drag_started: tab_being_dragged_from_here = self.tab_for_id(dragged_tab_id) is not None - window_drag_active = get_window_being_dragged()[1] if self.tab_being_dropped is None: wdtt = self.window_drag_target_tab_id if tab_being_dragged_from_here: tabs = 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) else: tabs = 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 window_drag_active: - tabs = tabs + (self._new_tab_drop_indicator(),) + tabs = tabs + (self._new_tab_drop_indicator(),) return tabs tmap = {t.id:t for t in self.tabs} at = self.active_tab @@ -1746,7 +1744,12 @@ class TabManager: # {{{ request_callback_with_thumbnail("start_tab_drag", self.os_window_id) return - tab = self.tab_for_id(self.tab_bar.tab_id_at(int(x))) + tab_id_at_x = self.tab_bar.tab_id_at(int(x)) + if tab_id_at_x == -1: + if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE: + self.new_tab() + return + tab = self.tab_for_id(tab_id_at_x) now = monotonic() if tab is None: if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE and len(self.recent_mouse_events) > 2: From 3e85db684d1157fa2f00d4974259214417663ed7 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 22 Mar 2026 17:13:08 -0400 Subject: [PATCH 12/18] Make always-visible '+' new tab button opt-in via tab_bar_show_new_tab_button Default is 'no' to avoid forcing the button on all kitty users. When disabled, the '+' indicator still appears during window drags as a drop target (existing behaviour). Set to 'yes' to keep it permanently visible as a clickable button. --- kitty/options/definition.py | 10 ++++++++++ kitty/tabs.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index d6dc751b0..f660e71dc 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1635,6 +1635,16 @@ The horizontal alignment of the tab bar, can be one of: :code:`left`, ''' ) +opt('tab_bar_show_new_tab_button', 'no', option_type='to_bool', ctype='bool', + long_text=''' +When set to :code:`yes`, a :code:`+` button is always shown at the end of the +tab bar as a clickable shortcut to open a new tab. When set to :code:`no` +(the default), the button is hidden at rest but still appears temporarily +while a window is being dragged, so it can be used as a drop target to open +the window in a new tab. +''' + ) + opt('tab_bar_min_tabs', '2', option_type='tab_bar_min_tabs', long_text='The minimum number of tabs that must exist before the tab bar is shown.' ) diff --git a/kitty/tabs.py b/kitty/tabs.py index 39815262a..e68034729 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1607,13 +1607,15 @@ class TabManager: # {{{ dragged_tab_id, drag_started = get_tab_being_dragged()[:2] if drag_started: tab_being_dragged_from_here = self.tab_for_id(dragged_tab_id) is not None + window_drag_active = get_window_being_dragged()[1] if self.tab_being_dropped is None: wdtt = self.window_drag_target_tab_id if tab_being_dragged_from_here: tabs = 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) else: tabs = tuple(t.data_for_tab_bar(t is at or t.id == wdtt) for t in self.tabs_to_be_shown_in_tab_bar) - tabs = tabs + (self._new_tab_drop_indicator(),) + if window_drag_active or get_options().tab_bar_show_new_tab_button: + tabs = tabs + (self._new_tab_drop_indicator(),) return tabs tmap = {t.id:t for t in self.tabs} at = self.active_tab From fd6940a0aa7fca58c570bc6564282455a27063cc Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 22 Mar 2026 17:16:22 -0400 Subject: [PATCH 13/18] Wire tab_bar_show_new_tab_button option into types.py and parse.py Registers the new bool option so kitty can parse it from kitty.conf and expose it via get_options(). Default is False (off). --- kitty/options/parse.py | 3 +++ kitty/options/types.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/kitty/options/parse.py b/kitty/options/parse.py index ef7810907..f9fb172b5 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1351,6 +1351,9 @@ class Parser: def tab_bar_min_tabs(self, val: str, ans: dict[str, typing.Any]) -> None: ans['tab_bar_min_tabs'] = tab_bar_min_tabs(val) + def tab_bar_show_new_tab_button(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['tab_bar_show_new_tab_button'] = to_bool(val) + def tab_bar_style(self, val: str, ans: dict[str, typing.Any]) -> None: val = val.lower() if val not in self.choices_for_tab_bar_style: diff --git a/kitty/options/types.py b/kitty/options/types.py index 5fec0b971..0cd5f4576 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -460,6 +460,7 @@ option_names = ( 'tab_bar_margin_height', 'tab_bar_margin_width', 'tab_bar_min_tabs', + 'tab_bar_show_new_tab_button', 'tab_bar_style', 'tab_fade', 'tab_powerline_style', @@ -663,6 +664,7 @@ class Options: tab_bar_margin_height: TabBarMarginHeight = TabBarMarginHeight(outer=0, inner=0) tab_bar_margin_width: float = 0 tab_bar_min_tabs: int = 2 + tab_bar_show_new_tab_button: bool = False tab_bar_style: choices_for_tab_bar_style = 'fade' tab_fade: tuple[float, ...] = (0.25, 0.5, 0.75, 1.0) tab_powerline_style: choices_for_tab_powerline_style = 'angled' From 82c6516718b5eea9128bedfabfd6fafbc3f663e2 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Sun, 22 Mar 2026 18:38:12 -0400 Subject: [PATCH 14/18] Use tab_id < 0 instead of == -1 for synthetic tab check --- kitty/tabs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kitty/tabs.py b/kitty/tabs.py index e68034729..5d528ff84 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1747,7 +1747,7 @@ class TabManager: # {{{ return tab_id_at_x = self.tab_bar.tab_id_at(int(x)) - if tab_id_at_x == -1: + if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button) if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE: self.new_tab() return From 606aa1543ee78aca2fbb7e878634dea348ef0224 Mon Sep 17 00:00:00 2001 From: mcrmck Date: Mon, 23 Mar 2026 21:44:48 -0400 Subject: [PATCH 15/18] Fix multi-OS-window drag bugs and KeyError crash on tab drag Two fixes: 1. boss.py: scope window drag is_dest to the receiving OS window only. Previously all tab managers got is_dest=True with the same window-local coordinates. Since every OS window's viewport starts at (0,0), a drag at (x,y) in Window 1 also matched windows in Window 2 at the same coords, causing spurious highlights and incorrect drop-target state in the second window. 2. tabs.py: filter synthetic tab IDs (< 0) from all_tabs in on_tab_drop_move. The '+' new-tab indicator uses tab_id=-1. If a tab drag started while window_drag_active or tab_bar_show_new_tab_button was set, the -1 ended up in tab_being_dropped.tab_ids, then tab_bar_data crashed with KeyError when it tried tmap[-1]. --- kitty/boss.py | 2 +- kitty/tabs.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 8dc58060c..0cc9528b5 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1934,7 +1934,7 @@ class Boss: 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) + q.on_window_drop_move(window_id, (not is_leave) and (q is tm), 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): diff --git a/kitty/tabs.py b/kitty/tabs.py index 5d528ff84..3861a8d36 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1644,9 +1644,9 @@ class TabManager: # {{{ self.layout_tab_bar() return if self.tab_bar_should_be_visible: - all_tabs = [t.tab_id for t in self.tab_bar.last_laid_out_tabs] + all_tabs = [t.tab_id for t in self.tab_bar.last_laid_out_tabs if t.tab_id >= 0] else: - all_tabs = [t.tab_id for t in self.tab_bar_data] + all_tabs = [t.tab_id for t in self.tab_bar_data if t.tab_id >= 0] force_update = False if self.tab_being_dropped is None: tab = get_boss().tab_for_id(tab_id) From a368a90e3738208ee0a9e7e7e003b8391ead761d Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 27 Mar 2026 02:08:41 -0400 Subject: [PATCH 16/18] Add directional drag-and-drop inserts for Vertical, Horizontal, Tall, Fat, Grid Previously, body drops in all non-Splits layouts showed a full-window overlay and performed a positional swap. This adds proper top/bottom or left/right half-window overlays and true before/after insertion for the five layouts Kovid identified. Architecture: - New `drag_overlay_mode` ClassVar on Layout ('full'|'axis_y'|'axis_x'|'free') controls both overlay display and valid direction axis. Layout subclasses set one line; tabs.py and boss.py dispatch on this attribute instead of hasattr. - New `insert_window_group_next_to(target_group_id, after)` on WindowList performs a positional insert (not swap) by popping the active group and inserting it before or after the target. - New base `insert_window_next_to` on Layout uses insert_window_group_next_to for axis_x/axis_y layouts and falls back to swap for 'full' (Stack). Splits overrides this with its existing tree-based implementation. - `_insert_window_in_direction` in boss.py collapses from a 7-line hasattr branch to a single layout.insert_window_next_to() call. Direction constraints: Vertical, Tall, Grid -> top/bottom (axis_y) Horizontal, Fat -> left/right (axis_x) Splits -> 4-way free (unchanged) Stack -> full-window swap (unchanged) --- kitty/boss.py | 8 +------- kitty/layout/base.py | 33 ++++++++++++++++++++++++++++++++- kitty/layout/grid.py | 3 ++- kitty/layout/splits.py | 3 ++- kitty/layout/tall.py | 4 +++- kitty/layout/vertical.py | 4 +++- kitty/tabs.py | 32 +++++++++++++++++++++----------- kitty/window_list.py | 21 +++++++++++++++++++++ 8 files changed, 85 insertions(+), 23 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 0cc9528b5..8f09a8576 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -3340,13 +3340,7 @@ class Boss: 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) + layout.insert_window_next_to(src_tab.windows, window, dest_window, horizontal, after) src_tab.relayout() def _move_tab_to(self, tab: Tab | None = None, target_os_window_id: int | None = None) -> Tab | None: diff --git a/kitty/layout/base.py b/kitty/layout/base.py index bbe95b53a..08bade8f6 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -4,7 +4,7 @@ from collections.abc import Generator, Iterable, Iterator, Sequence from functools import partial from itertools import repeat -from typing import Any, Callable, NamedTuple +from typing import Any, Callable, ClassVar, Literal, NamedTuple from kitty.borders import BorderColor from kitty.fast_data_types import BOTTOM_EDGE, RIGHT_EDGE, Region, get_options, set_active_window, viewport_for_window @@ -232,6 +232,12 @@ class Layout: must_draw_borders = False # can be overridden to customize behavior from kittens layout_opts = LayoutOpts({}) only_active_window_visible = False + # Controls drag-and-drop overlay display and valid direction axis for body drops. + # 'full' – full-window overlay, positional swap (Stack and any unrecognised layout) + # 'axis_y' – top/bottom halves only (Vertical, Tall, Grid) + # 'axis_x' – left/right halves only (Horizontal, Fat) + # 'free' – 4-way free direction (Splits; handled by its own insert_window_next_to override) + drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = 'full' def __init__(self, os_window_id: int, tab_id: int, layout_opts: str = '') -> None: self.set_owner(os_window_id, tab_id) @@ -303,6 +309,31 @@ class Layout: def move_window_to_group(self, all_windows: WindowList, group: int) -> bool: return all_windows.move_window_group(to_group=group) + def insert_window_next_to( + self, + all_windows: WindowList, + window: WindowType, + next_to: WindowType, + horizontal: bool, + after: bool, + ) -> None: + """Reposition window as a linear neighbour of next_to. + + For axis_x/axis_y layouts this performs a positional insert that preserves + the order of all other groups. For 'full' layouts it falls back to a swap. + The Splits layout overrides this with tree-based logic. + """ + 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 + all_windows.set_active_window_group_for(window) + if self.drag_overlay_mode in ('axis_x', 'axis_y'): + all_windows.insert_window_group_next_to(dest_wg.id, after) + else: + # 'full' fallback: swap (preserves existing behaviour for Stack etc.) + self.move_window_to_group(all_windows, dest_wg.id) + def add_window( self, all_windows: WindowList, window: WindowType, location: str | None = None, overlay_for: int | None = None, put_overlay_behind: bool = False, bias: float | None = None, diff --git a/kitty/layout/grid.py b/kitty/layout/grid.py index 34caa15f0..c34a22671 100644 --- a/kitty/layout/grid.py +++ b/kitty/layout/grid.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Iterator, Sequence from functools import lru_cache from itertools import repeat from math import ceil, floor -from typing import Any +from typing import Any, ClassVar, Literal from kitty.borders import BorderColor from kitty.types import Edges, NeighborsMap, WindowMapper @@ -34,6 +34,7 @@ class Grid(Layout): name: str = 'grid' no_minimal_window_borders = True + drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y' def remove_all_biases(self) -> bool: self.biased_rows: dict[int, float] = {} diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 05376695d..617d9681b 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -2,7 +2,7 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal from collections.abc import Collection, Generator, Iterator, Sequence -from typing import Any, Optional, TypedDict, Union +from typing import Any, ClassVar, Literal, Optional, TypedDict, Union from kitty.borders import BorderColor from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE @@ -561,6 +561,7 @@ class Splits(Layout): needs_all_windows = True layout_opts = SplitsLayoutOpts({}) no_minimal_window_borders = True + drag_overlay_mode: ClassVar[Literal['free']] = 'free' @property def default_axis_is_horizontal(self) -> bool | None: diff --git a/kitty/layout/tall.py b/kitty/layout/tall.py index 31cfd6c76..f2ee2717e 100644 --- a/kitty/layout/tall.py +++ b/kitty/layout/tall.py @@ -4,7 +4,7 @@ import sys from collections.abc import Generator, Iterator, Sequence from itertools import islice, repeat -from typing import Any +from typing import Any, ClassVar, Literal from kitty.borders import BorderColor from kitty.conf.utils import to_bool @@ -136,6 +136,7 @@ class Tall(Layout): name = 'tall' main_is_horizontal = True no_minimal_window_borders = True + drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y' layout_opts = TallLayoutOpts({}) main_axis_layout = Layout.xlayout perp_axis_layout = Layout.ylayout @@ -381,5 +382,6 @@ class Fat(Tall): name = 'fat' main_is_horizontal = False + drag_overlay_mode: ClassVar[Literal['axis_x']] = 'axis_x' main_axis_layout = Layout.ylayout perp_axis_layout = Layout.xlayout diff --git a/kitty/layout/vertical.py b/kitty/layout/vertical.py index fdf231db0..7f0d55e7d 100644 --- a/kitty/layout/vertical.py +++ b/kitty/layout/vertical.py @@ -2,7 +2,7 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal from collections.abc import Generator, Iterable -from typing import Any +from typing import Any, ClassVar, Literal from kitty.borders import BorderColor from kitty.types import Edges, NeighborsMap, WindowMapper @@ -64,6 +64,7 @@ class Vertical(Layout): name = 'vertical' main_is_horizontal = False no_minimal_window_borders = True + drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y' main_axis_layout = Layout.ylayout perp_axis_layout = Layout.xlayout @@ -155,5 +156,6 @@ class Horizontal(Vertical): name = 'horizontal' main_is_horizontal = True + drag_overlay_mode: ClassVar[Literal['axis_x']] = 'axis_x' main_axis_layout = Layout.xlayout perp_axis_layout = Layout.ylayout diff --git a/kitty/tabs.py b/kitty/tabs.py index 3861a8d36..dddea1609 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1950,18 +1950,25 @@ class TabManager: # {{{ self._set_drag_target_window(dest_window.id, 5) return active_tab = self.active_tab - if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'): - # Splits layout body hover: directional half-window overlay + if active_tab is not None: + mode = active_tab.current_layout.drag_overlay_mode rel_x = x - central.left g = dest_window.geometry dx = rel_x - (g.left + g.right) / 2 dy = rel_y - (g.top + g.bottom) / 2 quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4} - direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') + if mode == 'axis_y': + direction = 'bottom' if dy > 0 else 'top' + elif mode == 'axis_x': + direction = 'right' if dx > 0 else 'left' + elif mode == 'free': + direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top') + else: # 'full' (Stack, etc.): full-window overlay, no directional highlight + self._set_drag_target_window(dest_window.id, 6) + return self._set_drag_target_window(dest_window.id, quad_map[direction]) else: - # All other body hover: full window overlay only (reorder/swap, no title bar flash) - self._set_drag_target_window(dest_window.id, 6) + self._set_drag_target_window(0) else: self._set_drag_target_window(0) @@ -2024,13 +2031,16 @@ class TabManager: # {{{ # 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') + dx = rel_x - (g.left + g.right) / 2 + dy = rel_y - (g.top + g.bottom) / 2 + mode = active_tab.current_layout.drag_overlay_mode + if mode == 'axis_y': + direction: str = 'bottom' if dy > 0 else 'top' + elif mode == 'axis_x': + direction = 'right' if dx > 0 else 'left' + else: # 'free' (Splits) or 'full' (swap fallback) + direction = ('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: diff --git a/kitty/window_list.py b/kitty/window_list.py index b0de5684f..dde3fc193 100644 --- a/kitty/window_list.py +++ b/kitty/window_list.py @@ -519,6 +519,27 @@ class WindowList: return True return False + def insert_window_group_next_to(self, target_group_id: int, after: bool) -> bool: + """Move the active window group immediately before or after target_group_id. + + Unlike move_window_group (which swaps), this is a positional insert that + preserves the relative order of all other groups. + """ + src_idx = self.active_group_idx + if src_idx < 0 or not self.groups: + return False + target_idx = next((i for i, g in enumerate(self.groups) if g.id == target_group_id), -1) + if target_idx < 0 or src_idx == target_idx: + return False + group = self.groups.pop(src_idx) + # All indices above src shift down by one after the pop + if src_idx < target_idx: + target_idx -= 1 + insert_pos = target_idx + (1 if after else 0) + self.groups.insert(insert_pos, group) + self.set_active_group_idx(insert_pos) + return True + def compute_needs_borders_map(self, draw_active_borders: bool) -> dict[int, bool]: ag = self.active_group return {gr.id: ((gr is ag and draw_active_borders) or gr.needs_attention) for gr in self.groups} From d1b8df697541eeab46555c9e9e2c0d09b2a1c7fc Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 27 Mar 2026 02:17:59 -0400 Subject: [PATCH 17/18] Fix mypy error: remove narrowing ClassVar[Literal] annotations from layout subclasses Horizontal extends Vertical, and Fat extends Tall. Declaring drag_overlay_mode with a narrower Literal type in the subclass conflicts with the parent's declared type, causing mypy error "Incompatible types in assignment". Since the base Layout class already declares the full union type, subclasses only need a bare assignment. Also removes now-unused ClassVar and Literal imports from vertical.py, tall.py, and grid.py. --- kitty/layout/grid.py | 4 ++-- kitty/layout/splits.py | 2 +- kitty/layout/tall.py | 6 +++--- kitty/layout/vertical.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/kitty/layout/grid.py b/kitty/layout/grid.py index c34a22671..5a7fcda9c 100644 --- a/kitty/layout/grid.py +++ b/kitty/layout/grid.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Iterator, Sequence from functools import lru_cache from itertools import repeat from math import ceil, floor -from typing import Any, ClassVar, Literal +from typing import Any from kitty.borders import BorderColor from kitty.types import Edges, NeighborsMap, WindowMapper @@ -34,7 +34,7 @@ class Grid(Layout): name: str = 'grid' no_minimal_window_borders = True - drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y' + drag_overlay_mode = 'axis_y' def remove_all_biases(self) -> bool: self.biased_rows: dict[int, float] = {} diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 617d9681b..b7bbaa92a 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -561,7 +561,7 @@ class Splits(Layout): needs_all_windows = True layout_opts = SplitsLayoutOpts({}) no_minimal_window_borders = True - drag_overlay_mode: ClassVar[Literal['free']] = 'free' + drag_overlay_mode = 'free' @property def default_axis_is_horizontal(self) -> bool | None: diff --git a/kitty/layout/tall.py b/kitty/layout/tall.py index f2ee2717e..2a1f1f614 100644 --- a/kitty/layout/tall.py +++ b/kitty/layout/tall.py @@ -4,7 +4,7 @@ import sys from collections.abc import Generator, Iterator, Sequence from itertools import islice, repeat -from typing import Any, ClassVar, Literal +from typing import Any from kitty.borders import BorderColor from kitty.conf.utils import to_bool @@ -136,7 +136,7 @@ class Tall(Layout): name = 'tall' main_is_horizontal = True no_minimal_window_borders = True - drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y' + drag_overlay_mode = 'axis_y' layout_opts = TallLayoutOpts({}) main_axis_layout = Layout.xlayout perp_axis_layout = Layout.ylayout @@ -382,6 +382,6 @@ class Fat(Tall): name = 'fat' main_is_horizontal = False - drag_overlay_mode: ClassVar[Literal['axis_x']] = 'axis_x' + drag_overlay_mode = 'axis_x' main_axis_layout = Layout.ylayout perp_axis_layout = Layout.xlayout diff --git a/kitty/layout/vertical.py b/kitty/layout/vertical.py index 7f0d55e7d..c69dde0dd 100644 --- a/kitty/layout/vertical.py +++ b/kitty/layout/vertical.py @@ -2,7 +2,7 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal from collections.abc import Generator, Iterable -from typing import Any, ClassVar, Literal +from typing import Any from kitty.borders import BorderColor from kitty.types import Edges, NeighborsMap, WindowMapper @@ -64,7 +64,7 @@ class Vertical(Layout): name = 'vertical' main_is_horizontal = False no_minimal_window_borders = True - drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y' + drag_overlay_mode = 'axis_y' main_axis_layout = Layout.ylayout perp_axis_layout = Layout.xlayout @@ -156,6 +156,6 @@ class Horizontal(Vertical): name = 'horizontal' main_is_horizontal = True - drag_overlay_mode: ClassVar[Literal['axis_x']] = 'axis_x' + drag_overlay_mode = 'axis_x' main_axis_layout = Layout.xlayout perp_axis_layout = Layout.ylayout From 6c37c1c391d46ce17cc42df6a55583042f1c8fdf Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 27 Mar 2026 02:25:35 -0400 Subject: [PATCH 18/18] Fix mypy: annotate drag_overlay_mode with full union type in Vertical and Tall Bare literal assignments (drag_overlay_mode = 'axis_y') cause mypy to narrow-infer the type as Literal['axis_y'] on the parent class, making the subclass override (Horizontal = 'axis_x', Fat = 'axis_x') an incompatible assignment. Fix by explicitly annotating Vertical and Tall with the full union type from the base class, so the declared type stays wide and subclasses can freely assign any valid mode. Also removes unused ClassVar/Literal imports from splits.py. --- kitty/layout/splits.py | 2 +- kitty/layout/tall.py | 4 ++-- kitty/layout/vertical.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index b7bbaa92a..67fa6ff25 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -2,7 +2,7 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal from collections.abc import Collection, Generator, Iterator, Sequence -from typing import Any, ClassVar, Literal, Optional, TypedDict, Union +from typing import Any, Optional, TypedDict, Union from kitty.borders import BorderColor from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE diff --git a/kitty/layout/tall.py b/kitty/layout/tall.py index 2a1f1f614..d2f6515e2 100644 --- a/kitty/layout/tall.py +++ b/kitty/layout/tall.py @@ -4,7 +4,7 @@ import sys from collections.abc import Generator, Iterator, Sequence from itertools import islice, repeat -from typing import Any +from typing import Any, ClassVar, Literal from kitty.borders import BorderColor from kitty.conf.utils import to_bool @@ -136,7 +136,7 @@ class Tall(Layout): name = 'tall' main_is_horizontal = True no_minimal_window_borders = True - drag_overlay_mode = 'axis_y' + drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = 'axis_y' layout_opts = TallLayoutOpts({}) main_axis_layout = Layout.xlayout perp_axis_layout = Layout.ylayout diff --git a/kitty/layout/vertical.py b/kitty/layout/vertical.py index c69dde0dd..82513f30a 100644 --- a/kitty/layout/vertical.py +++ b/kitty/layout/vertical.py @@ -2,7 +2,7 @@ # License: GPLv3 Copyright: 2020, Kovid Goyal from collections.abc import Generator, Iterable -from typing import Any +from typing import Any, ClassVar, Literal from kitty.borders import BorderColor from kitty.types import Edges, NeighborsMap, WindowMapper @@ -64,7 +64,7 @@ class Vertical(Layout): name = 'vertical' main_is_horizontal = False no_minimal_window_borders = True - drag_overlay_mode = 'axis_y' + drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = 'axis_y' main_axis_layout = Layout.ylayout perp_axis_layout = Layout.xlayout