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)
This commit is contained in:
mcrmck
2026-03-27 02:08:41 -04:00
parent 606aa1543e
commit a368a90e37
8 changed files with 85 additions and 23 deletions

View File

@@ -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:

View File

@@ -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,

View File

@@ -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] = {}

View File

@@ -2,7 +2,7 @@
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
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:

View File

@@ -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

View File

@@ -2,7 +2,7 @@
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
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

View File

@@ -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:

View File

@@ -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}