diff --git a/kitty/dnd.c b/kitty/dnd.c index 3dd3825af..f93bf0b35 100644 --- a/kitty/dnd.c +++ b/kitty/dnd.c @@ -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; diff --git a/kitty/dnd.h b/kitty/dnd.h index ec5fb2a5e..5c51c139c 100644 --- a/kitty/dnd.h +++ b/kitty/dnd.h @@ -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); diff --git a/kitty/fontconfig.c b/kitty/fontconfig.c index 2c97e6377..143150702 100644 --- a/kitty/fontconfig.c +++ b/kitty/fontconfig.c @@ -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; } diff --git a/kitty/screen.c b/kitty/screen.c index d297e60f6..2097a2b34 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -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); diff --git a/kitty/state.h b/kitty/state.h index 470155719..0a9afc2fb 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -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; diff --git a/kitty_tests/dnd.py b/kitty_tests/dnd.py index 35dca5de3..8aa1ad4f3 100644 --- a/kitty_tests/dnd.py +++ b/kitty_tests/dnd.py @@ -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."""