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:
@@ -3340,13 +3340,7 @@ class Boss:
|
|||||||
layout = src_tab.current_layout
|
layout = src_tab.current_layout
|
||||||
horizontal = direction in ('left', 'right')
|
horizontal = direction in ('left', 'right')
|
||||||
after = direction in ('right', 'bottom')
|
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)
|
||||||
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()
|
src_tab.relayout()
|
||||||
|
|
||||||
def _move_tab_to(self, tab: Tab | None = None, target_os_window_id: int | None = None) -> Tab | None:
|
def _move_tab_to(self, tab: Tab | None = None, target_os_window_id: int | None = None) -> Tab | None:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
from collections.abc import Generator, Iterable, Iterator, Sequence
|
from collections.abc import Generator, Iterable, Iterator, Sequence
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from itertools import repeat
|
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.borders import BorderColor
|
||||||
from kitty.fast_data_types import BOTTOM_EDGE, RIGHT_EDGE, Region, get_options, set_active_window, viewport_for_window
|
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
|
must_draw_borders = False # can be overridden to customize behavior from kittens
|
||||||
layout_opts = LayoutOpts({})
|
layout_opts = LayoutOpts({})
|
||||||
only_active_window_visible = False
|
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:
|
def __init__(self, os_window_id: int, tab_id: int, layout_opts: str = '') -> None:
|
||||||
self.set_owner(os_window_id, tab_id)
|
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:
|
def move_window_to_group(self, all_windows: WindowList, group: int) -> bool:
|
||||||
return all_windows.move_window_group(to_group=group)
|
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(
|
def add_window(
|
||||||
self, all_windows: WindowList, window: WindowType, location: str | None = None,
|
self, all_windows: WindowList, window: WindowType, location: str | None = None,
|
||||||
overlay_for: int | None = None, put_overlay_behind: bool = False, bias: float | None = None,
|
overlay_for: int | None = None, put_overlay_behind: bool = False, bias: float | None = None,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Iterator, Sequence
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from itertools import repeat
|
from itertools import repeat
|
||||||
from math import ceil, floor
|
from math import ceil, floor
|
||||||
from typing import Any
|
from typing import Any, ClassVar, Literal
|
||||||
|
|
||||||
from kitty.borders import BorderColor
|
from kitty.borders import BorderColor
|
||||||
from kitty.types import Edges, NeighborsMap, WindowMapper
|
from kitty.types import Edges, NeighborsMap, WindowMapper
|
||||||
@@ -34,6 +34,7 @@ class Grid(Layout):
|
|||||||
|
|
||||||
name: str = 'grid'
|
name: str = 'grid'
|
||||||
no_minimal_window_borders = True
|
no_minimal_window_borders = True
|
||||||
|
drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y'
|
||||||
|
|
||||||
def remove_all_biases(self) -> bool:
|
def remove_all_biases(self) -> bool:
|
||||||
self.biased_rows: dict[int, float] = {}
|
self.biased_rows: dict[int, float] = {}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
from collections.abc import Collection, Generator, Iterator, Sequence
|
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.borders import BorderColor
|
||||||
from kitty.fast_data_types import BOTTOM_EDGE, LEFT_EDGE, RIGHT_EDGE, TOP_EDGE
|
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
|
needs_all_windows = True
|
||||||
layout_opts = SplitsLayoutOpts({})
|
layout_opts = SplitsLayoutOpts({})
|
||||||
no_minimal_window_borders = True
|
no_minimal_window_borders = True
|
||||||
|
drag_overlay_mode: ClassVar[Literal['free']] = 'free'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_axis_is_horizontal(self) -> bool | None:
|
def default_axis_is_horizontal(self) -> bool | None:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Generator, Iterator, Sequence
|
from collections.abc import Generator, Iterator, Sequence
|
||||||
from itertools import islice, repeat
|
from itertools import islice, repeat
|
||||||
from typing import Any
|
from typing import Any, ClassVar, Literal
|
||||||
|
|
||||||
from kitty.borders import BorderColor
|
from kitty.borders import BorderColor
|
||||||
from kitty.conf.utils import to_bool
|
from kitty.conf.utils import to_bool
|
||||||
@@ -136,6 +136,7 @@ class Tall(Layout):
|
|||||||
name = 'tall'
|
name = 'tall'
|
||||||
main_is_horizontal = True
|
main_is_horizontal = True
|
||||||
no_minimal_window_borders = True
|
no_minimal_window_borders = True
|
||||||
|
drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y'
|
||||||
layout_opts = TallLayoutOpts({})
|
layout_opts = TallLayoutOpts({})
|
||||||
main_axis_layout = Layout.xlayout
|
main_axis_layout = Layout.xlayout
|
||||||
perp_axis_layout = Layout.ylayout
|
perp_axis_layout = Layout.ylayout
|
||||||
@@ -381,5 +382,6 @@ class Fat(Tall):
|
|||||||
|
|
||||||
name = 'fat'
|
name = 'fat'
|
||||||
main_is_horizontal = False
|
main_is_horizontal = False
|
||||||
|
drag_overlay_mode: ClassVar[Literal['axis_x']] = 'axis_x'
|
||||||
main_axis_layout = Layout.ylayout
|
main_axis_layout = Layout.ylayout
|
||||||
perp_axis_layout = Layout.xlayout
|
perp_axis_layout = Layout.xlayout
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
from collections.abc import Generator, Iterable
|
from collections.abc import Generator, Iterable
|
||||||
from typing import Any
|
from typing import Any, ClassVar, Literal
|
||||||
|
|
||||||
from kitty.borders import BorderColor
|
from kitty.borders import BorderColor
|
||||||
from kitty.types import Edges, NeighborsMap, WindowMapper
|
from kitty.types import Edges, NeighborsMap, WindowMapper
|
||||||
@@ -64,6 +64,7 @@ class Vertical(Layout):
|
|||||||
name = 'vertical'
|
name = 'vertical'
|
||||||
main_is_horizontal = False
|
main_is_horizontal = False
|
||||||
no_minimal_window_borders = True
|
no_minimal_window_borders = True
|
||||||
|
drag_overlay_mode: ClassVar[Literal['axis_y']] = 'axis_y'
|
||||||
main_axis_layout = Layout.ylayout
|
main_axis_layout = Layout.ylayout
|
||||||
perp_axis_layout = Layout.xlayout
|
perp_axis_layout = Layout.xlayout
|
||||||
|
|
||||||
@@ -155,5 +156,6 @@ class Horizontal(Vertical):
|
|||||||
|
|
||||||
name = 'horizontal'
|
name = 'horizontal'
|
||||||
main_is_horizontal = True
|
main_is_horizontal = True
|
||||||
|
drag_overlay_mode: ClassVar[Literal['axis_x']] = 'axis_x'
|
||||||
main_axis_layout = Layout.xlayout
|
main_axis_layout = Layout.xlayout
|
||||||
perp_axis_layout = Layout.ylayout
|
perp_axis_layout = Layout.ylayout
|
||||||
|
|||||||
@@ -1950,18 +1950,25 @@ class TabManager: # {{{
|
|||||||
self._set_drag_target_window(dest_window.id, 5)
|
self._set_drag_target_window(dest_window.id, 5)
|
||||||
return
|
return
|
||||||
active_tab = self.active_tab
|
active_tab = self.active_tab
|
||||||
if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'):
|
if active_tab is not None:
|
||||||
# Splits layout body hover: directional half-window overlay
|
mode = active_tab.current_layout.drag_overlay_mode
|
||||||
rel_x = x - central.left
|
rel_x = x - central.left
|
||||||
g = dest_window.geometry
|
g = dest_window.geometry
|
||||||
dx = rel_x - (g.left + g.right) / 2
|
dx = rel_x - (g.left + g.right) / 2
|
||||||
dy = rel_y - (g.top + g.bottom) / 2
|
dy = rel_y - (g.top + g.bottom) / 2
|
||||||
quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4}
|
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])
|
self._set_drag_target_window(dest_window.id, quad_map[direction])
|
||||||
else:
|
else:
|
||||||
# All other body hover: full window overlay only (reorder/swap, no title bar flash)
|
self._set_drag_target_window(0)
|
||||||
self._set_drag_target_window(dest_window.id, 6)
|
|
||||||
else:
|
else:
|
||||||
self._set_drag_target_window(0)
|
self._set_drag_target_window(0)
|
||||||
|
|
||||||
@@ -2024,13 +2031,16 @@ class TabManager: # {{{
|
|||||||
# Cross-tab title bar drop: move to the destination tab
|
# Cross-tab title bar drop: move to the destination tab
|
||||||
boss._move_window_to(w, target_tab_id=active_tab.id)
|
boss._move_window_to(w, target_tab_id=active_tab.id)
|
||||||
else:
|
else:
|
||||||
# Quadrant-based directional insert
|
|
||||||
g = dest_window.geometry
|
g = dest_window.geometry
|
||||||
win_cx = (g.left + g.right) / 2
|
dx = rel_x - (g.left + g.right) / 2
|
||||||
win_cy = (g.top + g.bottom) / 2
|
dy = rel_y - (g.top + g.bottom) / 2
|
||||||
dx = rel_x - win_cx
|
mode = active_tab.current_layout.drag_overlay_mode
|
||||||
dy = rel_y - win_cy
|
if mode == 'axis_y':
|
||||||
direction: str = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top')
|
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)
|
boss._insert_window_in_direction(w, dest_window, direction)
|
||||||
|
|
||||||
def update_progress(self) -> None:
|
def update_progress(self) -> None:
|
||||||
|
|||||||
@@ -519,6 +519,27 @@ class WindowList:
|
|||||||
return True
|
return True
|
||||||
return False
|
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]:
|
def compute_needs_borders_map(self, draw_active_borders: bool) -> dict[int, bool]:
|
||||||
ag = self.active_group
|
ag = self.active_group
|
||||||
return {gr.id: ((gr is ag and draw_active_borders) or gr.needs_attention) for gr in self.groups}
|
return {gr.id: ((gr is ag and draw_active_borders) or gr.needs_attention) for gr in self.groups}
|
||||||
|
|||||||
Reference in New Issue
Block a user