Start work on drag and drop for tabs

This commit is contained in:
Kovid Goyal 2026-02-19 12:37:31 +05:30
parent 37c2dae810
commit dc57e5bdb8
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
10 changed files with 169 additions and 80 deletions

View file

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

View file

@ -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, &central, &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;
}

View file

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

View file

@ -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");

View file

@ -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 */
};

View file

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

View file

@ -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, &central, &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 {{{

View file

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

View file

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

View file

@ -2,6 +2,7 @@
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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()