Merge branch 'feat-draggable-window-title-bars' of https://github.com/mcrmck/kitty
This commit is contained in:
commit
9e79d3be9c
22 changed files with 595 additions and 35 deletions
|
|
@ -928,14 +928,15 @@ - (instancetype)initWithGlfwWindow:(_GLFWwindow *)initWindow
|
|||
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 @@ - (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@
|
|||
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 @@
|
|||
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 @@ def start(self, first_os_window_id: int, startup_sessions: Iterable[Session]) ->
|
|||
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 @@ def on_drop_move(self, os_window_id: int, x: int, y: int, from_self: bool, is_le
|
|||
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 @@ def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bo
|
|||
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 @@ def on_drag_source_finished(
|
|||
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 @@ def _move_window_to(
|
|||
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 @@ def format_tab_title(tab: Tab) -> str:
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
kitty/glfw.c
11
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;
|
||||
|
|
|
|||
|
|
@ -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 @@ def move_window(self, all_windows: WindowList, delta: int = 1) -> bool:
|
|||
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 @@ def __call__(self, all_windows: WindowList) -> None:
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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 @@ def move_window_to_group(self, all_windows: WindowList, group: int) -> bool:
|
|||
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',)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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['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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1562,6 +1562,16 @@
|
|||
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 @@
|
|||
'''
|
||||
)
|
||||
|
||||
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.'
|
||||
)
|
||||
|
|
|
|||
6
kitty/options/parse.py
generated
6
kitty/options/parse.py
generated
|
|
@ -1359,6 +1359,9 @@ def tab_bar_margin_width(self, val: str, ans: dict[str, typing.Any]) -> None:
|
|||
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 @@ def window_title_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None:
|
|||
|
||||
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)
|
||||
|
||||
|
|
|
|||
4
kitty/options/types.py
generated
4
kitty/options/types.py
generated
|
|
@ -462,6 +462,7 @@
|
|||
'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 @@
|
|||
'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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
// }}}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -265,6 +265,8 @@ def progress_percent(self) -> str:
|
|||
|
||||
|
||||
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,
|
||||
|
|
|
|||
290
kitty/tabs.py
290
kitty/tabs.py
|
|
@ -33,6 +33,7 @@
|
|||
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 @@
|
|||
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 @@ def on_bell(self, window: Window) -> None:
|
|||
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 @@ def previous_active_tab() -> Tab | None:
|
|||
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 @@ def tab_bar_data(self) -> Sequence[TabBarData]:
|
|||
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 @@ def on_tab_drop_move(self, tab_id: int = 0, is_dest: bool = False, x: int = 0, y
|
|||
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 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
|
|||
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 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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 @@ def update_title_bar(self, is_active: bool = False) -> None:
|
|||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ def is_visible_in_layout(self) -> bool:
|
|||
|
||||
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 @@ def move_window_group(self, by: int | None = None, to_group: int | None = None)
|
|||
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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue