terminal/kitty: drag and drop stream handler

This commit is contained in:
Mitchell Hashimoto
2026-08-22 09:30:40 -07:00
parent 50f69b883c
commit af8d28a940
6 changed files with 274 additions and 2 deletions

View File

@@ -91,6 +91,12 @@ mouse_shape: mouse.Shape = .text,
/// Per-session Glyph Protocol registrations.
glyph_glossary: glyph.Glossary = .empty,
/// Kitty drag and drop protocol (OSC 72) state. Allocated when a client
/// registers to accept drops (t=a) and freed when it unregisters (t=A),
/// so a terminal that never runs a drag and drop aware program pays
/// nothing for it. Non-null means a client currently accepts drops.
kitty_dnd: ?*kitty.dnd.State = null,
/// These are just a packed set of flags we may set on the terminal.
flags: packed struct {
// This supports a Kitty extension where programs using semantic
@@ -353,6 +359,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.pwd.deinit(alloc);
self.title.deinit(alloc);
self.glyph_glossary.deinit(alloc);
if (self.kitty_dnd) |dnd| dnd.destroy(alloc);
self.* = undefined;
}
@@ -4907,6 +4914,9 @@ pub fn fullReset(self: *Terminal) void {
self.pwd.clearRetainingCapacity();
self.title.clearRetainingCapacity();
self.glyph_glossary.clearAndFree(self.gpa());
// A reset only interrupts an in-progress chunked OSC 72 command;
// drag and drop registration survives, matching kitty.
if (self.kitty_dnd) |dnd| dnd.chunking = .{};
self.status_display = .main;
self.scrolling_region = .{
.top = 0,

View File

@@ -657,6 +657,7 @@ fn wrap(
.bell = &Effects.bellTrampoline,
.color_scheme = &Effects.colorSchemeTrampoline,
.desktop_notification = &Effects.desktopNotificationTrampoline,
.drag_and_drop = null,
.device_attributes = &Effects.deviceAttributesTrampoline,
.enquiry = &Effects.enquiryTrampoline,
.xtversion = &Effects.xtversionTrampoline,

View File

@@ -248,7 +248,7 @@ fn refuseDragOut(
/// A protocol state change an embedder may need to act on, returned by
/// `handleCommand` and delivered through the stream handler's
/// `dnd_event` effect. This is a flat enum so it can cross a C API
/// `drag_and_drop` effect. This is a flat enum so it can cross a C API
/// unchanged; any details are read back from `Terminal.kitty_dnd`.
pub const Event = enum {
/// The client registered (t=a), re-registered, or unregistered

View File

@@ -129,6 +129,7 @@ pub const Action = union(Key) {
color_operation: ColorOperation,
semantic_prompt: SemanticPrompt,
kitty_clipboard: KittyClipboard,
kitty_dnd: KittyDnd,
pub const Key = lib.Enum(
lib.target,
@@ -229,6 +230,7 @@ pub const Action = union(Key) {
"color_operation",
"semantic_prompt",
"kitty_clipboard",
"kitty_dnd",
},
);
@@ -449,6 +451,8 @@ pub const Action = union(Key) {
pub const SemanticPrompt = osc.Command.SemanticPrompt;
pub const KittyClipboard = osc.Command.KittyClipboardProtocol;
pub const KittyDnd = osc.Command.KittyDndProtocol;
};
/// Returns a type that can process a stream of tty control characters.
@@ -2561,6 +2565,10 @@ pub fn Stream(comptime H: type) type {
self.handler.vt(.kitty_clipboard, v);
},
.kitty_dnd_protocol => |v| {
self.handler.vt(.kitty_dnd, v);
},
.conemu_sleep,
.conemu_show_message_box,
.conemu_change_tab_title,
@@ -2571,7 +2579,6 @@ pub fn Stream(comptime H: type) type {
.conemu_output_environment_variable,
.conemu_run_process,
.kitty_text_sizing,
.kitty_dnd_protocol,
.kitty_desktop_notification,
.context_signal,
=> {

View File

@@ -1,4 +1,5 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const build_options = @import("terminal_options");
const testing = std.testing;
const apc = @import("apc.zig");
@@ -16,6 +17,7 @@ const osc = @import("osc.zig");
const osc_color = @import("osc/parsers/color.zig");
const kitty_clipboard = @import("kitty/clipboard.zig");
const kitty_color = @import("kitty/color.zig");
const kitty_dnd = @import("kitty/dnd.zig");
const size_report = @import("size_report.zig");
const simd = @import("../simd/main.zig");
const terminfo = @import("../terminfo/main.zig");
@@ -115,6 +117,15 @@ pub const Handler = struct {
/// valid for the duration of the callback.
desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void,
/// Called when drag and drop protocol state changes in a way the
/// embedder may need to act on: the running program registering
/// or unregistering to accept drops, answering a drag, or
/// concluding a drop. The event says what changed; the details
/// are read from `handler.terminal.kitty_dnd` (Kitty's OSC 72 is
/// the only drag and drop protocol today). Native drag events
/// flow the other way, by calling `kitty.dnd.State` directly.
drag_and_drop: ?*const fn (*Handler, kitty_dnd.Event) void,
/// Called in response to a color scheme DSR query (CSI ? 996 n).
/// Returns the current color scheme. Return null to silently
/// ignore the query.
@@ -207,6 +218,7 @@ pub const Handler = struct {
.color_scheme = null,
.desktop_notification = null,
.device_attributes = null,
.drag_and_drop = null,
.enquiry = null,
.progress_report = null,
.size = null,
@@ -439,6 +451,12 @@ pub const Handler = struct {
// Clipboard operations are external effects, not terminal state.
log.warn("error handling clipboard operation err={}", .{err});
},
.kitty_dnd => self.kittyDnd(value) catch |err| {
// Drag and drop is a self-contained subsystem: an OOM
// updating its state or a failure writing a response
// degrades it without corrupting terminal state, so we log.
log.warn("error handling kitty dnd err={}", .{err});
},
.dcs_hook => try self.dcsHook(value),
.dcs_put => try self.dcsPut(value),
@@ -1080,6 +1098,40 @@ pub const Handler = struct {
self.writePty(resp);
}
/// Handle an OSC 72 drag and drop command.
fn kittyDnd(
self: *Handler,
v: Action.KittyDnd,
) (Allocator.Error || std.Io.Writer.Error)!void {
// Responses are usually small (queries, errors) but data
// serving can produce many chunks, so fall back to the heap.
var stack = std.heap.stackFallback(512, self.terminal.gpa());
const response_alloc = stack.get();
var aw: std.Io.Writer.Allocating = .init(response_alloc);
defer aw.deinit();
// The state is allocated on registration and owned by the
// terminal, so it uses the terminal's allocator, not the
// response's.
const event = try kitty_dnd.handleCommand(
&self.terminal.kitty_dnd,
self.terminal.gpa(),
&aw.writer,
v,
);
if (aw.written().len > 0) {
const written = aw.toOwnedSliceSentinel(0) catch return;
defer response_alloc.free(written);
self.writePty(written);
}
if (event) |ev| {
const func = self.effects.drag_and_drop orelse return;
func(self, ev);
}
}
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
@@ -5141,3 +5193,204 @@ test "continuation reconstructs standard stream without duplicate effects" {
restored_terminal.screens.active.cursor.style_id,
);
}
test "kitty dnd: query response" {
const S = struct {
var pty: std.ArrayListUnmanaged(u8) = .empty;
fn writePty(_: *Handler, data: [:0]const u8) void {
pty.appendSlice(testing.allocator, data) catch unreachable;
}
};
S.pty = .empty;
defer S.pty.deinit(testing.allocator);
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
s.nextSlice("\x1B]72;t=q:i=3\x1B\\");
try testing.expectEqualStrings("\x1b]72;t=q:i=3\x1b\\", S.pty.items);
}
test "kitty dnd: register, drop, and serve data" {
const S = struct {
var pty: std.ArrayListUnmanaged(u8) = .empty;
fn writePty(_: *Handler, data: [:0]const u8) void {
pty.appendSlice(testing.allocator, data) catch unreachable;
}
};
S.pty = .empty;
defer S.pty.deinit(testing.allocator);
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Client registers to accept drops.
s.nextSlice("\x1B]72;t=a;text/plain text/uri-list\x1B\\");
try testing.expectEqualStrings("", S.pty.items);
try testing.expect(t.kitty_dnd != null);
// A native drop arrives; the embedder feeds it to the terminal
// state and delivers the produced event bytes itself.
{
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{
.cell_x = 2,
.cell_y = 1,
.pixel_x = 20,
.pixel_y = 18,
.operations = .{ .copy = true },
}, &.{
.{ .mime = "text/plain", .data = "hello" },
});
try testing.expectEqualStrings(
"\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\",
aw.written(),
);
}
// The client requests the data and concludes.
s.nextSlice("\x1B]72;t=r:x=1\x1B\\");
try testing.expectEqualStrings(
"\x1b]72;t=r:x=1:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\",
S.pty.items,
);
S.pty.clearRetainingCapacity();
s.nextSlice("\x1B]72;t=r\x1B\\");
try testing.expectEqualStrings("", S.pty.items);
try testing.expect(t.kitty_dnd.?.drop.items == null);
}
test "kitty dnd: state updates work without write_pty effect" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) });
defer s.deinit();
// Queries produce no output (nowhere to write) but registration
// state still updates.
s.nextSlice("\x1B]72;t=q\x1B\\");
s.nextSlice("\x1B]72;t=a\x1B\\");
try testing.expect(t.kitty_dnd != null);
// The terminal remains functional.
s.nextSlice("ok");
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings("ok", str);
}
test "kitty dnd: registration survives terminal reset" {
const S = struct {
var pty: std.ArrayListUnmanaged(u8) = .empty;
fn writePty(_: *Handler, data: [:0]const u8) void {
pty.appendSlice(testing.allocator, data) catch unreachable;
}
};
S.pty = .empty;
defer S.pty.deinit(testing.allocator);
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Start a chunked command, then reset mid-chunk.
s.nextSlice("\x1B]72;t=a:i=5\x1B\\");
s.nextSlice("\x1B]72;t=m:o=1:m=1;text/pl\x1B\\");
s.nextSlice("\x1Bc");
// Registration survives (matching kitty), chunking was interrupted
// so a new command is not treated as a continuation.
try testing.expect(t.kitty_dnd != null);
try testing.expect(!t.kitty_dnd.?.chunking.active);
s.nextSlice("\x1B]72;t=q\x1B\\");
try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", S.pty.items);
}
test "kitty dnd: effect reports registration, acceptance, and conclusion" {
const S = struct {
var events: std.ArrayListUnmanaged(kitty_dnd.Event) = .empty;
var mimes: std.ArrayListUnmanaged(u8) = .empty;
fn clear() void {
events.deinit(testing.allocator);
events = .empty;
mimes.deinit(testing.allocator);
mimes = .empty;
}
fn dragAndDrop(handler: *Handler, ev: kitty_dnd.Event) void {
events.append(testing.allocator, ev) catch unreachable;
// Registration details are read from the terminal state.
if (ev == .registration) {
mimes.clearRetainingCapacity();
const state = handler.terminal.kitty_dnd orelse return;
var it = state.registeredMimes();
while (it.next()) |m| {
mimes.appendSlice(testing.allocator, m) catch unreachable;
mimes.append(testing.allocator, ',') catch unreachable;
}
}
}
};
S.clear();
defer S.clear();
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
var handler: Handler = .init(&t);
handler.effects.drag_and_drop = &S.dragAndDrop;
var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
defer s.deinit();
// Registration with a MIME list, read back from the state.
s.nextSlice("\x1B]72;t=a;image/png text/plain\x1B\\");
try testing.expectEqual(@as(usize, 1), S.events.items.len);
try testing.expect(S.events.items[0] == .registration);
try testing.expectEqualStrings("image/png,text/plain,", S.mimes.items);
// A native drag and the client's answer.
{
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{
.cell_x = 0,
.cell_y = 0,
.pixel_x = 0,
.pixel_y = 0,
.operations = .{ .copy = true },
}, &.{.{ .mime = "text/plain", .data = "x" }});
}
s.nextSlice("\x1B]72;t=m:o=2;text/plain\x1B\\");
try testing.expectEqual(@as(usize, 2), S.events.items.len);
try testing.expect(S.events.items[1] == .acceptance);
// Conclusion carries the performed operation.
s.nextSlice("\x1B]72;t=r:o=2\x1B\\");
try testing.expectEqual(@as(usize, 3), S.events.items.len);
try testing.expectEqual(kitty_dnd.Event.concluded_move, S.events.items[2]);
// Unregistration reports with the state gone.
s.nextSlice("\x1B]72;t=A\x1B\\");
try testing.expectEqual(@as(usize, 4), S.events.items.len);
try testing.expect(S.events.items[3] == .registration);
try testing.expect(t.kitty_dnd == null);
try testing.expectEqualStrings("", S.mimes.items);
}

View File

@@ -357,6 +357,7 @@ pub const StreamHandler = struct {
.title_push,
.title_pop,
.kitty_clipboard,
.kitty_dnd,
=> {},
}
}