Add draggable window title bars
Implements drag-to-reorder for window title bars, following up on the merged window title bar feature (#9450) and the design discussion in #9619. - Drag a title bar and drop on another title bar to swap positions - Drop on a window body quadrant (left/right/top/bottom) to insert as a directional split; Splits layout uses insert_window_next_to(), other layouts fall back to move_window_to_group() - Drop on a tab bar tab to move the window into that tab - Drop on another OS window to move into its active tab - Drop outside kitty to detach into a new OS window - Tab bar highlights the hovered tab during a window drag, mirroring how the destination window title bar is highlighted - toggle_window_title_bars action temporarily force-shows title bars for drag-to-reorder when they are normally hidden, auto-hiding after the drag completes - window_title_bar_drag_threshold option (default 5px) controls how far the mouse must move before a drag is initiated; 0 disables dragging MIME type follows the same convention as tab dragging: application/net.kovidgoyal.kitty-window-{PID} Ref: #9619
This commit is contained in:
parent
db5453c291
commit
59c963c481
15 changed files with 391 additions and 29 deletions
|
|
@ -909,14 +909,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]];
|
||||
|
|
@ -1591,6 +1592,10 @@ - (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
|
|||
for (size_t i = 0; i < d->mimes_count; i++)
|
||||
_glfwPlatformRequestDropData(window, d->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;
|
||||
}
|
||||
|
||||
|
|
|
|||
105
kitty/boss.py
105
kitty/boss.py
|
|
@ -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,
|
||||
|
|
@ -1389,9 +1391,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):
|
||||
|
|
@ -1925,6 +1927,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, 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):
|
||||
|
|
@ -1937,6 +1943,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
|
||||
|
|
@ -1967,6 +1981,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 tab in tm:
|
||||
if tab.force_show_title_bars:
|
||||
tab.force_show_title_bars = False
|
||||
tab.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:
|
||||
|
|
@ -3232,6 +3267,46 @@ 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')
|
||||
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)
|
||||
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:
|
||||
|
|
@ -3323,6 +3398,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
|
||||
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,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
|
|
@ -664,12 +664,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;
|
||||
|
|
@ -2891,8 +2895,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;
|
||||
|
|
|
|||
|
|
@ -371,7 +371,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
|
||||
|
|
|
|||
|
|
@ -684,6 +684,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:
|
||||
self.pairs_root.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',)
|
||||
|
|
|
|||
|
|
@ -893,8 +893,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1268,9 +1270,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);
|
||||
|
|
|
|||
|
|
@ -1550,6 +1550,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() # }}}
|
||||
|
||||
|
||||
|
|
|
|||
3
kitty/options/parse.py
generated
3
kitty/options/parse.py
generated
|
|
@ -1529,6 +1529,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)
|
||||
|
||||
|
|
|
|||
2
kitty/options/types.py
generated
2
kitty/options/types.py
generated
|
|
@ -504,6 +504,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',
|
||||
|
|
@ -705,6 +706,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
|
||||
|
|
|
|||
|
|
@ -1551,6 +1551,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;
|
||||
|
|
@ -1579,6 +1593,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),
|
||||
|
|
|
|||
|
|
@ -405,6 +405,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;
|
||||
|
|
|
|||
214
kitty/tabs.py
214
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,
|
||||
|
|
@ -145,6 +147,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
|
||||
num_of_windows_with_progress: int = 0
|
||||
total_progress: int = 0
|
||||
has_indeterminate_progress: bool = False
|
||||
|
|
@ -461,7 +464,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:
|
||||
|
|
@ -1115,6 +1120,10 @@ 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
|
||||
|
||||
|
||||
class TabManager: # {{{
|
||||
|
||||
confirm_close_window_id: int = 0
|
||||
|
|
@ -1122,6 +1131,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
|
||||
|
|
@ -1576,9 +1587,10 @@ def tab_bar_data(self) -> Sequence[TabBarData]:
|
|||
if drag_started:
|
||||
tab_being_dragged_from_here = self.tab_for_id(dragged_tab_id) is not None
|
||||
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)
|
||||
return 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)
|
||||
return tuple(t.data_for_tab_bar(t is at or t.id == wdtt) for t in self.tabs_to_be_shown_in_tab_bar)
|
||||
tmap = {t.id:t for t in self.tabs}
|
||||
at = self.active_tab
|
||||
ans = []
|
||||
|
|
@ -1755,30 +1767,198 @@ 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()
|
||||
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) -> None:
|
||||
"""Highlight window_id's title bar as the drop target; 0 clears."""
|
||||
boss = get_boss()
|
||||
prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0
|
||||
if prev_id == window_id:
|
||||
return
|
||||
if prev_id and (prev_w := boss.window_id_map.get(prev_id)):
|
||||
prev_w.is_drag_target = False
|
||||
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)):
|
||||
new_w.is_drag_target = True
|
||||
new_w.update_title_bar(is_active=True)
|
||||
self.window_being_dropped = WindowBeingDropped(window_id=window_id)
|
||||
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)
|
||||
target_id = dest_window.id if (dest_window and dest_window.id != window_id) else 0
|
||||
self._set_drag_target_window(target_id)
|
||||
|
||||
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()
|
||||
w = boss.window_id_map.get(window_id)
|
||||
if w is None:
|
||||
return
|
||||
self._clear_force_show_title_bars()
|
||||
set_window_being_dragged()
|
||||
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)
|
||||
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:
|
||||
# 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')
|
||||
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
|
||||
|
|
|
|||
|
|
@ -660,6 +660,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
|
||||
|
|
@ -1069,7 +1070,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] = {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue