From dc57e5bdb874ee83eeae0e6a6ff3e32567fd57ef Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 19 Feb 2026 12:37:31 +0530 Subject: [PATCH] Start work on drag and drop for tabs --- kitty/boss.py | 11 +++++--- kitty/child-monitor.c | 57 ++++++++++++++++++++++++++++++++++----- kitty/fast_data_types.pyi | 7 +++++ kitty/mouse.c | 8 +++--- kitty/png-reader.c | 20 +++++++++++--- kitty/png-reader.h | 4 +-- kitty/shaders.c | 52 +---------------------------------- kitty/state.c | 28 +++++++++++++++++++ kitty/state.h | 10 +++++-- kitty/tabs.py | 52 +++++++++++++++++++++++++++++------ 10 files changed, 169 insertions(+), 80 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index e2100d146..3e9de7264 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1371,10 +1371,13 @@ class Boss: run_update_check(get_options().update_check_interval * 60 * 60) self.update_check_started = True - def handle_click_on_tab(self, os_window_id: int, x: int, button: int, modifiers: int, action: int) -> None: - tm = self.os_window_map.get(os_window_id) - if tm is not None: - tm.handle_click_on_tab(x, 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): + tm.handle_tab_bar_mouse(x, y, button, modifiers, action) + + def start_tab_drag(self, os_window_id: int, window_id: int, pixels: bytes, width: int, height: int) -> None: + if tm := self.os_window_map.get(os_window_id): + tm.start_tab_drag(pixels, width, height) def on_window_resize(self, os_window_id: int, w: int, h: int, dpi_changed: bool) -> None: if dpi_changed: diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index a47b0dbea..504a7bd7f 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -811,6 +811,43 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int * return needs_render || was_previously_rendered_with_layers != os_window->needs_layers; } +static void +thumbnail_callback(OSWindow *os_window) { +#define tc global_state.thumbnail_callback + Region region = {.right=os_window->viewport_width, .bottom=os_window->viewport_height}; + if (tc.window) { + Window *w = window_for_window_id(tc.window); + if (!w) return; + region.left = w->render_data.geometry.left; + region.top = w->render_data.geometry.top; + region.right = w->render_data.geometry.right; + region.bottom = w->render_data.geometry.bottom; + } else { + if (!tc.include_tab_bar) { + Region central = {0}, tab_bar = {0}; + os_window_regions(os_window, ¢ral, &tab_bar); + if (tab_bar.bottom > tab_bar.top) region = central; + } + } + unsigned vw = region.right - region.left, vh = region.bottom - region.top; + unsigned thumb_w = (unsigned)(vw * tc.scale), thumb_h = (unsigned)(vh * tc.scale); + if (thumb_w > tc.max_width) { + thumb_w = tc.max_width; + double scale = 300. / vw; + thumb_h = (unsigned)(vh * scale + 0.5f); + } + RAII_PyObject(pixels, PyBytes_FromStringAndSize(NULL, 4 * thumb_w * thumb_h)); + if (pixels && global_state.boss) { + take_screenshot_of_rectangular_region( + os_window, region, (unsigned char*)PyBytes_AS_STRING(pixels), &thumb_w, &thumb_h); + _PyBytes_Resize(&pixels, 4 * thumb_w *thumb_h); + PyObject *r = PyObject_CallMethod( + global_state.boss, tc.callback, "KKOII", os_window->id, tc.window, pixels, thumb_w, thumb_h); + if (!r) PyErr_Print(); else Py_DECREF(r); + } +#undef tc +} + static void render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, color_type active_window_bg, unsigned int num_visible_windows, bool all_windows_have_same_bg) { Tab *tab = os_window->tabs + os_window->active_tab; @@ -832,6 +869,10 @@ render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, co } } setup_os_window_for_rendering(os_window, tab, active_window, false); + if (global_state.thumbnail_callback.os_window == os_window->id) { + thumbnail_callback(os_window); + global_state.thumbnail_callback.os_window = 0; + } swap_window_buffers(os_window); os_window->last_active_tab = os_window->active_tab; os_window->last_num_tabs = os_window->num_tabs; os_window->last_active_window_id = active_window_id; os_window->focused_at_last_render = os_window->is_focused; @@ -857,22 +898,24 @@ no_render_frame_received_recently(OSWindow *w, monotonic_t now, monotonic_t max_ bool render_os_window(OSWindow *w, monotonic_t now, bool scan_for_animated_images) { if (!w->num_tabs) return false; - if (!should_os_window_be_rendered(w)) { + if (!should_os_window_be_rendered(w) && global_state.thumbnail_callback.os_window != w->id) { update_os_window_title(w); if (w->is_focused) change_menubar_title(w->window_title); return false; } if (!w->keep_rendering_till_swap && USE_RENDER_FRAMES && w->render_state != RENDER_FRAME_READY) { if (w->render_state == RENDER_FRAME_NOT_REQUESTED || no_render_frame_received_recently(w, now, ms_to_monotonic_t(250ll))) request_frame_render(w); - // dont respect render frames soon after a resize on Wayland as they cause flicker because - // we want to fill the newly resized buffer ASAP, not at compositors convenience - if (!global_state.is_wayland || (monotonic() - w->viewport_resized_at) > s_double_to_monotonic_t(1)) { - return false; + if (w->id != global_state.thumbnail_callback.os_window) { + // dont respect render frames soon after a resize on Wayland as they cause flicker because + // we want to fill the newly resized buffer ASAP, not at compositors convenience + if (!global_state.is_wayland || (monotonic() - w->viewport_resized_at) > s_double_to_monotonic_t(1)) { + return false; + } } } w->render_calls++; make_os_window_context_current(w); - bool needs_render = w->redraw_count > 0 || w->live_resize.in_progress; + bool needs_render = w->redraw_count > 0 || w->live_resize.in_progress || global_state.thumbnail_callback.os_window == w->id; if (w->viewport_size_dirty) { set_gpu_viewport(w->viewport_width, w->viewport_height); w->viewport_size_dirty = false; @@ -895,7 +938,7 @@ render(monotonic_t now, bool input_read) { EVDBG("input_read: %d, check_for_active_animated_images: %d\n", input_read, global_state.check_for_active_animated_images); static monotonic_t last_render_at = MONOTONIC_T_MIN; monotonic_t time_since_last_render = last_render_at == MONOTONIC_T_MIN ? OPT(repaint_delay) : now - last_render_at; - if (!input_read && time_since_last_render < OPT(repaint_delay)) { + if (!input_read && time_since_last_render < OPT(repaint_delay) && !global_state.thumbnail_callback.os_window) { set_maximum_wait(OPT(repaint_delay) - time_since_last_render); return; } diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index d86ae419a..85a4fef94 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1817,3 +1817,10 @@ def start_drag_with_data( os_window_id: int, data_map: dict[str, bytes], thumbnail: bytes = b'', width: int = 0, height: int = 0, operations: int = GLFW_DRAG_OPERATION_MOVE ) -> None: ... + +def set_tab_being_dragged(tab_id: int) -> None: ... +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 +) -> None: ... +def png_from_32bit_rgba_data(data: bytes, width: int, height: int, flip_vertically: bool = False) -> bytes: ... diff --git a/kitty/mouse.c b/kitty/mouse.c index 5857c775e..98d6fc975 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -866,8 +866,10 @@ HANDLER(handle_event) { static void handle_tab_bar_mouse(int button, int modifiers, int action) { set_currently_hovered_window(0, modifiers); - if (button > -1) { // dont report motion events, as they are expensive and useless - call_boss(handle_click_on_tab, "Kdiii", global_state.callback_os_window->id, global_state.callback_os_window->mouse_x, button, modifiers, action); + OSWindow *w = global_state.callback_os_window; + // dont report motion events, as they are expensive and useless + if (w && (button > -1 || global_state.tab_being_dragged)) { + call_boss(handle_tab_bar_mouse, "Kddiii", w->id, w->mouse_x, w->mouse_y, button, modifiers, action); } } @@ -1130,7 +1132,7 @@ mouse_event(const int button, int modifiers, int action) { w = window_for_event(&window_idx, &in_tab_bar); set_currently_hovered_window(w ? w->id : 0, modifiers); - if (in_tab_bar) { + if (in_tab_bar || global_state.tab_being_dragged) { mouse_cursor_shape = POINTER_POINTER; handle_tab_bar_mouse(button, modifiers, action); debug("handled by tab bar\n"); diff --git a/kitty/png-reader.c b/kitty/png-reader.c index c666d59be..073536c72 100644 --- a/kitty/png-reader.c +++ b/kitty/png-reader.c @@ -164,7 +164,7 @@ png_write_to_memory(png_structp png_ptr, png_bytep data, png_size_t length) { static void png_flush_memory(png_structp png_ptr) { (void)png_ptr; } static const char* -create_png_from_data(char *data, size_t width, size_t height, size_t stride, size_t *out_size, bool flip_vertically, int color_type) { +create_png_from_data(const char *data, size_t width, size_t height, size_t stride, size_t *out_size, bool flip_vertically, int color_type) { *out_size = 0; png_memory_write_state state = {.capacity=width*height * sizeof(uint32_t)}; state.buffer = malloc(state.capacity); @@ -202,12 +202,25 @@ create_png_from_data(char *data, size_t width, size_t height, size_t stride, siz } const char* -png_from_32bit_rgba(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { +png_from_32bit_rgba(const char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { return create_png_from_data(data, width, height, 4 * width, out_size, flip_vertically, PNG_COLOR_TYPE_RGBA); } +PyObject* +png_from_32bit_rgba_data(PyObject *self UNUSED, PyObject *args) { + int flip_vertically = 0; const char* data; Py_ssize_t len; + unsigned width, height; + if (!PyArg_ParseTuple(args, "y#II|p", &data, &len, &width, &height, &flip_vertically)) return NULL; + size_t out_size; + const char *out = create_png_from_data(data, width, height, 4 * width, &out_size, flip_vertically, PNG_COLOR_TYPE_RGBA); + PyObject *ans = PyBytes_FromStringAndSize(out, out_size); + free((void*)out); + return ans; +} + + const char* -png_from_24bit_rgb(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { +png_from_24bit_rgb(const char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically) { return create_png_from_data(data, width, height, 3 * width, out_size, flip_vertically, PNG_COLOR_TYPE_RGB); } @@ -237,6 +250,7 @@ load_png_data(PyObject *self UNUSED, PyObject *args) { static PyMethodDef module_methods[] = { METHODB(load_png_data, METH_VARARGS), + METHODB(png_from_32bit_rgba_data, METH_VARARGS), {NULL, NULL, 0, NULL} /* Sentinel */ }; diff --git a/kitty/png-reader.h b/kitty/png-reader.h index 41fdbe05c..fad4b1c26 100644 --- a/kitty/png-reader.h +++ b/kitty/png-reader.h @@ -28,5 +28,5 @@ typedef struct png_read_data { } png_read_data; void inflate_png_inner(png_read_data *d, const uint8_t *buf, size_t bufsz, int max_image_dimension); -const char* png_from_32bit_rgba(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); -const char* png_from_24bit_rgb(char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); +const char* png_from_32bit_rgba(const char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); +const char* png_from_24bit_rgb(const char *data, size_t width, size_t height, size_t *out_size, bool flip_vertically); diff --git a/kitty/shaders.c b/kitty/shaders.c index 3e2cc165f..804304fac 100644 --- a/kitty/shaders.c +++ b/kitty/shaders.c @@ -1441,7 +1441,7 @@ setup_os_window_for_rendering(OSWindow *os_window, Tab *tab, Window *active_wind // The region parameter specifies which part of the framebuffer to capture. // Scaling is performed on the GPU using the BLIT_PROGRAM shader for better performance. // Setting the thumbnail dimensions to zero disables scaling. -static void +void take_screenshot_of_rectangular_region(OSWindow *os_window, Region region, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h) { unsigned vw = os_window->viewport_width; unsigned vh = os_window->viewport_height; @@ -1515,56 +1515,6 @@ take_screenshot_of_rectangular_region(OSWindow *os_window, Region region, unsign free_texture(&temp_texture); free_framebuffer(&temp_framebuffer); } - -// The include_tab_bar parameter controls whether the tab bar is included in the screenshot. -// When false, only the central window area is captured (excluding the tab bar). -// Scaling is performed on the GPU using the BLIT_PROGRAM shader for better performance. -// Setting the thumbnail dimensions to zero disables scaling. -// Must be called only after rendering the OS Window but before the buffer is swapped. -void -take_screenshot_of_oswindow(OSWindow *os_window, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h, bool include_tab_bar) { - Region region = {0}; - // Calculate the region to capture (excluding tab bar if requested) - if (!include_tab_bar) { - Region central = {0}, tab_bar = {0}; - os_window_regions(os_window, ¢ral, &tab_bar); - if (tab_bar.bottom > tab_bar.top) { - // Tab bar is present, exclude it from the screenshot - region = central; - } else { - // No tab bar, capture the entire viewport - region.right = os_window->viewport_width; - region.bottom = os_window->viewport_height; - } - } else { - // Capture the entire viewport including tab bar - region.right = os_window->viewport_width; - region.bottom = os_window->viewport_height; - } - take_screenshot_of_rectangular_region(os_window, region, dst_buf, thumb_w, thumb_h); -} - -// Takes a screenshot of a specific window identified by window_id. -// The screenshot captures only the rectangular region occupied by the window. -// Scaling is performed on the GPU using the BLIT_PROGRAM shader for better performance. -// Setting the thumbnail dimensions to zero disables scaling. -// Must be called only after rendering the parent OS Window but before the -// buffer is swapped. -bool -take_screenshot_of_window(id_type window_id, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h) { - Window *window = window_for_window_id(window_id); - OSWindow *os_window = os_window_for_kitty_window(window_id); - if (!window || !os_window) return false; - // Compute the region for this window - Region region; - region.left = window->render_data.geometry.left; - region.top = window->render_data.geometry.top; - region.right = window->render_data.geometry.right; - region.bottom = window->render_data.geometry.bottom; - take_screenshot_of_rectangular_region(os_window, region, dst_buf, thumb_w, thumb_h); - return true; -} - // }}} // Python API {{{ diff --git a/kitty/state.c b/kitty/state.c index 81276f5b7..383628a4c 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1483,12 +1483,40 @@ get_mouse_data_for_window(PyObject *self UNUSED, PyObject *args) { Py_RETURN_NONE; } +static PyObject* +set_tab_being_dragged(PyObject *self UNUSED, PyObject *args) { + if (!PyLong_Check(args)) { PyErr_SetString(PyExc_TypeError, "tab id must be integer"); return NULL; } + global_state.tab_being_dragged = PyLong_AsUnsignedLongLong(args); + Py_RETURN_NONE; +} + +static PyObject* +request_callback_with_thumbnail(PyObject *self UNUSED, PyObject *args) { + unsigned long long os_window_id, window_id = 0; + const char *callback; int include_tab_bar = 0; + double scale = 0.25; unsigned max_width = 480; + if (!PyArg_ParseTuple(args, "sK|KpdI", &callback, &os_window_id, &window_id, &include_tab_bar, &scale, &max_width)) return NULL; + WITH_OS_WINDOW(os_window_id) + global_state.thumbnail_callback.os_window = os_window->id; + global_state.thumbnail_callback.window = window_id; + global_state.thumbnail_callback.include_tab_bar = include_tab_bar; + snprintf(global_state.thumbnail_callback.callback, arraysz(global_state.thumbnail_callback.callback), "%s", callback); + global_state.thumbnail_callback.max_width = max_width; + global_state.thumbnail_callback.scale = scale; + mark_os_window_dirty(os_window_id); + END_WITH_OS_WINDOW + Py_RETURN_NONE; +} + + #define M(name, arg_type) {#name, (PyCFunction)name, arg_type, NULL} #define MW(name, arg_type) {#name, (PyCFunction)py##name, arg_type, NULL} static PyMethodDef module_methods[] = { M(os_window_focus_counters, METH_NOARGS), M(get_mouse_data_for_window, METH_VARARGS), + M(request_callback_with_thumbnail, METH_VARARGS), + M(set_tab_being_dragged, METH_O), MW(update_pointer_shape, METH_VARARGS), MW(current_os_window, METH_NOARGS), MW(next_window_id, METH_NOARGS), diff --git a/kitty/state.h b/kitty/state.h index 6170f2b3f..ec4dcb14a 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -388,6 +388,13 @@ typedef struct GlobalState { int action; PyObject *drag_data; } drag_source; + id_type tab_being_dragged; + struct { + id_type os_window, window; + char callback[32]; + bool include_tab_bar; + double scale; unsigned max_width; + } thumbnail_callback; } GlobalState; extern GlobalState global_state; @@ -491,5 +498,4 @@ void dispatch_buffered_keys(Window *w); bool screen_needs_rendering_in_layers(OSWindow *os_window, Window *w, Screen *screen); void setup_os_window_for_rendering(OSWindow*, Tab*, Window*, bool); void swap_window_buffers(OSWindow *w); -void take_screenshot_of_oswindow(OSWindow *os_window, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h, bool include_tab_bar); -bool take_screenshot_of_window(id_type window_id, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h); +void take_screenshot_of_rectangular_region(OSWindow *os_window, Region region, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h); diff --git a/kitty/tabs.py b/kitty/tabs.py index 8bf1c69db..280adc0ee 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -2,6 +2,7 @@ # License: GPL v3 Copyright: 2016, Kovid Goyal import json +import math import os import re import stat @@ -37,10 +38,12 @@ from .fast_data_types import ( next_window_id, remove_tab, remove_window, + request_callback_with_thumbnail, ring_bell, set_active_tab, set_active_window, set_redirect_keys_to_overlay, + set_tab_being_dragged, swap_tabs, sync_os_window_title, ) @@ -80,6 +83,14 @@ class TabMouseEvent(NamedTuple): tab_id: int = 0 +class TabDragState(NamedTuple): + tab_id: int + start_x: float + start_y: float + original_index: int + drag_started: bool = False # True if drag threshold exceeded + + class TabDict(TypedDict): id: int is_focused: bool @@ -1069,6 +1080,7 @@ class TabManager: # {{{ num_of_windows_with_progress: int = 0 total_progress: int = 0 has_indeterminate_progress: bool = False + tab_drag_state: TabDragState | None = None 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 @@ -1520,8 +1532,23 @@ class TabManager: # {{{ )) return ans - def handle_click_on_tab(self, x: int, button: int, modifiers: int, action: int) -> None: - tab = self.tab_for_id(self.tab_bar.tab_id_at(x)) + def start_tab_drag(self, pixels: bytes, width: int, height: int) -> None: + if (state := self.tab_drag_state) is None: + return + from .fast_data_types import png_from_32bit_rgba_data + with open('/t/screenshot.png', 'wb') as f: + f.write(png_from_32bit_rgba_data(pixels, width, height)) + print(11111111, state, width, height) + + def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int, action: int) -> None: + if button == -1: # motion + if (state := self.tab_drag_state) is not None and not state.drag_started: + if math.sqrt((x-state.start_x)**2 + (y-state.start_y)**2) > 5: + self.tab_drag_state = state._replace(drag_started=True) + 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))) now = monotonic() if tab is None: if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE and len(self.recent_mouse_events) > 2: @@ -1537,12 +1564,21 @@ class TabManager: # {{{ self.recent_mouse_events.clear() return else: - if action == GLFW_PRESS and button == GLFW_MOUSE_BUTTON_LEFT: - self.set_active_tab(tab) - elif button == GLFW_MOUSE_BUTTON_MIDDLE and action == GLFW_RELEASE and self.recent_mouse_events: - p = self.recent_mouse_events[-1] - if p.button == button and p.action == GLFW_PRESS and p.tab_id == tab.id: - get_boss().close_tab(tab) + if button == GLFW_MOUSE_BUTTON_LEFT: + if action == GLFW_PRESS: + if (idx := self.tabs.index(tab) if tab in self.tabs else -1) > -1: + set_tab_being_dragged(tab.id) + self.tab_drag_state = TabDragState( + tab_id=tab.id, start_x=x, start_y=y, original_index=idx) + else: + if self.tab_drag_state is None or not self.tab_drag_state.drag_started: + self.set_active_tab(tab) + set_tab_being_dragged(0) + elif button == GLFW_MOUSE_BUTTON_MIDDLE: + if action == GLFW_RELEASE and self.recent_mouse_events: + p = self.recent_mouse_events[-1] + if p.button == button and p.action == GLFW_PRESS and p.tab_id == tab.id: + get_boss().close_tab(tab) self.recent_mouse_events.append(TabMouseEvent(button, modifiers, action, now, tab.id if tab else 0)) if len(self.recent_mouse_events) > 5: self.recent_mouse_events.popleft()