diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/Surface.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/Surface.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/Surface.zig | 1073 |
1 files changed, 791 insertions, 282 deletions
diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig index 2c4edc49..77529212 100644 --- a/vendor/libghostty-vt/src/Surface.zig +++ b/vendor/libghostty-vt/src/Surface.zig @@ -20,8 +20,9 @@ const builtin = @import("builtin"); const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const global_state = &@import("global.zig").state; +const global = @import("global.zig"); const oni = @import("oniguruma"); +const simd = @import("simd/main.zig"); const crash = @import("crash/main.zig"); const unicode = @import("unicode/main.zig"); const rendererpkg = @import("renderer.zig"); @@ -154,6 +155,10 @@ child_exited: bool = false, /// to let us know. focused: bool = true, +/// Whether this surface may be visible. Unknown visibility is considered +/// visible so reporting remains conservative. +visible: bool = true, + /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, @@ -168,13 +173,13 @@ readonly: bool = false, /// precision timestamp. It does not necessarily need to correspond to the /// actual time, but we must be able to compare two subsequent timestamps to get /// the wall clock time that has elapsed between timestamps. -command_timer: ?std.time.Instant = null, +command_timer: ?std.Io.Timestamp = null, /// Search state search: ?Search = null, /// Used to rate limit BEL handling. -last_bell_time: ?std.time.Instant = null, +last_bell_time: ?std.Io.Timestamp = null, /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key @@ -330,6 +335,7 @@ const DerivedConfig = struct { title: ?[:0]const u8, title_report: bool, links: []DerivedConfig.Link, + link_osc8: bool, link_previews: configpkg.LinkPreviews, scroll_to_bottom: configpkg.Config.ScrollToBottom, notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, @@ -409,6 +415,7 @@ const DerivedConfig = struct { .title = config.title, .title_report = config.@"title-report", .links = links, + .link_osc8 = config.@"link-osc8", .link_previews = config.@"link-previews", .scroll_to_bottom = config.@"scroll-to-bottom", .notify_on_command_finish = config.@"notify-on-command-finish", @@ -559,8 +566,8 @@ pub fn init( errdefer renderer_impl.deinit(); // The mutex used to protect our renderer state. - const mutex = try alloc.create(std.Thread.Mutex); - mutex.* = .{}; + const mutex = try alloc.create(std.Io.Mutex); + mutex.* = .init; errdefer alloc.destroy(mutex); // Create the renderer thread @@ -581,7 +588,11 @@ pub fn init( self.* = .{ .id = id: { while (true) { - const candidate = std.crypto.random.int(u64); + const candidate = candidate: { + const rng_impl: std.Random.IoSource = .{ .io = global.io() }; + const rng = rng_impl.interface(); + break :candidate rng.int(u64); + }; if (candidate == 0) continue; break :id candidate; } @@ -631,13 +642,12 @@ pub fn init( var env = rt_surface.defaultTermioEnv() catch |err| env: { // If an error occurs, we don't want to block surface startup. log.warn("error getting env map for surface err={}", .{err}); - break :env internal_os.getEnvMap(alloc) catch - std.process.EnvMap.init(alloc); + break :env global.environMap() catch std.process.Environ.Map.init(alloc); }; errdefer env.deinit(); // don't leak GHOSTTY_LOG to any subprocesses - env.remove("GHOSTTY_LOG"); + _ = env.orderedRemove("GHOSTTY_LOG"); var buf: [18]u8 = undefined; try env.put( @@ -654,7 +664,7 @@ pub fn init( .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", .working_directory = if (config.@"working-directory") |wd| wd.value() else null, - .resources_dir = global_state.resources_dir.host(), + .resources_dir = global.resourcesDir().host(), .term = config.term, .rt_pre_exec_info = .init(config), .rt_post_fork_info = .init(config), @@ -717,7 +727,7 @@ pub fn init( rendererpkg.Thread.threadMain, .{&self.renderer_thread}, ); - self.renderer_thr.setName("renderer") catch {}; + self.renderer_thr.setName(global.io(), "renderer") catch {}; // Start our IO thread self.io_thr = try std.Thread.spawn( @@ -725,7 +735,7 @@ pub fn init( termio.Thread.threadMain, .{ &self.io_thread, &self.io }, ); - self.io_thr.setName("io") catch {}; + self.io_thr.setName(global.io(), "io") catch {}; // Determine our initial window size if configured. We need to do this // quite late in the process because our height/width are in grid dimensions, @@ -834,7 +844,7 @@ pub fn deinit(self: *Surface) void { self.alloc.destroy(self.renderer_state.mutex); self.config.deinit(); - log.info("surface closed addr={x}", .{@intFromPtr(self)}); + log.info("surface closed id={x}", .{self.id}); } /// Close this surface. This will trigger the runtime to start the @@ -851,7 +861,7 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } -/// Queue a message for the IO thread. +/// Queue a message for the IO thread, taking ownership of `msg`. /// /// We centralize all our logic into this spot so we can intercept /// messages for example in readonly mode. @@ -866,7 +876,10 @@ fn queueIo( .write_small, .write_stable, .write_alloc, - => return, + => { + msg.deinit(); + return; + }, else => {}, } @@ -900,14 +913,14 @@ pub fn activateInspector(self: *Surface) !void { // Put the inspector onto the render state { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); assert(self.renderer_state.inspector == null); self.renderer_state.inspector = self.inspector; } // Notify our components we have an inspector active - _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), .{ .inspector = true }, .{ .forever = {} }); self.queueIo(.{ .inspector = true }, .unlocked); } @@ -917,14 +930,14 @@ pub fn deactivateInspector(self: *Surface) void { // Remove the inspector from the render state { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); assert(self.renderer_state.inspector != null); self.renderer_state.inspector = null; } // Notify our components we have deactivated inspector - _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), .{ .inspector = false }, .{ .forever = {} }); self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector @@ -948,8 +961,8 @@ pub fn needsConfirmQuit(self: *Surface) bool { .always => true, .false => false, .true => true: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); break :true !self.io.terminal.cursorIsAtPrompt(); }, }; @@ -1049,6 +1062,10 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { _ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); }, + .kitty_clipboard_read => |req| try self.kittyClipboardRead(req), + + .kitty_clipboard_write => |req| try self.kittyClipboardWrite(req), + .clipboard_write => |w| switch (w.req) { .small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type), .stable => |v| try self.clipboardWrite(v, w.clipboard_type), @@ -1061,9 +1078,10 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .pwd_change => |w| { defer w.deinit(); - // We always allocate for this because we need to null-terminate. - const str = try self.alloc.dupeZ(u8, w.slice()); - defer self.alloc.free(str); + var stack = std.heap.stackFallback(256, self.alloc); + const alloc = stack.get(); + const str = try alloc.dupeZ(u8, w.slice()); + defer alloc.free(str); _ = try self.rt_app.performAction( .{ .surface = self }, @@ -1096,9 +1114,9 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .password_input => |v| try self.passwordInput(v), .ring_bell => bell: { - const now = std.time.Instant.now() catch unreachable; + const now: std.Io.Timestamp = .now(global.io(), .awake); if (self.last_bell_time) |last| { - if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + if (last.durationTo(now).toMilliseconds() < 100) break :bell; } self.last_bell_time = now; _ = self.rt_app.performAction( @@ -1126,15 +1144,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .start_command => { - self.command_timer = try .now(); + self.command_timer = .now(global.io(), .awake); }, .stop_command => |v| timer: { - const end: std.time.Instant = try .now(); + const end: std.Io.Timestamp = .now(global.io(), .awake); const start = self.command_timer orelse break :timer; self.command_timer = null; + const duration_raw = start.durationTo(end).nanoseconds; + assert(duration_raw >= 0 and duration_raw <= std.math.maxInt(u64)); - const duration: Duration = .{ .duration = end.since(start) }; + const duration: Duration = .{ + .duration = @as( + u64, + @intCast(std.math.clamp(start.durationTo(end).nanoseconds, 0, std.math.maxInt(u64))), + ), + }; log.debug("command took {f}", .{duration}); _ = self.rt_app.performAction( @@ -1185,8 +1210,8 @@ fn selectionScrollTick(self: *Surface) !void { const pos_vp = self.posToViewport(pos.x, pos.y); // We need our locked state for the remainder - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; const selection = self.mouse.selection_gesture.autoscrollTick(t, .{ @@ -1276,8 +1301,8 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { // If the native GUI can't be shown, display a text message in the // terminal. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; t.carriageReturn(); t.linefeed() catch break :terminal; @@ -1316,8 +1341,8 @@ fn childExitedAbnormally( }); const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms}); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; // No matter what move the cursor back to the column 0. @@ -1383,8 +1408,8 @@ fn childExitedAbnormally( /// Called when the terminal detects there is a password input prompt. fn passwordInput(self: *Surface, v: bool) !void { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // If our password input state is unchanged then we don't // waste time doing anything more. @@ -1436,6 +1461,7 @@ fn searchCallback_( for (matches) |*m| m.* = try m.clone(alloc); _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_viewport_matches = .{ .arena = arena, .matches = matches, @@ -1454,6 +1480,7 @@ fn searchCallback_( const match = try sel.highlight.clone(alloc); _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_selected_match = .{ .arena = arena, .match = match, @@ -1469,6 +1496,7 @@ fn searchCallback_( } else { // Reset our selected match _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_selected_match = null }, .forever, ); @@ -1493,10 +1521,12 @@ fn searchCallback_( // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_selected_match = null }, .forever, ); _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_viewport_matches = .{ .arena = .init(self.alloc), .matches = &.{}, @@ -1539,8 +1569,8 @@ fn modsChanged(self: *Surface, mods: input.Mods) void { // highlight links. Additionally, mark the screen as dirty so // that the highlight state of all links is properly updated. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.renderer_state.mouse.mods = self.mouseModsWithCapture(self.mouse.mods); // We use the clear screen dirty flag to force a rebuild of all @@ -1786,7 +1816,7 @@ pub fn updateConfig( termio_config_ptr.* = try termio.Termio.DerivedConfig.init(self.alloc, config); errdefer termio_config_ptr.deinit(); - _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), renderer_message, .{ .forever = {} }); self.queueIo(.{ .change_config = .{ .alloc = self.alloc, @@ -1907,8 +1937,8 @@ pub fn dumpText( alloc: Allocator, sel: terminal.Selection, ) !Text { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); return try self.dumpTextLocked(alloc, sel); } @@ -2033,15 +2063,15 @@ pub fn dumpTextLocked( /// Returns true if the terminal has a selection. pub fn hasSelection(self: *const Surface) bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); return self.io.terminal.screens.active.selection != null; } /// Returns the selected text. This is allocated. pub fn selectionString(self: *Surface, alloc: Allocator) !?[:0]const u8 { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.io.terminal.screens.active.selection orelse return null; return try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = sel, @@ -2056,8 +2086,8 @@ pub fn pwd( self: *const Surface, alloc: Allocator, ) Allocator.Error!?[]const u8 { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const terminal_pwd = self.io.terminal.getPwd() orelse return null; return try alloc.dupe(u8, terminal_pwd); } @@ -2074,7 +2104,7 @@ fn resolvePathForOpening( const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); - std.fs.accessAbsolute(resolved, .{}) catch { + std.Io.Dir.accessAbsolute(global.io(), resolved, .{}) catch { self.alloc.free(resolved); return null; }; @@ -2088,10 +2118,10 @@ fn resolvePathForOpening( /// Returns the x/y coordinate of where the IME (Input Method Editor) /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { - self.renderer_state.mutex.lock(); + self.renderer_state.mutex.lockUncancelable(global.io()); const cursor = self.renderer_state.terminal.screens.active.cursor; const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; - self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.unlock(global.io()); // TODO: need to handle when scrolling and the cursor is not // in the visible portion of the screen. @@ -2165,34 +2195,18 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) return; } - const dec = std.base64.standard.Decoder; - - // Build buffer - const size = dec.calcSizeForSlice(data) catch |err| switch (err) { - error.InvalidPadding => { - log.info("application sent invalid base64 data for OSC 52", .{}); - return; - }, - - // Should not be reachable but don't want to risk it. - else => return, - }; - var buf = try self.alloc.allocSentinel(u8, size, 0); + // Decode with the SIMD decoder, strict per the Kitty clipboard + // spec that also governs OSC 52 base64 handling: a request with + // characters outside the base64 alphabet is discarded entirely + // (never partially decoded), while a missing-padding tail is + // tolerated since OSC 52 has no way to report errors. + var buf = try self.alloc.allocSentinel(u8, simd.base64.maxLen(data), 0); defer self.alloc.free(buf); - buf[buf.len] = 0; - - // Decode - dec.decode(buf, data) catch |err| switch (err) { - // Ignore this. It is possible to actually have valid data and - // get this error, so we allow it. - error.InvalidPadding => {}, - - else => { - log.info("application sent invalid base64 data for OSC 52", .{}); - return; - }, + const decoded = simd.base64.decodeStrict(data, buf, .optional) catch { + log.info("application sent invalid base64 data for OSC 52", .{}); + return; }; - assert(buf[buf.len] == 0); + buf[decoded.len] = 0; // When clipboard-write is "ask" a prompt is displayed to the user asking // them to confirm the clipboard access. Each app runtime handles this @@ -2200,7 +2214,7 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) const confirm = self.config.clipboard_write == .ask; self.rt_surface.setClipboard(loc, &.{.{ .mime = "text/plain", - .data = buf, + .data = buf[0..decoded.len :0], }}, confirm) catch |err| { log.err("error setting clipboard string err={}", .{err}); return; @@ -2232,13 +2246,14 @@ fn copySelectionToClipboards( const ScreenFormatter = terminal.formatter.ScreenFormatter; var aw: std.Io.Writer.Allocating = .init(alloc); - var contents: std.ArrayList(apprt.ClipboardContent) = .empty; + var contents_buf: [2]apprt.ClipboardContent = undefined; + var contents: std.ArrayList(apprt.ClipboardContent) = .initBuffer(&contents_buf); switch (format) { .plain => { var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2255,7 +2270,7 @@ fn copySelectionToClipboards( // Note: We don't apply codepoint mappings to VT format since it contains // escape sequences that should be preserved as-is - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2272,7 +2287,7 @@ fn copySelectionToClipboards( // Note: We don't apply codepoint mappings to HTML format since HTML // has its own character encoding and entity system - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2283,7 +2298,7 @@ fn copySelectionToClipboards( var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2306,7 +2321,7 @@ fn copySelectionToClipboards( try formatter.format(&aw.writer); // Note: We don't apply codepoint mappings to HTML format - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2363,31 +2378,29 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { fn setSelectionAndCopy(self: *Surface, sel: terminal.Selection) !void { try self.setSelection(sel); - // If copy on select is false then exit early. - if (self.config.copy_on_select == .false) return; - switch (self.config.copy_on_select) { - .false => unreachable, // handled above with an early exit + .none => {}, - // Both standard and selection clipboards are set. + // The selection clipboard is set if supported, otherwise nothing is copied. + .primary => try self.copySelectionToClipboards( + sel, + &.{.selection}, + .mixed, + ), + + // Only the standard clipboard is set. .clipboard => try self.copySelectionToClipboards( sel, - &.{ .standard, .selection }, + &.{.standard}, .mixed, ), - // The selection clipboard is set if supported, otherwise the standard. - .true => { - const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection)) - .selection - else - .standard; - try self.copySelectionToClipboards( - sel, - &.{clipboard}, - .mixed, - ); - }, + // Both standard and selection clipboards are set. + .both => try self.copySelectionToClipboards( + sel, + &.{ .standard, .selection }, + .mixed, + ), } } @@ -2440,7 +2453,7 @@ pub fn setFontSize(self: *Surface, size: font.face.DesiredSize) !void { // Notify our render thread of the new font stack. The renderer // MUST accept the new font grid and deref the old. - _ = self.renderer_thread.mailbox.push(.{ + _ = self.renderer_thread.mailbox.push(global.io(), .{ .font_grid = .{ .grid = font_grid, .set = &self.app.font_grid_set, @@ -2530,8 +2543,8 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // We clear our selection when ANY OF: // 1. We have an existing preedit @@ -2566,7 +2579,7 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { // Allocate the codepoints slice const Codepoint = rendererpkg.State.Preedit.Codepoint; - var codepoints: std.ArrayListUnmanaged(Codepoint) = .{}; + var codepoints: std.ArrayList(Codepoint) = .empty; defer codepoints.deinit(self.alloc); while (it.nextCodepoint()) |cp| { const width: usize = @intCast(unicode.table.get(cp).width); @@ -2697,8 +2710,8 @@ pub fn keyCallback( )) |v| return v; // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed; } @@ -2728,8 +2741,8 @@ pub fn keyCallback( { // Refresh our link state const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.mouseRefreshLinks( pos, self.posToViewport(pos.x, pos.y), @@ -2802,11 +2815,11 @@ pub fn keyCallback( // an encoded value, we close the surface. We want to eventually // move this behavior to the apprt probably. if (self.child_exited) { + write_req.deinit(); self.close(); return .closed; } - errdefer write_req.deinit(); self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, @@ -2821,8 +2834,8 @@ pub fn keyCallback( // some data to send to the pty, then we move the viewport down to the // bottom. We also clear the selection for any key other then modifiers. if (!event.key.modifier()) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.config.selection_clear_on_typing or event.key == .escape) @@ -2924,16 +2937,19 @@ fn maybeHandleBinding( // Determine if this entry has an action or if its a leader key. const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => |set| { - // Setup the next set we'll look at. - self.keyboard.sequence_set = set; - // Store this event so that we can drain and encode on invalid. // We don't need to cap this because it is naturally capped by // the config validation. if (try self.encodeKey(event, insp_ev)) |req| { - try self.keyboard.sequence_queued.append(self.alloc, req); + self.keyboard.sequence_queued.append(self.alloc, req) catch |err| { + req.deinit(); + return err; + }; } + // Setup the next set we'll look at only after all fallible work. + self.keyboard.sequence_set = set; + // Start or continue our key sequence _ = self.rt_app.performAction( .{ .surface = self }, @@ -3222,17 +3238,15 @@ fn encodeKey( ); defer alloc_writer.deinit(); - // This results in a double allocation but this is such an unlikely - // path the performance impact is unimportant. try input.key_encode.encode( &alloc_writer.writer, event, encoding_opts, ); - break :req try termio.Message.WriteReq.init( - self.alloc, - alloc_writer.writer.buffered(), - ); + break :req .{ .alloc = .{ + .alloc = self.alloc, + .data = try alloc_writer.toOwnedSlice(), + } }; }; // Copy the encoded data into the inspector event if we have one. @@ -3253,8 +3267,8 @@ fn encodeKey( } fn encodeKeyOpts(self: *const Surface) input.key_encode.Options { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t = &self.io.terminal; var opts: input.key_encode.Options = .fromTerminal(t); @@ -3295,9 +3309,27 @@ pub fn occlusionCallback(self: *Surface, visible: bool) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; - _ = self.renderer_thread.mailbox.push(.{ + // Avoid duplicate renderer and visibility reports. + if (self.visible == visible) return; + self.visible = visible; + + // Update the terminal state for synchronous queries, then notify the IO + // thread so it can emit a mode 2033 report when enabled. + self.renderer_state.mutex.lockUncancelable(global.io()); + self.io.terminal.flags.visible = visible; + const report_visibility = self.io.terminal.modes.get(.report_visibility); + self.renderer_state.mutex.unlock(global.io()); + if (report_visibility) { + self.queueIo(.{ .visibility_report = .{ + .visible = visible, + .force = false, + } }, .unlocked); + } + + _ = self.renderer_thread.mailbox.push(global.io(), .{ .visible = visible, }, .{ .forever = {} }); + try self.queueRender(); } @@ -3315,7 +3347,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.focused = focused; // Notify our render thread of the new state - _ = self.renderer_thread.mailbox.push(.{ + _ = self.renderer_thread.mailbox.push(global.io(), .{ .focus = focused, }, .{ .forever = {} }); @@ -3382,9 +3414,9 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { // Update the focus state and notify the terminal { - self.renderer_state.mutex.lock(); + self.renderer_state.mutex.lockUncancelable(global.io()); self.io.terminal.flags.focused = focused; - self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.unlock(global.io()); self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3516,8 +3548,8 @@ pub fn scrollCallback( // log.info("SCROLL: delta_y={} delta_x={}", .{ y.delta, x.delta }); { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // If we have an active mouse reporting mode, clear the selection. // The selection can occur if the user uses the shift mod key to @@ -3642,6 +3674,12 @@ fn isMouseReporting(self: *const Surface) bool { self.io.terminal.flags.mouse_event != .none; } +pub fn mouseReportingActive(self: *Surface) bool { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + return self.isMouseReporting(); +} + fn mouseReport( self: *Surface, button: ?input.MouseButton, @@ -3717,8 +3755,8 @@ fn mouseShiftCapture(self: *const Surface, lock: bool) bool { .false, .true => {}, } - if (lock) self.renderer_state.mutex.lock(); - defer if (lock) self.renderer_state.mutex.unlock(); + if (lock) self.renderer_state.mutex.lockUncancelable(global.io()); + defer if (lock) self.renderer_state.mutex.unlock(global.io()); // If the terminal explicitly requests it then we always allow it // since we processed never/always at this point. @@ -3739,8 +3777,8 @@ fn mouseShiftCapture(self: *const Surface, lock: bool) bool { /// Returns true if the mouse is currently captured by the terminal /// (i.e. reporting events). pub fn mouseCaptured(self: *Surface) bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); return self.io.terminal.flags.mouse_event != .none; } @@ -3796,21 +3834,12 @@ pub fn mouseButtonCallback( // If we are within the interval that the click would register // an increment then we do not extend the selection. - if (std.time.Instant.now()) |now| { - const click_time = self.mouse.selection_gesture.left_click_time orelse - break :extend_selection; - const since = now.since(click_time); - if (since <= self.config.mouse_interval) { - // Click interval very short, we may be increasing - // click counts so we don't extend the selection. - break :extend_selection; - } - } else |err| { - // This is a weird behavior, I think either behavior is actually - // fine. This failure should be exceptionally rare anyways. - // My thinking here is that we can't be sure if we should extend - // the selection or not so we just don't. - log.warn("failed to get time, not extending selection err={}", .{err}); + const click_time = self.mouse.selection_gesture.left_click_time orelse + break :extend_selection; + const since = click_time.untilNow(global.io(), .awake); + if (since.toNanoseconds() <= self.config.mouse_interval) { + // Click interval very short, we may be increasing + // click counts so we don't extend the selection. break :extend_selection; } @@ -3821,8 +3850,8 @@ pub fn mouseButtonCallback( } if (button == .left and action == .release) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // The selection gesture tracks whether a press became a drag by // comparing the release cell to the original press cell. Resolve the @@ -3860,7 +3889,7 @@ pub fn mouseButtonCallback( // The selection clipboard is only updated for left-click drag when // the left button is released. This is to avoid the clipboard // being updated on every mouse move which would be noisy. - if (self.config.copy_on_select != .false) { + if (self.config.copy_on_select != .none) { const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelectionAndCopy(terminal.Selection.init( @@ -3897,8 +3926,8 @@ pub fn mouseButtonCallback( // Report mouse events if enabled { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.isMouseReporting()) report: { // If we have shift-pressed and we aren't allowed to capture it, // then we do not do a mouse report. @@ -3937,8 +3966,8 @@ pub fn mouseButtonCallback( // For left button clicks we always record some information for // selection/highlighting purposes. if (button == .left and action == .press) click: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; @@ -3962,12 +3991,8 @@ pub fn mouseButtonCallback( break :pin pin; }; - const time = std.time.Instant.now() catch |err| time: { - log.err("error reading time, mouse multi-click won't work err={}", .{err}); - break :time null; - }; var press_selection = try self.mouse.selection_gesture.press(t, .{ - .time = time, + .time = std.Io.Timestamp.now(global.io(), .awake), .pin = pin, .xpos = pos.x, .ypos = pos.y, @@ -4022,22 +4047,16 @@ pub fn mouseButtonCallback( } } - // Middle-click paste source follows copy-on-select: when copy-on-select - // targets the selection clipboard, middle-click reads from it; when - // copy-on-select targets the system clipboard, middle-click reads from - // that instead. Falls back to the standard clipboard on platforms that - // do not support the selection clipboard. + // Middle-click action, either ignore, or paste from clipboard or paste from the selection clipboard if supported. if (button == .middle and action == .press) switch (self.config.middle_click_action) { .ignore => {}, + .@"clipboard-paste" => { + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); + }, .@"primary-paste" => { - const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { - .clipboard => .standard, - .true, .false => if (self.rt_surface.supportsClipboard(.selection)) - .selection - else - .standard, - }; - _ = try self.startClipboardRequest(clipboard, .{ .paste = {} }); + if (self.rt_surface.supportsClipboard(.selection)) { + _ = try self.startClipboardRequest(.selection, .{ .paste = .selection }); + } }, }; @@ -4047,8 +4066,8 @@ pub fn mouseButtonCallback( // want to be careful in the future we can add a function to apprts // that let's us know. if (button == .right and action == .press) sel: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // Get our viewport pin const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; @@ -4119,9 +4138,9 @@ pub fn mouseButtonCallback( } else { // Pasting can trigger a lock grab in complete clipboard // request so we need to unlock. - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); - _ = try self.startClipboardRequest(.standard, .paste); + self.renderer_state.mutex.unlock(global.io()); + defer self.renderer_state.mutex.lockUncancelable(global.io()); + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); // We don't need to clear selection because we didn't have // one to begin with. @@ -4134,9 +4153,9 @@ pub fn mouseButtonCallback( // Pasting can trigger a lock grab in complete clipboard // request so we need to unlock. - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); - _ = try self.startClipboardRequest(.standard, .paste); + self.renderer_state.mutex.unlock(global.io()); + defer self.renderer_state.mutex.lockUncancelable(global.io()); + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); }, } @@ -4295,7 +4314,9 @@ fn linkAtPos( const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); // If we have the proper modifiers set then we can check for OSC8 links. - if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: { + if (self.config.link_osc8 and + mouse_mods.equal(input.ctrlOrSuper(.{}))) + hyperlink: { const rac = mouse_pin.rowAndCell(); const cell = rac.cell; if (!cell.hyperlink) break :hyperlink; @@ -4329,12 +4350,10 @@ fn linkAtPin( .semantic_prompt_boundary = true, }) orelse return null; - var strmap: terminal.StringMap = undefined; - self.alloc.free(try screen.selectionString(self.alloc, .{ + const strmap = try screen.selectionStringMap(self.alloc, .{ .sel = line, .trim = false, - .map = &strmap, - })); + }); defer strmap.deinit(self.alloc); for (self.config.links) |link| { @@ -4405,7 +4424,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try self.openUrl(.{ .kind = .unknown, .url = uri }); + try self.openUrl(.{ .kind = .osc8, .url = uri }); }, } @@ -4428,7 +4447,6 @@ fn openUrl( // apprts to handle this themselves. log.warn("apprt did not handle open URL action, falling back to default opener", .{}); try internal_os.open( - self.alloc, action.kind, action.url, ); @@ -4474,8 +4492,8 @@ pub fn mousePressureCallback( if (self.mouse.click_state[left_idx] == .press and stage == .deep) select: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.mouse.selection_gesture.deepPress( self.renderer_state.terminal, @@ -4540,8 +4558,8 @@ pub fn cursorPosCallback( try self.queueRender(); } - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // No mouse point so we don't highlight links self.renderer_state.mouse.point = null; @@ -4567,8 +4585,8 @@ pub fn cursorPosCallback( self.mouse.over_link = false; // We are reading/writing state for the remainder - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // Update our mouse state. We set this to null initially because we only // want to set it when we're not selecting or doing any other mouse @@ -4824,8 +4842,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // CSI/ESC triggers a scroll. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.scrollToBottom() catch |err| { log.warn("error scrolling to bottom err={}", .{err}); }; @@ -4833,10 +4851,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .text => |data| { - // For text we always allocate just because its easier to - // handle all cases that way. - const buf = try self.alloc.alloc(u8, data.len); - defer self.alloc.free(buf); + var stack = std.heap.stackFallback(256, self.alloc); + const alloc = stack.get(); + const buf = try alloc.alloc(u8, data.len); + defer alloc.free(buf); const text = configpkg.string.parse(buf, data) catch |err| { log.warn( "error parsing text binding text={s} err={}", @@ -4851,8 +4869,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // Text triggers a scroll. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.scrollToBottom() catch |err| { log.warn("error scrolling to bottom err={}", .{err}); }; @@ -4864,8 +4882,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // in cursor keys mode. We're in "normal" mode if cursor // keys mode is NOT set. const normal = normal: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // With the lock held, we must scroll to the bottom. // We always scroll to the bottom for these inputs. @@ -4884,8 +4902,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .reset => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.renderer_state.terminal.fullReset(); }, @@ -4955,7 +4973,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool terminal.search.Thread.threadMain, .{&s.state}, ); - s.thread.setName("search") catch {}; + s.thread.setName(global.io(), "search") catch {}; break :init s; }; @@ -4968,6 +4986,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } _ = s.state.mailbox.push( + global.io(), .{ .change_needle = try .init( self.alloc, text, @@ -4980,6 +4999,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .navigate_search => |nav| { const s: *Search = if (self.search) |*s| s else return false; _ = s.state.mailbox.push( + global.io(), .{ .select = switch (nav) { .next => .next, .previous => .prev, @@ -4990,8 +5010,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .copy_to_clipboard => |format| { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( @@ -5022,8 +5042,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (!self.mouse.over_link) return false; const pos = try self.rt_surface.getCursorPos(); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (try self.linkAtPos(pos)) |link_info| { const url_text = switch (link_info.action) { .open => url_text: { @@ -5068,15 +5088,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .paste_from_clipboard => return try self.startClipboardRequest( + .paste_from_clipboard => return (try self.startClipboardRequest( .standard, - .{ .paste = {} }, - ), + .{ .paste = .standard }, + )) == .started, - .paste_from_selection => return try self.startClipboardRequest( + .paste_from_selection => return (try self.startClipboardRequest( .selection, - .{ .paste = {} }, - ), + .{ .paste = .selection }, + )) == .started, .increase_font_size => |delta| { // Max delta is somewhat arbitrary. @@ -5141,6 +5161,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .tab, ), + .prompt_window_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .window, + ), + .set_surface_title => |v| { const title = try self.alloc.dupeZ(u8, v); defer self.alloc.free(title); @@ -5161,6 +5187,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .set_window_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_window_title, + .{ .title = title }, + ); + }, + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before @@ -5168,8 +5204,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // alternate screen then clear screen does nothing so we want to // return false so the keybind can be unconsumed. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.io.terminal.screens.active_key == .alternate) return false; } @@ -5192,8 +5228,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_to_row => |n| { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; t.screens.active.scroll(.{ .row = n }); } @@ -5203,8 +5239,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_to_selection => { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.io.terminal.screens.active.selection orelse return false; const tl = sel.topLeft(self.io.terminal.screens.active); self.io.terminal.screens.active.scroll(.{ .pin = tl }); @@ -5300,6 +5336,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .amount = position }, ), + .move_tab_to_new_window => return try self.rt_app.performAction( + .{ .surface = self }, + .move_tab_to_new_window, + {}, + ), + .new_split => |direction| return try self.rt_app.performAction( .{ .surface = self }, .new_split, @@ -5442,8 +5484,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { @@ -5564,7 +5606,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .main => @panic("crash binding action, crashing intentionally"), .render => { - _ = self.renderer_thread.mailbox.push(.{ .crash = {} }, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), .{ .crash = {} }, .{ .forever = {} }); self.queueRender() catch |err| { // Not a big deal if this fails. log.warn("failed to notify renderer of crash message err={}", .{err}); @@ -5575,8 +5617,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .adjust_selection => |direction| { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const screen: *terminal.Screen = self.io.terminal.screens.active; const sel = if (screen.selection) |*sel| sel else { @@ -5655,7 +5697,8 @@ fn writeScreenFile( ) !void { // Create a temporary directory to store our scrollback. var tmp_dir = try internal_os.TempDir.init(); - errdefer tmp_dir.deinit(); + var retain_tmp_dir = false; + defer if (retain_tmp_dir) tmp_dir.close(.retain) else tmp_dir.deinit(); var filename_buf: [std.fs.max_path_bytes]u8 = undefined; const filename = try std.fmt.bufPrint( @@ -5672,23 +5715,24 @@ fn writeScreenFile( // Open our scrollback file var file = try tmp_dir.dir.createFile( + global.io(), filename, switch (builtin.os.tag) { .windows => .{}, - else => .{ .mode = 0o600 }, + else => .{ .permissions = .fromMode(0o600) }, }, ); - defer file.close(); + defer file.close(global.io()); // Screen.dumpString writes byte-by-byte, so buffer it var buf: [4096]u8 = undefined; - var file_writer = file.writer(&buf); + var file_writer = file.writer(global.io(), &buf); var buf_writer = &file_writer.interface; // Write the scrollback contents. This requires a lock. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this @@ -5724,7 +5768,6 @@ fn writeScreenFile( const sel = sel_ orelse { // If we have no selection we have no data so we do nothing. - tmp_dir.deinit(); return; }; @@ -5751,7 +5794,11 @@ fn writeScreenFile( // Get the final path var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath(filename, &path_buf); + const path = path_buf[0..try tmp_dir.dir.realPathFile( + global.io(), + filename, + &path_buf, + )]; switch (write_screen.action) { .copy => { @@ -5774,71 +5821,294 @@ fn writeScreenFile( path, ), .unlocked), } + + // The action accepted the path, so retain the file for its consumer. + retain_tmp_dir = true; } +/// The payload for completing a clipboard request with +/// completeClipboardRequest. +pub const CompleteClipboard = struct { + /// The representations the apprt could serve for the request's MIME + /// types. These are immediately copied as needed so they only need + /// to live for the duration of the completion call. Requesters that + /// only carry text (paste, OSC 52) use the first text-like + /// representation. + contents: []const terminal.clipboard.Content = &.{}, + + /// The listing of MIME types available on the clipboard, only + /// gathered when the request asked for it. + available: []const []const u8 = &.{}, + + /// True if any clipboard confirmation prompt was already answered + /// by the user, skipping further prompts: + /// + /// - For "regular" pasting this means that unsafe pastes are + /// allowed. Unsafe data is defined as data that contains + /// newlines, though this definition may change later to detect + /// other scenarios. + /// + /// - For OSC 52 and Kitty clipboard protocol reads and writes no + /// prompt is shown to the user when this is true. + confirmed: bool = false, + + /// True if the user asked to remember their decision. This is only + /// honored by request types that support session grants (Kitty + /// clipboard protocol requests carrying a password). + remember: bool = false, +}; + /// Call this to complete a clipboard request sent to apprt. This should -/// only be called once for each request. The data is immediately copied so -/// it is safe to free the data after this call. -/// -/// If `confirmed` is true then any clipboard confirmation prompts are skipped: -/// -/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe -/// data is defined as data that contains newlines, though this definition -/// may change later to detect other scenarios. -/// -/// - For OSC 52 reads and writes no prompt is shown to the user if -/// `confirmed` is true. +/// only be called once for each request. /// -/// If `confirmed` is false then this may return either an UnsafePaste or -/// UnauthorizedPaste error, depending on the type of clipboard request. +/// If `complete.confirmed` is false then this may return either an +/// UnsafePaste or UnauthorizedPaste error, depending on the type of +/// clipboard request. The request state remains alive in that case so +/// the apprt can run its confirmation flow. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, - data: [:0]const u8, - confirmed: bool, + complete: CompleteClipboard, ) !void { switch (req) { - .paste => try self.completeClipboardPaste(data, confirmed), + .paste => try self.completeClipboardPaste( + clipboardTextContent(complete.contents) orelse "", + complete.confirmed, + ), + + .list => |clipboard| if (!try self.completeClipboardPasteEvent( + clipboard, + complete.available, + )) { + log.debug("mode 5522 paste event was not written", .{}); + }, .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - data, + clipboardTextContent(complete.contents) orelse "", clipboard, - confirmed, + complete.confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ - .mime = "text/plain", - .data = data, - }}, !confirmed), + .osc_52_write => |clipboard| { + // The write API wants sentinel-terminated data; the write + // text round-tripped through the apprt confirmation flow as + // a plain representation. + const data = try self.alloc.dupeZ( + u8, + clipboardTextContent(complete.contents) orelse "", + ); + defer self.alloc.free(data); + try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !complete.confirmed); + }, + + .kitty_read => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt, and a request with no + // data types is exempt from prompting entirely; see + // readPromptExempt. + if (self.config.clipboard_read == .ask and + !complete.confirmed and + !kitty.granted and + !terminal.kitty.clipboard.readPromptExempt(kitty.mimes.len)) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_read = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + try self.completeKittyClipboardRead( + kitty, + complete.contents, + complete.available, + ); + }, + + .kitty_write => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt. + if (self.config.clipboard_write == .ask and + !complete.confirmed and + !kitty.granted) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_write = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + // Apply the committed representations carried by the + // request itself; any contents echoed back by the apprt + // are only what its confirmation prompt displayed. An + // empty commit clears the clipboard, which the apprt + // write API expresses as a single empty text entry. + self.rt_surface.setClipboard( + kitty.location, + if (kitty.contents.len > 0) kitty.contents else &.{.{ + .mime = "text/plain", + .data = "", + }}, + false, + ) catch |err| { + log.err("error setting clipboard err={}", .{err}); + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EIO, + ); + return; + }; + + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .DONE, + ); + }, + } +} + +/// The first text-like representation of the contents, if any. +fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u8 { + for (contents) |content| { + if (terminal.clipboard.isTextMime(content.mime)) return content.data; + } + return null; +} + +/// Deny an in-flight clipboard request. This consumes the request: for +/// request types whose protocol expects an answer, the denial reply is +/// written to the pty. +pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void { + switch (req) { + // A denied paste simply doesn't happen. + .paste, .list => {}, + + // OSC 52 has no error responses, but the client is waiting on + // a reply, so a denied read is answered with empty contents. + .osc_52_read => |clipboard| self.completeClipboardReadOSC52( + "", + clipboard, + true, + ) catch |err| { + log.warn("error replying to OSC 52 clipboard read err={}", .{err}); + }, + + // A denied write simply doesn't happen. + .osc_52_write => {}, + + // The Kitty clipboard protocol reports denial explicitly. + .kitty_read => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .read, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard read err={}", .{err}); + }; + }, + + .kitty_write => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard write err={}", .{err}); + }; + }, } } /// This starts a clipboard request, with some basic validation. For example, /// an OSC 52 request is not actually requested if OSC 52 is disabled. /// -/// Returns true if the request was started, false if it was not (e.g., clipboard -/// doesn't contain text for paste requests). This allows performable keybinds -/// to pass through when the action cannot be performed. +/// The result reports whether the request was started; requests that +/// weren't started never complete. Callers own reacting to that, e.g. +/// performable paste keybinds pass through and Kitty clipboard reads +/// answer the program. fn startClipboardRequest( self: *Surface, loc: apprt.Clipboard, req: apprt.ClipboardRequest, -) !bool { - switch (req) { - .paste => {}, // always allowed +) !apprt.ClipboardReadResult { + const effective_req: apprt.ClipboardRequest = switch (req) { + .paste => |clipboard| effective: { + // Snapshot the mode before asking the apprt for clipboard data. + // Event pastes request only a MIME listing, while ordinary + // pastes request the text representation as before. + self.renderer_state.mutex.lockUncancelable(global.io()); + const event = self.io.terminal.modes.get(.kitty_paste_events); + self.renderer_state.mutex.unlock(global.io()); + + break :effective if (event) + .{ .list = clipboard } + else + req; + }, + else => req, + }; + + switch (effective_req) { + .paste, .list => {}, // always allowed .osc_52_read => if (self.config.clipboard_read == .deny) { log.info( "application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}, ); - return false; + return .unsupported; }, - // No clipboard write code paths travel through this function + // The clipboard access policies were already applied by + // kittyClipboardRead and kittyClipboardWrite, which own + // replying on denial. + .kitty_read, .kitty_write => {}, + + // OSC 52 writes don't travel through this function; they go + // straight to the apprt setClipboard API. .osc_52_write => unreachable, } - return try self.rt_surface.clipboardRequest(loc, req); + return try self.rt_surface.clipboardRequest(loc, effective_req); } fn completeClipboardPaste( @@ -5849,8 +6119,8 @@ fn completeClipboardPaste( if (data.len == 0) return; const encode_opts: input.paste.Options = encode_opts: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const opts: input.paste.Options = .fromTerminal(&self.io.terminal); // If we have paste protection enabled, we detect unsafe pastes and return @@ -5920,6 +6190,63 @@ fn completeClipboardPaste( }; } +/// Send a Kitty clipboard-protocol paste event when mode 5522 is enabled. +/// The event only lists the available MIME types; it does not read any of +/// their data. The shared terminal paste implementation generates and records +/// the one-time password used by the program's follow-up OSC 5522 read. +fn completeClipboardPasteEvent( + self: *Surface, + clipboard: apprt.Clipboard, + available: []const []const u8, +) !bool { + if (self.readonly) return false; + + const kitty_clipboard = terminal.kitty.clipboard; + const location: terminal.clipboard.Location = switch (clipboard) { + .standard => .standard, + .selection => .selection, + .primary => .primary, + }; + + // The protocol implementation caps listings at this size too. Cap here + // so the temporary Content array stays on the stack. + var contents_buf: [kitty_clipboard.max_listing_mimes]terminal.clipboard.Content = undefined; + const contents_len = @min(available.len, contents_buf.len); + for (available[0..contents_len], contents_buf[0..contents_len]) |mime, *content| { + content.* = .{ .mime = mime, .data = "" }; + } + + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + const pasted = try terminal.paste.paste(.{ + .terminal = &self.io.terminal, + .alloc = self.alloc, + .writer = &aw.writer, + .kitty_clipboard = .{ + .grants = &self.io.terminal_stream.handler.kitty_clipboard_grants, + .io = global.io(), + }, + }, .{ + .source = .{ .clipboard = location }, + .contents = .{ .memory = contents_buf[0..contents_len] }, + // A paste event discloses no clipboard data, so unsafe-text + // confirmation does not apply. If mode 5522 is reset, the empty + // stand-in representations cause the shared helper to write nothing. + .allow_unsafe = true, + }); + if (!pasted) return false; + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .locked); + return true; +} + fn completeClipboardReadOSC52( self: *Surface, data: []const u8, @@ -5940,8 +6267,8 @@ fn completeClipboardReadOSC52( // This must hold the base64 encoded data PLUS the OSC code surrounding it. const enc = std.base64.standard.Encoder; const size = enc.calcSize(data.len); - var buf = try self.alloc.alloc(u8, size + 9); // const for OSC - defer self.alloc.free(buf); + const buf = try self.alloc.alloc(u8, size + 9); // const for OSC + errdefer self.alloc.free(buf); const kind: u8 = switch (clipboard_type) { .standard => 'c', @@ -5959,10 +6286,177 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.queueIo(try termio.Message.writeReq( - self.alloc, - buf, - ), .unlocked); + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = buf, + } }, .unlocked); +} + +/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded +/// by the IO thread. This takes ownership of the request state. +fn kittyClipboardRead( + self: *Surface, + req: *apprt.ClipboardRequest.KittyRead, +) !void { + // A read denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + if (self.config.clipboard_read == .deny) { + defer req.destroy(); + log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_read = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.read, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The clipboard has nothing we can serve, which is a + // successful read that serves no representations. This never + // prompts even under an ask policy since there are no contents + // to disclose. + .unavailable => { + defer req.destroy(); + try self.completeKittyClipboardRead(req, &.{}, &.{}); + }, + + // The apprt can't serve this clipboard at all, e.g. an + // unsupported primary selection. + .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Handle a committed Kitty clipboard protocol (OSC 5522) write +/// transaction forwarded by the IO thread. This takes ownership of the +/// request state. +fn kittyClipboardWrite( + self: *Surface, + req: *apprt.ClipboardRequest.KittyWrite, +) !void { + // A write denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + // The IO thread already fails transactions that begin under a + // deny policy, but the policy may have changed mid-transaction. + if (self.config.clipboard_write == .deny) { + defer req.destroy(); + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_write = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.write, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The apprt can't write this clipboard at all, e.g. an + // unsupported primary selection. Writes carry their own + // contents so there is no meaningful unavailable state; treat + // it the same. + .unavailable, .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Reply to a Kitty clipboard request with a single status packet. +fn kittyClipboardStatus( + self: *Surface, + op: terminal.kitty.clipboard.Operation, + id: []const u8, + terminator: terminal.osc.Terminator, + status: terminal.kitty.clipboard.Status, +) error{ OutOfMemory, WriteFailed }!void { + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = op, + .status = status, + .id = id, + .terminator = terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); +} + +/// Complete a Kitty clipboard protocol read with the clipboard +/// contents. +fn completeKittyClipboardRead( + self: *Surface, + req: *const apprt.ClipboardRequest.KittyRead, + contents: []const terminal.clipboard.Content, + available: []const []const u8, +) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Serve the requested representations in request order under their + // requested names. Text-like MIME aliases all match the canonical + // text representation, since that is the only name the apprt + // serves text under. Requested types without a representation are + // simply never served, which is how the protocol communicates an + // unavailable representation. + var contents_buf: [kitty_clipboard.max_read_mimes]terminal.clipboard.Content = undefined; + var contents_len: usize = 0; + for (req.mimes) |mime| { + const data: []const u8 = data: { + for (contents) |content| { + if (std.mem.eql(u8, content.mime, mime)) break :data content.data; + if (terminal.clipboard.isTextMime(mime) and + terminal.clipboard.isTextMime(content.mime)) + { + break :data content.data; + } + } + + continue; + }; + + contents_buf[contents_len] = .{ .mime = mime, .data = data }; + contents_len += 1; + } + + // Encode the full success sequence: the OK packet, the targets + // listing if it was requested, DATA chunks for each served + // representation, and the final DONE packet. + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (kitty_clipboard.ReadSuccess{ + .primary = req.location == .primary, + .id = req.id, + .list = req.list, + .available = available, + .contents = contents_buf[0..contents_len], + .terminator = req.terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); } fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { @@ -5970,12 +6464,12 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const // how fast identical notifications can be sent sequentially. const hash_algorithm = std.hash.Wyhash; - const now = try std.time.Instant.now(); + const now: std.Io.Timestamp = .now(global.io(), .awake); // Set a limit of one desktop notification per second so that the OS // doesn't kill us when we run out of resources. if (self.app.last_notification_time) |last| { - if (now.since(last) < 1 * std.time.ns_per_s) { + if (last.durationTo(now).toSeconds() < 1) { log.warn("rate limiting desktop notifications", .{}); return; } @@ -5992,7 +6486,7 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const // notifications with identical content. if (self.app.last_notification_time) |last| { if (self.app.last_notification_digest == new_digest) { - if (now.since(last) < 5 * std.time.ns_per_s) { + if (last.durationTo(now).toSeconds() < 5) { log.warn("suppressing identical desktop notification", .{}); return; } @@ -6034,3 +6528,18 @@ fn presentSurface(self: *Surface) !void { pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { return self.io.getProcessInfo(info); } + +test "queueIo frees allocated writes in readonly mode" { + const testing = std.testing; + + const surface = try testing.allocator.create(Surface); + defer testing.allocator.destroy(surface); + surface.readonly = true; + + // queueIo must free allocated writes in read-only mode. + const data = try testing.allocator.dupe(u8, "\x1b]lGhostty\x1b\\"); + surface.queueIo(.{ .write_alloc = .{ + .alloc = testing.allocator, + .data = data, + } }, .unlocked); +} |
