From a368a90e3738208ee0a9e7e7e003b8391ead761d Mon Sep 17 00:00:00 2001 From: mcrmck Date: Fri, 27 Mar 2026 02:08:41 -0400 Subject: [PATCH] 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}