diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b7176bba2..561728f5f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ba00f1385..6c35d6e06 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -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, diff --git a/src/terminal/kitty/dnd_drop.zig b/src/terminal/kitty/dnd_drop.zig index f9486f9e9..e3c7ee5ce 100644 --- a/src/terminal/kitty/dnd_drop.zig +++ b/src/terminal/kitty/dnd_drop.zig @@ -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 diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5556ba808..cd62a316a 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -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, => { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 03e6d1887..bcf8690bc 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 192e51644..1dc5268f7 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -357,6 +357,7 @@ pub const StreamHandler = struct { .title_push, .title_pop, .kitty_clipboard, + .kitty_dnd, => {}, } }