diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index a7f07c427..3f0c02962 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -928,14 +928,15 @@ static void _glfwUpdateNotchCover(_GLFWwindow*); 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]]; @@ -1632,6 +1633,10 @@ reset_drop_copy_mimes(_GLFWDropData *d) { for (size_t i = 0; i < num_accepted; i++) _glfwPlatformRequestDropData(window, d->copy_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 85fec0d1b..75ab13a1f 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, @@ -1393,9 +1395,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): @@ -1929,6 +1931,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) 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): @@ -1941,6 +1947,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 @@ -1972,6 +1986,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 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() + 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: @@ -3277,6 +3312,40 @@ 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') + 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: tab = tab or self.active_tab if tab is None: @@ -3368,6 +3437,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/child-monitor.c b/kitty/child-monitor.c index 2cb3dac55..cb71e8af6 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -853,6 +853,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) { @@ -919,6 +920,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 de7fdd657..e91cf91c2 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1540,6 +1540,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 @@ -1837,6 +1840,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 3a82d6e23..b776d29bd 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -669,12 +669,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; @@ -2977,8 +2981,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..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, @@ -371,7 +402,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/grid.py b/kitty/layout/grid.py index 34caa15f0..5a7fcda9c 100644 --- a/kitty/layout/grid.py +++ b/kitty/layout/grid.py @@ -34,6 +34,7 @@ class Grid(Layout): name: str = 'grid' no_minimal_window_borders = True + 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 f99ba70ab..67fa6ff25 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -561,6 +561,7 @@ class Splits(Layout): needs_all_windows = True layout_opts = SplitsLayoutOpts({}) no_minimal_window_borders = True + drag_overlay_mode = 'free' @property def default_axis_is_horizontal(self) -> bool | None: @@ -722,6 +723,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: + pair.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/layout/tall.py b/kitty/layout/tall.py index 31cfd6c76..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,6 +136,7 @@ class Tall(Layout): name = 'tall' main_is_horizontal = True no_minimal_window_borders = True + 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 @@ -381,5 +382,6 @@ class Fat(Tall): name = 'fat' main_is_horizontal = False + 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 fdf231db0..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,6 +64,7 @@ class Vertical(Layout): name = 'vertical' main_is_horizontal = False no_minimal_window_borders = True + drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = '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 = 'axis_x' main_axis_layout = Layout.xlayout perp_axis_layout = Layout.ylayout diff --git a/kitty/mouse.c b/kitty/mouse.c index 754bfa3ba..732c0fb0c 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -898,8 +898,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); } } @@ -1310,9 +1312,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 1c86d1a51..453aa1906 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1562,6 +1562,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() # }}} @@ -1637,6 +1647,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/options/parse.py b/kitty/options/parse.py index 15441bdc8..6ece678d3 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1359,6 +1359,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: @@ -1537,6 +1540,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 a996e75f7..b95a0fc9d 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -462,6 +462,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', @@ -506,6 +507,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', @@ -665,6 +667,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' @@ -708,6 +711,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/screen.h b/kitty/screen.h index 3a5645817..d4b43d30c 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -138,6 +138,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 d8da11c40..3ba23613c 100644 --- a/kitty/shaders.c +++ b/kitty/shaders.c @@ -787,6 +787,37 @@ 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 + title bar highlight (title bar hover) + case 6: break; // full window, no title bar highlight (body hover) + 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; @@ -1225,6 +1256,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 8e40fc913..e065fb9ec 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -866,6 +866,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; @@ -1554,6 +1573,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; @@ -1582,6 +1615,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), @@ -1618,6 +1653,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/state.h b/kitty/state.h index 5c72f8de2..3f2635f38 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -439,6 +439,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/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 ff3e4d3c4..dddea1609 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, @@ -146,6 +148,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 renaming_in_window: int = 0 num_of_windows_with_progress: int = 0 total_progress: int = 0 @@ -463,7 +466,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: @@ -1117,6 +1122,11 @@ 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 + quadrant: int = 0 # 0=none, 1=left, 2=right, 3=top, 4=bottom, 5=full+titlebar, 6=full + + class TabManager: # {{{ confirm_close_window_id: int = 0 @@ -1124,6 +1134,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 @@ -1582,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 @@ -1589,10 +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) 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) + 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 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 ans = [] @@ -1620,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) @@ -1722,7 +1746,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 < 0: # synthetic tab (e.g. "+" new-tab button) + 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: @@ -1769,30 +1798,251 @@ 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() + 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): + 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) + 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, 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 + 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)): + 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 + + 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) + if dest_window and dest_window.id != window_id: + from .fast_data_types import viewport_for_window as _vfw + central = _vfw(self.os_window_id)[0] + rel_y = y - central.top + 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 + 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 + active_tab = self.active_tab + 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} + 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: + self._set_drag_target_window(0) + else: + 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 + boss = get_boss() + self._clear_force_show_title_bars() + w = boss.window_id_map.get(window_id) + 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 + 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) + else: + boss._move_window_to(w, target_tab_id='new') + 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: + g = dest_window.geometry + 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: self.num_of_windows_with_progress = 0 self.total_progress = 0 diff --git a/kitty/window.py b/kitty/window.py index 7b6b7156c..b0a21ba31 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -673,6 +673,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 @@ -1082,7 +1083,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..dde3fc193 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] = {} @@ -517,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}