Merge branch 'copilot/implement-drag-thumbnail-text' of https://github.com/kovidgoyal/kitty

This commit is contained in:
Kovid Goyal 2026-05-04 11:44:08 +05:30
commit 7942e7aaac
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
6 changed files with 126 additions and 10 deletions

View file

@ -25,6 +25,7 @@ static size_t MIME_LIST_SIZE_CAP = DEFAULT_MIME_LIST_SIZE_CAP;
static size_t PRESENT_DATA_CAP = DEFAULT_PRESENT_DATA_CAP;
#define DEFAULT_REMOTE_DRAG_LIMIT 1024 * 1024 * 1024
static size_t REMOTE_DRAG_LIMIT = DEFAULT_REMOTE_DRAG_LIMIT;
#define DRAG_OPACITY_MAX 1024u
static PyObject *g_dnd_test_write_func = NULL;
static const unsigned file_permissions = 0644;
static const unsigned dir_permissions = 0755;
@ -1343,17 +1344,18 @@ drag_add_pre_sent_data(Window *w, unsigned idx, const uint8_t *payload, size_t s
#define img ds.images[idx]
void
drag_add_image(Window *w, unsigned idx, int fmt, int width, int height, const uint8_t *payload, size_t sz) {
drag_add_image(Window *w, unsigned idx, int fmt, int width, int height, int opacity, const uint8_t *payload, size_t sz) {
if (ds.state != DRAG_SOURCE_BEING_BUILT) abrt(EINVAL);
if (idx + 1 >= arraysz(ds.images)) abrt(EFBIG);
if (ds.images_sent_total_sz + sz > PRESENT_DATA_CAP) abrt(EFBIG);
ds.images_sent_total_sz += sz;
if (!img.started) {
if (fmt != 24 && fmt != 32 && fmt != 100) abrt(EINVAL);
if (width < 1 || height < 1) abrt(EINVAL);
if (fmt != 0 && fmt != 24 && fmt != 32 && fmt != 100) abrt(EINVAL);
if (fmt != 0 && (width < 1 || height < 1)) abrt(EINVAL);
img.started = true;
img.width = width; img.height = height;
img.fmt = fmt;
img.opacity = opacity;
base64_init_stream_decoder(&img.base64_state);
}
if (img.capacity < sz + img.sz) {
@ -1420,10 +1422,64 @@ drag_start(Window *w) {
case 100:
if (!expand_png_data(w, idx)) return;
break;
case 0: {
// Text format: render using draw_window_title
OSWindow *osw = os_window_for_kitty_window(w->id);
if (!osw || !osw->fonts_data) break; // no fonts available, skip
int X = img.width > 0 ? img.width : 1;
int Y = img.height > 0 ? img.height : 1;
double adjusted_font_sz = osw->fonts_data->font_sz_in_pts * (double)X / (double)Y;
double ydpi = osw->fonts_data->logical_dpi_y;
double px_sz_d = adjusted_font_sz * ydpi / 72.0;
if (px_sz_d < 1.0) px_sz_d = 1.0;
size_t render_height = (size_t)(px_sz_d * 4.0 / 3.0 + 0.5);
if (render_height < 1) render_height = 1;
// White text on a background with the specified opacity (0-DRAG_OPACITY_MAX)
color_type fg_color = 0xFFFFFF;
uint8_t bg_alpha = (uint8_t)(((uint32_t)img.opacity * 255u + DRAG_OPACITY_MAX / 2) / DRAG_OPACITY_MAX);
color_type bg_color = ((uint32_t)bg_alpha) << 24;
// Add a null terminator for draw_window_title
uint8_t *txt = realloc(img.data, img.sz + 1);
if (!txt) { abrt(ENOMEM); return; }
img.data = txt; txt[img.sz] = '\0';
// Render into a max-width buffer; draw_window_title reduces actual_width
// to fit the text, using actual_width as the row stride in the buffer
size_t max_width = 4096;
uint8_t *render_buf = malloc(max_width * render_height * 4);
if (!render_buf) { abrt(ENOMEM); return; }
size_t actual_width = max_width;
bool ok = draw_window_title(adjusted_font_sz, ydpi, (const char*)img.data,
fg_color, bg_color, render_buf,
max_width, render_height, &actual_width);
if (!ok || actual_width < 1) { free(render_buf); break; }
// Shrink the buffer to the actual rendered size (data is already compact
// since draw_window_title uses actual_width as the row stride)
size_t final_sz = actual_width * render_height * 4;
uint8_t *compact = realloc(render_buf, final_sz);
if (!compact) { free(render_buf); abrt(ENOMEM); return; }
render_buf = compact;
// Un-premultiply alpha: draw_window_title output is pre-multiplied RGBA
for (size_t j = 0; j < actual_width * render_height; j++) {
uint8_t *px = render_buf + j * 4;
uint16_t a = px[3];
if (a > 0) {
px[0] = (uint8_t)(((uint16_t)px[0] * 255u + a / 2u) / a);
px[1] = (uint8_t)(((uint16_t)px[1] * 255u + a / 2u) / a);
px[2] = (uint8_t)(((uint16_t)px[2] * 255u + a / 2u) / a);
}
}
free(img.data);
img.data = render_buf;
img.sz = final_sz;
img.width = (int)actual_width;
img.height = (int)render_height;
img.fmt = 32;
break;
}
}
total_size += img.sz;
if (total_size > 2 * PRESENT_DATA_CAP) abrt(EFBIG);
if (img.sz != (size_t)img.width * (size_t)img.height * 4u) abrt(EINVAL);
if (img.fmt != 0 && img.sz != (size_t)img.width * (size_t)img.height * 4u) abrt(EINVAL);
}
}
last_total_image_size = total_size;

View file

@ -26,7 +26,7 @@ typedef enum { DRAG_NOTIFY_ACCEPTED, DRAG_NOTIFY_ACTION_CHANGED, DRAG_NOTIFY_DRO
void drag_free_offer(Window *w);
void drag_add_mimes(Window *w, int allowed_operations, uint32_t client_id, const char *data, size_t sz, bool has_more);
void drag_add_pre_sent_data(Window *w, unsigned idx, const uint8_t *payload, size_t sz);
void drag_add_image(Window *w, unsigned idx_, int fmt, int width, int height, const uint8_t *payload, size_t sz);
void drag_add_image(Window *w, unsigned idx_, int fmt, int width, int height, int opacity, const uint8_t *payload, size_t sz);
void drag_change_image(Window *w, unsigned idx);
void drag_start(Window *w);
void drag_notify(Window *w, DragNotifyType type);

View file

@ -488,6 +488,26 @@ fallback_font(char_type ch, const char *family, bool bold, bool italic, bool pre
ok = _native_fc_match(pat, ans);
end:
if (pat != NULL) FcPatternDestroy(pat);
if (!ok && builtin_nerd_font.face && builtin_nerd_font.descriptor &&
glyph_id_for_codepoint(builtin_nerd_font.face, ch) > 0) {
PyObject *pypath = PyDict_GetItemString(builtin_nerd_font.descriptor, "path");
PyObject *pyindex = PyDict_GetItemString(builtin_nerd_font.descriptor, "index");
PyObject *pyhinting = PyDict_GetItemString(builtin_nerd_font.descriptor, "hinting");
PyObject *pyhintstyle = PyDict_GetItemString(builtin_nerd_font.descriptor, "hint_style");
if (pypath && PyUnicode_Check(pypath)) {
const char *path = PyUnicode_AsUTF8(pypath);
if (path) {
ans->path = strdup(path);
if (ans->path) {
ans->index = (pyindex && PyLong_Check(pyindex)) ? (int)PyLong_AsLong(pyindex) : 0;
ans->hinting = (pyhinting && PyLong_Check(pyhinting)) ? (int)PyLong_AsLong(pyhinting) : 0;
ans->hintstyle = (pyhintstyle && PyLong_Check(pyhintstyle)) ? (int)PyLong_AsLong(pyhintstyle) : 0;
ok = true;
}
}
}
if (PyErr_Occurred()) PyErr_Clear();
}
return ok;
}

View file

@ -1562,7 +1562,7 @@ screen_handle_dnd_command(Screen *self, const DnDCommand *cmd_, const uint8_t *p
} break;
case 'p': {
if (cmd->cell_x >= 0) drag_add_pre_sent_data(w, cmd->cell_x, payload, cmd->payload_sz);
else drag_add_image(w, -cmd->cell_x, cmd->cell_y, cmd->pixel_x, cmd->pixel_y, payload, cmd->payload_sz);
else drag_add_image(w, -cmd->cell_x, cmd->cell_y, cmd->pixel_x, cmd->pixel_y, (int)cmd->operation, payload, cmd->payload_sz);
} break;
case 'P': {
if (cmd->cell_x >= 0) drag_change_image(w, cmd->cell_x);

View file

@ -331,7 +331,7 @@ typedef struct Window {
char *base_dir_for_remote_items; int base_dir_fd_plus_one;
} *items;
struct {
int width, height, fmt; uint8_t *data; size_t sz, capacity; bool started; base64_state base64_state;
int width, height, fmt, opacity; uint8_t *data; size_t sz, capacity; bool started; base64_state base64_state;
} images[16];
size_t pre_sent_total_sz, images_sent_total_sz;
unsigned img_idx;

View file

@ -140,14 +140,17 @@ def client_drag_pre_send(idx: int, data_b64: str, client_id: int = 0, more: bool
def client_drag_add_image(
idx: int, fmt: int, width: int, height: int, data_b64: str,
client_id: int = 0, more: bool = False,
client_id: int = 0, more: bool = False, opacity: int = 0,
) -> bytes:
"""Escape code for adding an image thumbnail (t=p:x=-idx:y=fmt:X=w:Y=h ; b64).
*idx*: 1-based image number (will be negated, so idx=1 means x=-1).
*fmt*: 24=RGB, 32=RGBA, 100=PNG.
*fmt*: 0=text, 24=RGB, 32=RGBA, 100=PNG.
*opacity*: background opacity for fmt=0 (0=transparent, 1024=opaque).
"""
meta = f'{DND_CODE};t=p:x=-{idx}:y={fmt}:X={width}:Y={height}'
if opacity:
meta += f':o={opacity}'
if client_id:
meta += f':i={client_id}'
if more:
@ -1930,8 +1933,45 @@ class TestDnDProtocol(BaseTest):
parse_bytes(screen, client_drag_start())
self.assert_error(cap)
def test_drag_add_image_text_valid(self) -> None:
"""Adding a text thumbnail (fmt=0) is accepted without error."""
with dnd_test_window() as (screen, cap):
self._setup_drag_offer(screen, cap, 'text/plain')
text = '📁'
data_b64 = standard_b64encode(text.encode()).decode()
parse_bytes(screen, client_drag_add_image(1, 0, 1, 1, data_b64))
self._assert_no_output(cap)
def test_drag_add_image_text_zero_scale(self) -> None:
"""Text thumbnail with X=0 and Y=0 (default 1/1 scaling) is accepted."""
with dnd_test_window() as (screen, cap):
self._setup_drag_offer(screen, cap, 'text/plain')
text = 'drag me'
data_b64 = standard_b64encode(text.encode()).decode()
# X=0, Y=0 means default scaling (1/1)
parse_bytes(screen, client_drag_add_image(1, 0, 0, 0, data_b64))
self._assert_no_output(cap)
def test_drag_add_image_text_with_opacity(self) -> None:
"""Text thumbnail with opacity key is accepted."""
with dnd_test_window() as (screen, cap):
self._setup_drag_offer(screen, cap, 'text/plain')
text = 'file'
data_b64 = standard_b64encode(text.encode()).decode()
# o=512 means 50% opaque background
parse_bytes(screen, client_drag_add_image(1, 0, 1, 1, data_b64, opacity=512))
self._assert_no_output(cap)
def test_drag_add_image_text_fully_opaque(self) -> None:
"""Text thumbnail with fully opaque background (o=1024) is accepted."""
with dnd_test_window() as (screen, cap):
self._setup_drag_offer(screen, cap, 'text/plain')
text = 'hello'
data_b64 = standard_b64encode(text.encode()).decode()
parse_bytes(screen, client_drag_add_image(1, 0, 2, 1, data_b64, opacity=1024))
self._assert_no_output(cap)
# ---- Request queue and disambiguation tests --------------------------------
def test_x_key_echoed_in_data_response(self) -> None:
"""x= key is echoed in data responses to identify which request is being answered."""