From ffaf020576e76e67def887a02bb297744c774863 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Jul 2024 14:29:47 -0700 Subject: [PATCH] termio: move stream handler to dedicated file, remove dep on EventData --- src/termio.zig | 3 + src/termio/Termio.zig | 1305 +-------------------------------- src/termio/stream_handler.zig | 1273 ++++++++++++++++++++++++++++++++ 3 files changed, 1311 insertions(+), 1270 deletions(-) create mode 100644 src/termio/stream_handler.zig diff --git a/src/termio.zig b/src/termio.zig index d151e46dd..2d663addd 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -2,6 +2,8 @@ //! for taking the config, spinning up a child process, and handling IO //! with the terminal. +const stream_handler = @import("termio/stream_handler.zig"); + pub usingnamespace @import("termio/message.zig"); pub const reader = @import("termio/reader.zig"); pub const Options = @import("termio/Options.zig"); @@ -9,6 +11,7 @@ pub const Termio = @import("termio/Termio.zig"); pub const Thread = @import("termio/Thread.zig"); pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = Thread.Mailbox; +pub const StreamHandler = stream_handler.StreamHandler; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 432ce158b..0481a3126 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -27,6 +27,8 @@ const windows = internal_os.windows; const configpkg = @import("../config.zig"); const shell_integration = @import("shell_integration.zig"); +const StreamHandler = @import("stream_handler.zig").StreamHandler; + const log = std.log.scoped(.io_exec); const c = @cImport({ @@ -249,6 +251,36 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo var process = try xev.Process.init(pid); errdefer process.deinit(); + // Create our stream handler + const handler: StreamHandler = handler: { + const default_cursor_color = if (self.config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + break :handler .{ + .alloc = self.alloc, + .writer_mailbox = thread.mailbox, + .writer_wakeup = thread.wakeup, + .surface_mailbox = self.surface_mailbox, + .renderer_state = self.renderer_state, + .renderer_wakeup = self.renderer_wakeup, + .renderer_mailbox = self.renderer_mailbox, + .grid_size = &self.grid_size, + .terminal = &self.terminal, + .osc_color_report_format = self.config.osc_color_report_format, + .enquiry_response = self.config.enquiry_response, + .default_foreground_color = self.config.foreground.toTerminalRGB(), + .default_background_color = self.config.background.toTerminalRGB(), + .default_cursor_style = self.config.cursor_style, + .default_cursor_blink = self.config.cursor_blink, + .default_cursor_color = default_cursor_color, + .cursor_color = default_cursor_color, + .foreground_color = self.config.foreground.toTerminalRGB(), + .background_color = self.config.background.toTerminalRGB(), + }; + }; + // Setup our event data before we start ev_data_ptr.* = .{ .writer_mailbox = thread.mailbox, @@ -261,13 +293,7 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo .data_stream = stream, .loop = &thread.loop, .terminal_stream = .{ - .handler = StreamHandler.init( - self.alloc, - ev_data_ptr, - &self.grid_size, - &self.terminal, - &self.config, - ), + .handler = handler, .parser = .{ .osc_parser = .{ // Populate the OSC parser allocator (optional) because @@ -754,7 +780,7 @@ pub const ThreadData = struct { } }; -const EventData = struct { +pub const EventData = struct { // The preallocation size for the write request pool. This should be big // enough to satisfy most write requests. It must be a power of 2. const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); @@ -807,10 +833,6 @@ const EventData = struct { /// flooding with cursor resets. last_cursor_reset: i64 = 0, - /// This is set to true when we've seen a title escape sequence. We use - /// this to determine if we need to default the window title. - seen_title: bool = false, - pub fn deinit(self: *EventData, alloc: Allocator) void { // Clear our write pools. We know we aren't ever going to do // any more IO since we stop our data stream below so we can just @@ -832,7 +854,7 @@ const EventData = struct { /// This queues a render operation with the renderer thread. The render /// isn't guaranteed to happen immediately but it will happen as soon as /// practical. - inline fn queueRender(self: *EventData) !void { + pub inline fn queueRender(self: *EventData) !void { try self.renderer_wakeup.notify(); } }; @@ -1710,1260 +1732,3 @@ const ReadThread = struct { } } }; - -/// This is used as the handler for the terminal.Stream type. This is -/// stateful and is expected to live for the entire lifetime of the terminal. -/// It is NOT VALID to stop a stream handler, create a new one, and use that -/// unless all of the member fields are copied. -const StreamHandler = struct { - ev: *EventData, - alloc: Allocator, - grid_size: *renderer.GridSize, - terminal: *terminal.Terminal, - - /// The APC command handler maintains the APC state. APC is like - /// CSI or OSC, but it is a private escape sequence that is used - /// to send commands to the terminal emulator. This is used by - /// the kitty graphics protocol. - apc: terminal.apc.Handler = .{}, - - /// The DCS handler maintains DCS state. DCS is like CSI or OSC, - /// but requires more stateful parsing. This is used by functionality - /// such as XTGETTCAP. - dcs: terminal.dcs.Handler = .{}, - - /// This is set to true when a message was written to the writer - /// mailbox. This can be used by callers to determine if they need - /// to wake up the writer. - writer_messaged: bool = false, - - /// The default cursor state. This is used with CSI q. This is - /// set to true when we're currently in the default cursor state. - default_cursor: bool = true, - default_cursor_style: terminal.CursorStyle, - default_cursor_blink: ?bool, - default_cursor_color: ?terminal.color.RGB, - - /// Actual cursor color. This can be changed with OSC 12. - cursor_color: ?terminal.color.RGB, - - /// The default foreground and background color are those set by the user's - /// config file. These can be overridden by terminal applications using OSC - /// 10 and OSC 11, respectively. - default_foreground_color: terminal.color.RGB, - default_background_color: terminal.color.RGB, - - /// The actual foreground and background color. Normally this will be the - /// same as the default foreground and background color, unless changed by a - /// terminal application. - foreground_color: terminal.color.RGB, - background_color: terminal.color.RGB, - - /// The response to use for ENQ requests. The memory is owned by - /// whoever owns StreamHandler. - enquiry_response: []const u8, - - osc_color_report_format: configpkg.Config.OSCColorReportFormat, - - pub fn init( - alloc: Allocator, - ev: *EventData, - grid_size: *renderer.GridSize, - t: *terminal.Terminal, - config: *const DerivedConfig, - ) StreamHandler { - const default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - return .{ - .alloc = alloc, - .ev = ev, - .grid_size = grid_size, - .terminal = t, - .osc_color_report_format = config.osc_color_report_format, - .enquiry_response = config.enquiry_response, - .default_foreground_color = config.foreground.toTerminalRGB(), - .default_background_color = config.background.toTerminalRGB(), - .default_cursor_style = config.cursor_style, - .default_cursor_blink = config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = default_cursor_color, - .foreground_color = config.foreground.toTerminalRGB(), - .background_color = config.background.toTerminalRGB(), - }; - } - - pub fn deinit(self: *StreamHandler) void { - self.apc.deinit(); - self.dcs.deinit(); - } - - /// Change the configuration for this handler. - pub fn changeConfig(self: *StreamHandler, config: *DerivedConfig) void { - self.osc_color_report_format = config.osc_color_report_format; - self.enquiry_response = config.enquiry_response; - self.default_foreground_color = config.foreground.toTerminalRGB(); - self.default_background_color = config.background.toTerminalRGB(); - self.default_cursor_style = config.cursor_style; - self.default_cursor_blink = config.cursor_blink; - self.default_cursor_color = if (config.cursor_color) |col| - col.toTerminalRGB() - else - null; - - // If our cursor is the default, then we update it immediately. - if (self.default_cursor) self.setCursorStyle(.default) catch |err| { - log.warn("failed to set default cursor style: {}", .{err}); - }; - } - - inline fn queueRender(self: *StreamHandler) !void { - try self.ev.queueRender(); - } - - inline fn surfaceMessageWriter( - self: *StreamHandler, - msg: apprt.surface.Message, - ) void { - // See messageWriter which has similar logic and explains why - // we may have to do this. - if (self.ev.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.surface_mailbox.push(msg, .{ .forever = {} }); - } - } - - inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { - // Try to write to the mailbox with an instant timeout. This is the - // fast path because we can queue without a lock. - if (self.ev.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { - // If we enter this conditional, the mailbox is full. We wake up - // the writer thread so that it can process messages to clear up - // space. However, the writer thread may require the renderer - // lock so we need to unlock. - self.ev.writer_wakeup.notify() catch |err| { - log.warn("failed to wake up writer, data will be dropped err={}", .{err}); - return; - }; - - // Unlock the renderer state so the writer thread can acquire it. - // Then try to queue our message before continuing. This is a very - // slow path because we are having a lot of contention for data. - // But this only gets triggered in certain pathological cases. - // - // Note that writes themselves don't require a lock, but there - // are other messages in the writer mailbox (resize, focus) that - // could acquire the lock. This is why we have to release our lock - // here. - self.ev.renderer_state.mutex.unlock(); - defer self.ev.renderer_state.mutex.lock(); - _ = self.ev.writer_mailbox.push(msg, .{ .forever = {} }); - } - - // Normally, we just flag this true to wake up the writer thread - // once per batch of data. - self.writer_messaged = true; - } - - pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - self.dcs.hook(self.alloc, dcs); - } - - pub fn dcsPut(self: *StreamHandler, byte: u8) !void { - self.dcs.put(byte); - } - - pub fn dcsUnhook(self: *StreamHandler) !void { - var cmd = self.dcs.unhook() orelse return; - defer cmd.deinit(); - - // log.warn("DCS command: {}", .{cmd}); - switch (cmd) { - .xtgettcap => |*gettcap| { - const map = comptime terminfo.ghostty.xtgettcapMap(); - while (gettcap.next()) |key| { - const response = map.get(key) orelse continue; - self.messageWriter(.{ .write_stable = response }); - } - }, - .decrqss => |decrqss| { - var response: [128]u8 = undefined; - var stream = std.io.fixedBufferStream(&response); - const writer = stream.writer(); - - // Offset the stream position to just past the response prefix. - // We will write the "payload" (if any) below. If no payload is - // written then we send an invalid DECRPSS response. - const prefix_fmt = "\x1bP{d}$r"; - const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; - stream.pos = prefix_len; - - switch (decrqss) { - // Invalid or unhandled request - .none => {}, - - .sgr => { - const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); - - // printAttributes wrote into our buffer, so adjust the stream - // position - stream.pos += buf.len; - - try writer.writeByte('m'); - }, - - .decscusr => { - const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { - .block => if (blink) 1 else 2, - .underline => if (blink) 3 else 4, - .bar => if (blink) 5 else 6, - }; - try writer.print("{d} q", .{style}); - }, - - .decstbm => { - try writer.print("{d};{d}r", .{ - self.terminal.scrolling_region.top + 1, - self.terminal.scrolling_region.bottom + 1, - }); - }, - - .decslrm => { - // We only send a valid response when left and right - // margin mode (DECLRMM) is enabled. - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try writer.print("{d};{d}s", .{ - self.terminal.scrolling_region.left + 1, - self.terminal.scrolling_region.right + 1, - }); - } - }, - } - - // Our response is valid if we have a response payload - const valid = stream.pos > prefix_len; - - // Write the terminator - try writer.writeAll("\x1b\\"); - - // Write the response prefix into the buffer - _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); - const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); - self.messageWriter(msg); - }, - } - } - - pub fn apcStart(self: *StreamHandler) !void { - self.apc.start(); - } - - pub fn apcPut(self: *StreamHandler, byte: u8) !void { - self.apc.feed(self.alloc, byte); - } - - pub fn apcEnd(self: *StreamHandler) !void { - var cmd = self.apc.end() orelse return; - defer cmd.deinit(self.alloc); - - // log.warn("APC command: {}", .{cmd}); - switch (cmd) { - .kitty => |*kitty_cmd| { - if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { - var buf: [1024]u8 = undefined; - var buf_stream = std.io.fixedBufferStream(&buf); - try resp.encode(buf_stream.writer()); - const final = buf_stream.getWritten(); - if (final.len > 2) { - // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); - self.messageWriter(try termio.Message.writeReq(self.alloc, final)); - } - } - }, - } - } - - pub fn print(self: *StreamHandler, ch: u21) !void { - try self.terminal.print(ch); - } - - pub fn printRepeat(self: *StreamHandler, count: usize) !void { - try self.terminal.printRepeat(count); - } - - pub fn bell(self: StreamHandler) !void { - _ = self; - log.info("BELL", .{}); - } - - pub fn backspace(self: *StreamHandler) !void { - self.terminal.backspace(); - } - - pub fn horizontalTab(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { - for (0..count) |_| { - const x = self.terminal.screen.cursor.x; - try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; - } - } - - pub fn linefeed(self: *StreamHandler) !void { - // Small optimization: call index instead of linefeed because they're - // identical and this avoids one layer of function call overhead. - try self.terminal.index(); - } - - pub fn carriageReturn(self: *StreamHandler) !void { - self.terminal.carriageReturn(); - } - - pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorLeft(amount); - } - - pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorRight(amount); - } - - pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorDown(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorUp(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub fn setCursorCol(self: *StreamHandler, col: u16) !void { - self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); - } - - pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| offset, - ); - } - - pub fn setCursorRow(self: *StreamHandler, row: u16) !void { - self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); - } - - pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| offset, - self.terminal.screen.cursor.x + 1, - ); - } - - pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { - self.terminal.setCursorPos(row, col); - } - - pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { - if (mode == .complete) { - // Whenever we erase the full display, scroll to bottom. - try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - self.terminal.eraseDisplay(mode, protected); - } - - pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { - self.terminal.eraseLine(mode, protected); - } - - pub fn deleteChars(self: *StreamHandler, count: usize) !void { - self.terminal.deleteChars(count); - } - - pub fn eraseChars(self: *StreamHandler, count: usize) !void { - self.terminal.eraseChars(count); - } - - pub fn insertLines(self: *StreamHandler, count: usize) !void { - self.terminal.insertLines(count); - } - - pub fn insertBlanks(self: *StreamHandler, count: usize) !void { - self.terminal.insertBlanks(count); - } - - pub fn deleteLines(self: *StreamHandler, count: usize) !void { - self.terminal.deleteLines(count); - } - - pub fn reverseIndex(self: *StreamHandler) !void { - self.terminal.reverseIndex(); - } - - pub fn index(self: *StreamHandler) !void { - try self.terminal.index(); - } - - pub fn nextLine(self: *StreamHandler) !void { - try self.terminal.index(); - self.terminal.carriageReturn(); - } - - pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { - self.terminal.setTopAndBottomMargin(top, bot); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try self.setLeftAndRightMargin(0, 0); - } else { - try self.saveCursor(); - } - } - - pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { - self.terminal.setLeftAndRightMargin(left, right); - } - - pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { - self.terminal.flags.modify_other_keys_2 = false; - switch (format) { - .other_keys => |v| switch (v) { - .numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - }, - else => {}, - } - } - - pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - // Get the mode value and respond. - const code: u8 = code: { - const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; - if (self.terminal.modes.get(mode)) break :code 1; - break :code 2; - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B[{s}{};{}$y", - .{ - if (ansi) "" else "?", - mode_raw, - code, - }, - ); - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { - // log.debug("save mode={}", .{mode}); - self.terminal.modes.save(mode); - } - - pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { - // For restore mode we have to restore but if we set it, we - // always have to call setMode because setting some modes have - // side effects and we want to make sure we process those. - const v = self.terminal.modes.restore(mode); - // log.debug("restore mode={} v={}", .{ mode, v }); - try self.setMode(mode, v); - } - - pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { - // Note: this function doesn't need to grab the render state or - // terminal locks because it is only called from process() which - // grabs the lock. - - // If we are setting cursor blinking, we ignore it if we have - // a default cursor blink setting set. This is a really weird - // behavior so this comment will go deep into trying to explain it. - // - // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) - // and DEC mode 12. DECSCUSR is the modern approach and has a - // way to revert to the "default" (as defined by the terminal) - // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls - // blinking and is either on or off and has no way to set a - // default. DEC mode 12 is also the more antiquated approach. - // - // The problem is that if the user specifies a desired default - // cursor blink with `cursor-style-blink`, the moment a running - // program uses DEC mode 12, the cursor blink can never be reset - // to the default without an explicit DECSCUSR. But if a program - // is using mode 12, it is by definition not using DECSCUSR. - // This makes for somewhat annoying interactions where a poorly - // (or legacy) behaved program will stop blinking, and it simply - // never restarts. - // - // To get around this, we have a special case where if the user - // specifies some explicit default cursor blink desire, we ignore - // DEC mode 12. We allow DECSCUSR to still set the cursor blink - // because programs using DECSCUSR usually are well behaved and - // reset the cursor blink to the default when they exit. - // - // To be extra safe, users can also add a manual `CSI 0 q` to - // their shell config when they render prompts to ensure the - // cursor is exactly as they request. - if (mode == .cursor_blinking and - self.default_cursor_blink != null) - { - return; - } - - // We first always set the raw mode on our mode state. - self.terminal.modes.set(mode, enabled); - - // And then some modes require additional processing. - switch (mode) { - // Just noting here that autorepeat has no effect on - // the terminal. xterm ignores this mode and so do we. - // We know about just so that we don't log that it is - // an unknown mode. - .autorepeat => {}, - - // Schedule a render since we changed colors - .reverse_colors => { - self.terminal.flags.dirty.reverse_colors = true; - try self.queueRender(); - }, - - // Origin resets cursor pos. This is called whether or not - // we're enabling or disabling origin mode and whether or - // not the value changed. - .origin => self.terminal.setCursorPos(1, 1), - - .enable_left_and_right_margin => if (!enabled) { - // When we disable left/right margin mode we need to - // reset the left/right margins. - self.terminal.scrolling_region.left = 0; - self.terminal.scrolling_region.right = self.terminal.cols - 1; - }, - - .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens - try self.queueRender(); - }, - - // Force resize back to the window size - .enable_mode_3 => self.terminal.resize( - self.alloc, - self.grid_size.columns, - self.grid_size.rows, - ) catch |err| { - log.err("error updating terminal size: {}", .{err}); - }, - - .@"132_column" => try self.terminal.deccolm( - self.alloc, - if (enabled) .@"132_cols" else .@"80_cols", - ), - - // We need to start a timer to prevent the emulator being hung - // forever. - .synchronized_output => { - if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); - try self.queueRender(); - }, - - .linefeed => { - self.messageWriter(.{ .linefeed_mode = enabled }); - }, - - .mouse_event_x10 => { - if (enabled) { - self.terminal.flags.mouse_event = .x10; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_normal => { - if (enabled) { - self.terminal.flags.mouse_event = .normal; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_button => { - if (enabled) { - self.terminal.flags.mouse_event = .button; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - .mouse_event_any => { - if (enabled) { - self.terminal.flags.mouse_event = .any; - try self.setMouseShape(.default); - } else { - self.terminal.flags.mouse_event = .none; - try self.setMouseShape(.text); - } - }, - - .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, - .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, - .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, - .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, - - else => {}, - } - } - - pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { - self.terminal.flags.mouse_shift_capture = if (v) .true else .false; - } - - pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { - switch (attr) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - - else => self.terminal.setAttribute(attr) catch |err| - log.warn("error setting attribute {}: {}", .{ attr, err }), - } - } - - pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { - try self.terminal.screen.startHyperlink(uri, id); - } - - pub fn endHyperlink(self: *StreamHandler) !void { - self.terminal.screen.endHyperlink(); - } - - pub fn deviceAttributes( - self: *StreamHandler, - req: terminal.DeviceAttributeReq, - params: []const u16, - ) !void { - _ = params; - - // For the below, we quack as a VT220. We don't quack as - // a 420 because we don't support DCS sequences. - switch (req) { - .primary => self.messageWriter(.{ - .write_stable = "\x1B[?62;22c", - }), - - .secondary => self.messageWriter(.{ - .write_stable = "\x1B[>1;10;0c", - }), - - else => log.warn("unimplemented device attributes req: {}", .{req}), - } - } - - pub fn deviceStatusReport( - self: *StreamHandler, - req: terminal.device_status.Request, - ) !void { - switch (req) { - .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), - - .cursor_position => { - const pos: struct { - x: usize, - y: usize, - } = if (self.terminal.modes.get(.origin)) .{ - .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, - .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, - } else .{ - .x = self.terminal.screen.cursor.x, - .y = self.terminal.screen.cursor.y, - }; - - // Response always is at least 4 chars, so this leaves the - // remainder for the row/column as base-10 numbers. This - // will support a very large terminal. - var msg: termio.Message = .{ .write_small = .{} }; - const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ - pos.y + 1, - pos.x + 1, - }); - msg.write_small.len = @intCast(resp.len); - - self.messageWriter(msg); - }, - - .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), - } - } - - pub fn setCursorStyle( - self: *StreamHandler, - style: terminal.CursorStyleReq, - ) !void { - // Assume we're setting to a non-default. - self.default_cursor = false; - - switch (style) { - .default => { - self.default_cursor = true; - self.terminal.screen.cursor.cursor_style = self.default_cursor_style; - self.terminal.modes.set( - .cursor_blinking, - self.default_cursor_blink orelse true, - ); - }, - - .blinking_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_block => { - self.terminal.screen.cursor.cursor_style = .block; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_underline => { - self.terminal.screen.cursor.cursor_style = .underline; - self.terminal.modes.set(.cursor_blinking, false); - }, - - .blinking_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, true); - }, - - .steady_bar => { - self.terminal.screen.cursor.cursor_style = .bar; - self.terminal.modes.set(.cursor_blinking, false); - }, - - else => log.warn("unimplemented cursor style: {}", .{style}), - } - } - - pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { - self.terminal.setProtectedMode(mode); - } - - pub fn decaln(self: *StreamHandler) !void { - try self.terminal.decaln(); - } - - pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { - self.terminal.tabClear(cmd); - } - - pub fn tabSet(self: *StreamHandler) !void { - self.terminal.tabSet(); - } - - pub fn tabReset(self: *StreamHandler) !void { - self.terminal.tabReset(); - } - - pub fn saveCursor(self: *StreamHandler) !void { - self.terminal.saveCursor(); - } - - pub fn restoreCursor(self: *StreamHandler) !void { - try self.terminal.restoreCursor(); - } - - pub fn enquiry(self: *StreamHandler) !void { - log.debug("sending enquiry response={s}", .{self.enquiry_response}); - self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); - } - - pub fn scrollDown(self: *StreamHandler, count: usize) !void { - self.terminal.scrollDown(count); - } - - pub fn scrollUp(self: *StreamHandler, count: usize) !void { - self.terminal.scrollUp(count); - } - - pub fn setActiveStatusDisplay( - self: *StreamHandler, - req: terminal.StatusDisplay, - ) !void { - self.terminal.status_display = req; - } - - pub fn configureCharset( - self: *StreamHandler, - slot: terminal.CharsetSlot, - set: terminal.Charset, - ) !void { - self.terminal.configureCharset(slot, set); - } - - pub fn invokeCharset( - self: *StreamHandler, - active: terminal.CharsetActiveSlot, - slot: terminal.CharsetSlot, - single: bool, - ) !void { - self.terminal.invokeCharset(active, slot, single); - } - - pub fn fullReset( - self: *StreamHandler, - ) !void { - self.terminal.fullReset(); - try self.setMouseShape(.text); - } - - pub fn queryKittyKeyboard(self: *StreamHandler) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("querying kitty keyboard mode", .{}); - var data: termio.Message.WriteReq.Small.Array = undefined; - const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ - self.terminal.screen.kitty_keyboard.current().int(), - }); - - self.messageWriter(.{ - .write_small = .{ - .data = data, - .len = @intCast(resp.len), - }, - }); - } - - pub fn pushKittyKeyboard( - self: *StreamHandler, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("pushing kitty keyboard mode: {}", .{flags}); - self.terminal.screen.kitty_keyboard.push(flags); - } - - pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("popping kitty keyboard mode n={}", .{n}); - self.terminal.screen.kitty_keyboard.pop(@intCast(n)); - } - - pub fn setKittyKeyboard( - self: *StreamHandler, - mode: terminal.kitty.KeySetMode, - flags: terminal.kitty.KeyFlags, - ) !void { - if (comptime disable_kitty_keyboard_protocol) return; - - log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); - self.terminal.screen.kitty_keyboard.set(mode, flags); - } - - pub fn reportXtversion( - self: *StreamHandler, - ) !void { - log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); - var buf: [288]u8 = undefined; - const resp = try std.fmt.bufPrint( - &buf, - "\x1BP>|{s} {s}\x1B\\", - .{ - "ghostty", - build_config.version_string, - }, - ); - const msg = try termio.Message.writeReq(self.alloc, resp); - self.messageWriter(msg); - } - - //------------------------------------------------------------------------- - // OSC - - pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { - var buf: [256]u8 = undefined; - if (title.len >= buf.len) { - log.warn("change title requested larger than our buffer size, ignoring", .{}); - return; - } - - @memcpy(buf[0..title.len], title); - buf[title.len] = 0; - - // Mark that we've seen a title - self.ev.seen_title = true; - self.surfaceMessageWriter(.{ .set_title = buf }); - } - - pub fn setMouseShape( - self: *StreamHandler, - shape: terminal.MouseShape, - ) !void { - // Avoid changing the shape it it is already set to avoid excess - // cross-thread messaging. - if (self.terminal.mouse_shape == shape) return; - - self.terminal.mouse_shape = shape; - self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); - } - - pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { - // Note: we ignore the "kind" field and always use the standard clipboard. - // iTerm also appears to do this but other terminals seem to only allow - // certain. Let's investigate more. - - const clipboard_type: apprt.Clipboard = switch (kind) { - 'c' => .standard, - 's' => .selection, - 'p' => .primary, - else => .standard, - }; - - // Get clipboard contents - if (data.len == 1 and data[0] == '?') { - self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); - return; - } - - // Write clipboard contents - self.surfaceMessageWriter(.{ - .clipboard_write = .{ - .req = try apprt.surface.Message.WriteReq.init( - self.alloc, - data, - ), - .clipboard_type = clipboard_type, - }, - }); - } - - pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt); - self.terminal.flags.shell_redraws_prompt = redraw; - } - - pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt_continuation); - } - - pub fn promptEnd(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.input); - } - - pub fn endOfInput(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.command); - } - - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { - if (builtin.os.tag == .windows) { - log.warn("reportPwd unimplemented on windows", .{}); - return; - } - - const uri = std.Uri.parse(url) catch |e| { - log.warn("invalid url in OSC 7: {}", .{e}); - return; - }; - - if (!std.mem.eql(u8, "file", uri.scheme) and - !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) - { - log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); - return; - } - - // OSC 7 is a little sketchy because anyone can send any value from - // any host (such an SSH session). The best practice terminals follow - // is to valid the hostname to be local. - const host_valid = host_valid: { - const host_component = uri.host orelse break :host_valid false; - - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { - break :host_valid true; - } - - // Otherwise, it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const hostname = posix.gethostname(&buf) catch |err| { - log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); - break :host_valid false; - }; - - break :host_valid std.mem.eql(u8, host, hostname); - }; - if (!host_valid) { - log.warn("OSC 7 host must be local", .{}); - return; - } - - // We need to unescape the path. We first try to unescape onto - // the stack and fall back to heap allocation if we have to. - var pathBuf: [1024]u8 = undefined; - const path, const heap = path: { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const path = switch (uri.path) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // If the path doesn't have any escapes, we can use it directly. - if (std.mem.indexOfScalar(u8, path, '%') == null) - break :path .{ path, false }; - - // First try to stack-allocate - var fba = std.heap.FixedBufferAllocator.init(&pathBuf); - if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| - break :path .{ v, false } - else |_| {} - - // Fall back to heap - if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| - break :path .{ v, true } - else |_| {} - - // Fall back to using it directly... - log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); - break :path .{ path, false }; - }; - defer if (heap) self.alloc.free(path); - - log.debug("terminal pwd: {s}", .{path}); - try self.terminal.setPwd(path); - - // If we haven't seen a title, use our pwd as the title. - if (!self.ev.seen_title) { - try self.changeWindowTitle(path); - self.ev.seen_title = false; - } - } - - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - terminator: terminal.osc.Terminator, - ) !void { - if (self.osc_color_report_format == .none) return; - - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color, - .background => self.background_color, - .cursor => self.cursor_color orelse self.foreground_color, - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - }, - - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); - - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - } - } - } - }, - .foreground => { - self.foreground_color = self.default_foreground_color; - _ = self.ev.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = self.default_background_color; - _ = self.ev.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = self.default_cursor_color; - _ = self.ev.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); - }, - } - } - - pub fn showDesktopNotification( - self: *StreamHandler, - title: []const u8, - body: []const u8, - ) !void { - var message = apprt.surface.Message{ .desktop_notification = undefined }; - - const title_len = @min(title.len, message.desktop_notification.title.len); - @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); - message.desktop_notification.title[title_len] = 0; - - const body_len = @min(body.len, message.desktop_notification.body.len); - @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); - message.desktop_notification.body[body_len] = 0; - - self.surfaceMessageWriter(message); - } -}; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig new file mode 100644 index 000000000..f88aeca97 --- /dev/null +++ b/src/termio/stream_handler.zig @@ -0,0 +1,1273 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const xev = @import("xev"); +const apprt = @import("../apprt.zig"); +const build_config = @import("../build_config.zig"); +const configpkg = @import("../config.zig"); +const renderer = @import("../renderer.zig"); +const termio = @import("../termio.zig"); +const terminal = @import("../terminal/main.zig"); +const terminfo = @import("../terminfo/main.zig"); +const posix = std.posix; + +const log = std.log.scoped(.io_handler); + +/// True if we should disable the kitty keyboard protocol. We have to +/// disable this on GLFW because GLFW input events don't support the +/// correct granularity of events. +const disable_kitty_keyboard_protocol = apprt.runtime == apprt.glfw; + +/// This is used as the handler for the terminal.Stream type. This is +/// stateful and is expected to live for the entire lifetime of the terminal. +/// It is NOT VALID to stop a stream handler, create a new one, and use that +/// unless all of the member fields are copied. +pub const StreamHandler = struct { + alloc: Allocator, + grid_size: *renderer.GridSize, + terminal: *terminal.Terminal, + + /// Mailbox for data to the writer thread. + writer_mailbox: *termio.Mailbox, + writer_wakeup: xev.Async, + + /// Mailbox for the surface. + surface_mailbox: apprt.surface.Mailbox, + + /// The shared render state + renderer_state: *renderer.State, + + /// The mailbox for notifying the renderer of things. + renderer_mailbox: *renderer.Thread.Mailbox, + + /// A handle to wake up the renderer. This hints to the renderer that that + /// a repaint should happen. + renderer_wakeup: xev.Async, + + /// The default cursor state. This is used with CSI q. This is + /// set to true when we're currently in the default cursor state. + default_cursor: bool = true, + default_cursor_style: terminal.CursorStyle, + default_cursor_blink: ?bool, + default_cursor_color: ?terminal.color.RGB, + + /// Actual cursor color. This can be changed with OSC 12. + cursor_color: ?terminal.color.RGB, + + /// The default foreground and background color are those set by the user's + /// config file. These can be overridden by terminal applications using OSC + /// 10 and OSC 11, respectively. + default_foreground_color: terminal.color.RGB, + default_background_color: terminal.color.RGB, + + /// The actual foreground and background color. Normally this will be the + /// same as the default foreground and background color, unless changed by a + /// terminal application. + foreground_color: terminal.color.RGB, + background_color: terminal.color.RGB, + + /// The response to use for ENQ requests. The memory is owned by + /// whoever owns StreamHandler. + enquiry_response: []const u8, + + /// The color reporting format for OSC requests. + osc_color_report_format: configpkg.Config.OSCColorReportFormat, + + //--------------------------------------------------------------- + // Internal state + + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + apc: terminal.apc.Handler = .{}, + + /// The DCS handler maintains DCS state. DCS is like CSI or OSC, + /// but requires more stateful parsing. This is used by functionality + /// such as XTGETTCAP. + dcs: terminal.dcs.Handler = .{}, + + /// This is set to true when a message was written to the writer + /// mailbox. This can be used by callers to determine if they need + /// to wake up the writer. + writer_messaged: bool = false, + + /// This is set to true when we've seen a title escape sequence. We use + /// this to determine if we need to default the window title. + seen_title: bool = false, + + pub fn deinit(self: *StreamHandler) void { + self.apc.deinit(); + self.dcs.deinit(); + } + + /// This queues a render operation with the renderer thread. The render + /// isn't guaranteed to happen immediately but it will happen as soon as + /// practical. + inline fn queueRender(self: *StreamHandler) !void { + try self.renderer_wakeup.notify(); + } + + /// Change the configuration for this handler. + pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { + self.osc_color_report_format = config.osc_color_report_format; + self.enquiry_response = config.enquiry_response; + self.default_foreground_color = config.foreground.toTerminalRGB(); + self.default_background_color = config.background.toTerminalRGB(); + self.default_cursor_style = config.cursor_style; + self.default_cursor_blink = config.cursor_blink; + self.default_cursor_color = if (config.cursor_color) |col| + col.toTerminalRGB() + else + null; + + // If our cursor is the default, then we update it immediately. + if (self.default_cursor) self.setCursorStyle(.default) catch |err| { + log.warn("failed to set default cursor style: {}", .{err}); + }; + } + + inline fn surfaceMessageWriter( + self: *StreamHandler, + msg: apprt.surface.Message, + ) void { + // See messageWriter which has similar logic and explains why + // we may have to do this. + if (self.surface_mailbox.push(msg, .{ .instant = {} }) == 0) { + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + _ = self.surface_mailbox.push(msg, .{ .forever = {} }); + } + } + + inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { + // Try to write to the mailbox with an instant timeout. This is the + // fast path because we can queue without a lock. + if (self.writer_mailbox.push(msg, .{ .instant = {} }) == 0) { + // If we enter this conditional, the mailbox is full. We wake up + // the writer thread so that it can process messages to clear up + // space. However, the writer thread may require the renderer + // lock so we need to unlock. + self.writer_wakeup.notify() catch |err| { + log.warn("failed to wake up writer, data will be dropped err={}", .{err}); + return; + }; + + // Unlock the renderer state so the writer thread can acquire it. + // Then try to queue our message before continuing. This is a very + // slow path because we are having a lot of contention for data. + // But this only gets triggered in certain pathological cases. + // + // Note that writes themselves don't require a lock, but there + // are other messages in the writer mailbox (resize, focus) that + // could acquire the lock. This is why we have to release our lock + // here. + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + _ = self.writer_mailbox.push(msg, .{ .forever = {} }); + } + + // Normally, we just flag this true to wake up the writer thread + // once per batch of data. + self.writer_messaged = true; + } + + pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { + self.dcs.hook(self.alloc, dcs); + } + + pub fn dcsPut(self: *StreamHandler, byte: u8) !void { + self.dcs.put(byte); + } + + pub fn dcsUnhook(self: *StreamHandler) !void { + var cmd = self.dcs.unhook() orelse return; + defer cmd.deinit(); + + // log.warn("DCS command: {}", .{cmd}); + switch (cmd) { + .xtgettcap => |*gettcap| { + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + const response = map.get(key) orelse continue; + self.messageWriter(.{ .write_stable = response }); + } + }, + .decrqss => |decrqss| { + var response: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(&response); + const writer = stream.writer(); + + // Offset the stream position to just past the response prefix. + // We will write the "payload" (if any) below. If no payload is + // written then we send an invalid DECRPSS response. + const prefix_fmt = "\x1bP{d}$r"; + const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len; + stream.pos = prefix_len; + + switch (decrqss) { + // Invalid or unhandled request + .none => {}, + + .sgr => { + const buf = try self.terminal.printAttributes(stream.buffer[stream.pos..]); + + // printAttributes wrote into our buffer, so adjust the stream + // position + stream.pos += buf.len; + + try writer.writeByte('m'); + }, + + .decscusr => { + const blink = self.terminal.modes.get(.cursor_blinking); + const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { + .block => if (blink) 1 else 2, + .underline => if (blink) 3 else 4, + .bar => if (blink) 5 else 6, + }; + try writer.print("{d} q", .{style}); + }, + + .decstbm => { + try writer.print("{d};{d}r", .{ + self.terminal.scrolling_region.top + 1, + self.terminal.scrolling_region.bottom + 1, + }); + }, + + .decslrm => { + // We only send a valid response when left and right + // margin mode (DECLRMM) is enabled. + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try writer.print("{d};{d}s", .{ + self.terminal.scrolling_region.left + 1, + self.terminal.scrolling_region.right + 1, + }); + } + }, + } + + // Our response is valid if we have a response payload + const valid = stream.pos > prefix_len; + + // Write the terminator + try writer.writeAll("\x1b\\"); + + // Write the response prefix into the buffer + _ = try std.fmt.bufPrint(response[0..prefix_len], prefix_fmt, .{@intFromBool(valid)}); + const msg = try termio.Message.writeReq(self.alloc, response[0..stream.pos]); + self.messageWriter(msg); + }, + } + } + + pub fn apcStart(self: *StreamHandler) !void { + self.apc.start(); + } + + pub fn apcPut(self: *StreamHandler, byte: u8) !void { + self.apc.feed(self.alloc, byte); + } + + pub fn apcEnd(self: *StreamHandler) !void { + var cmd = self.apc.end() orelse return; + defer cmd.deinit(self.alloc); + + // log.warn("APC command: {}", .{cmd}); + switch (cmd) { + .kitty => |*kitty_cmd| { + if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| { + var buf: [1024]u8 = undefined; + var buf_stream = std.io.fixedBufferStream(&buf); + try resp.encode(buf_stream.writer()); + const final = buf_stream.getWritten(); + if (final.len > 2) { + // log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)}); + self.messageWriter(try termio.Message.writeReq(self.alloc, final)); + } + } + }, + } + } + + pub fn print(self: *StreamHandler, ch: u21) !void { + try self.terminal.print(ch); + } + + pub fn printRepeat(self: *StreamHandler, count: usize) !void { + try self.terminal.printRepeat(count); + } + + pub fn bell(self: StreamHandler) !void { + _ = self; + log.info("BELL", .{}); + } + + pub fn backspace(self: *StreamHandler) !void { + self.terminal.backspace(); + } + + pub fn horizontalTab(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTab(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTabBack(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + pub fn linefeed(self: *StreamHandler) !void { + // Small optimization: call index instead of linefeed because they're + // identical and this avoids one layer of function call overhead. + try self.terminal.index(); + } + + pub fn carriageReturn(self: *StreamHandler) !void { + self.terminal.carriageReturn(); + } + + pub fn setCursorLeft(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorLeft(amount); + } + + pub fn setCursorRight(self: *StreamHandler, amount: u16) !void { + self.terminal.cursorRight(amount); + } + + pub fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorDown(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { + self.terminal.cursorUp(amount); + if (carriage) self.terminal.carriageReturn(); + } + + pub fn setCursorCol(self: *StreamHandler, col: u16) !void { + self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); + } + + pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1, + self.terminal.screen.cursor.x + 1 +| offset, + ); + } + + pub fn setCursorRow(self: *StreamHandler, row: u16) !void { + self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); + } + + pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { + self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1 +| offset, + self.terminal.screen.cursor.x + 1, + ); + } + + pub fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { + self.terminal.setCursorPos(row, col); + } + + pub fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { + if (mode == .complete) { + // Whenever we erase the full display, scroll to bottom. + try self.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); + } + + self.terminal.eraseDisplay(mode, protected); + } + + pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { + self.terminal.eraseLine(mode, protected); + } + + pub fn deleteChars(self: *StreamHandler, count: usize) !void { + self.terminal.deleteChars(count); + } + + pub fn eraseChars(self: *StreamHandler, count: usize) !void { + self.terminal.eraseChars(count); + } + + pub fn insertLines(self: *StreamHandler, count: usize) !void { + self.terminal.insertLines(count); + } + + pub fn insertBlanks(self: *StreamHandler, count: usize) !void { + self.terminal.insertBlanks(count); + } + + pub fn deleteLines(self: *StreamHandler, count: usize) !void { + self.terminal.deleteLines(count); + } + + pub fn reverseIndex(self: *StreamHandler) !void { + self.terminal.reverseIndex(); + } + + pub fn index(self: *StreamHandler) !void { + try self.terminal.index(); + } + + pub fn nextLine(self: *StreamHandler) !void { + try self.terminal.index(); + self.terminal.carriageReturn(); + } + + pub fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { + self.terminal.setTopAndBottomMargin(top, bot); + } + + pub fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + try self.setLeftAndRightMargin(0, 0); + } else { + try self.saveCursor(); + } + } + + pub fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { + self.terminal.setLeftAndRightMargin(left, right); + } + + pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { + self.terminal.flags.modify_other_keys_2 = false; + switch (format) { + .other_keys => |v| switch (v) { + .numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + }, + else => {}, + } + } + + pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { + // Get the mode value and respond. + const code: u8 = code: { + const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; + if (self.terminal.modes.get(mode)) break :code 1; + break :code 2; + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[{s}{};{}$y", + .{ + if (ansi) "" else "?", + mode_raw, + code, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { + // log.debug("save mode={}", .{mode}); + self.terminal.modes.save(mode); + } + + pub fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + const v = self.terminal.modes.restore(mode); + // log.debug("restore mode={} v={}", .{ mode, v }); + try self.setMode(mode, v); + } + + pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { + // Note: this function doesn't need to grab the render state or + // terminal locks because it is only called from process() which + // grabs the lock. + + // If we are setting cursor blinking, we ignore it if we have + // a default cursor blink setting set. This is a really weird + // behavior so this comment will go deep into trying to explain it. + // + // There are two ways to set cursor blinks: DECSCUSR (CSI _ q) + // and DEC mode 12. DECSCUSR is the modern approach and has a + // way to revert to the "default" (as defined by the terminal) + // cursor style and blink by doing "CSI 0 q". DEC mode 12 controls + // blinking and is either on or off and has no way to set a + // default. DEC mode 12 is also the more antiquated approach. + // + // The problem is that if the user specifies a desired default + // cursor blink with `cursor-style-blink`, the moment a running + // program uses DEC mode 12, the cursor blink can never be reset + // to the default without an explicit DECSCUSR. But if a program + // is using mode 12, it is by definition not using DECSCUSR. + // This makes for somewhat annoying interactions where a poorly + // (or legacy) behaved program will stop blinking, and it simply + // never restarts. + // + // To get around this, we have a special case where if the user + // specifies some explicit default cursor blink desire, we ignore + // DEC mode 12. We allow DECSCUSR to still set the cursor blink + // because programs using DECSCUSR usually are well behaved and + // reset the cursor blink to the default when they exit. + // + // To be extra safe, users can also add a manual `CSI 0 q` to + // their shell config when they render prompts to ensure the + // cursor is exactly as they request. + if (mode == .cursor_blinking and + self.default_cursor_blink != null) + { + return; + } + + // We first always set the raw mode on our mode state. + self.terminal.modes.set(mode, enabled); + + // And then some modes require additional processing. + switch (mode) { + // Just noting here that autorepeat has no effect on + // the terminal. xterm ignores this mode and so do we. + // We know about just so that we don't log that it is + // an unknown mode. + .autorepeat => {}, + + // Schedule a render since we changed colors + .reverse_colors => { + self.terminal.flags.dirty.reverse_colors = true; + try self.queueRender(); + }, + + // Origin resets cursor pos. This is called whether or not + // we're enabling or disabling origin mode and whether or + // not the value changed. + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + // When we disable left/right margin mode we need to + // reset the left/right margins. + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = false, + .clear_on_enter = false, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + .alt_screen_save_cursor_clear_enter => { + const opts: terminal.Terminal.AlternateScreenOptions = .{ + .cursor_save = true, + .clear_on_enter = true, + }; + + if (enabled) + self.terminal.alternateScreen(opts) + else + self.terminal.primaryScreen(opts); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + + // Force resize back to the window size + .enable_mode_3 => self.terminal.resize( + self.alloc, + self.grid_size.columns, + self.grid_size.rows, + ) catch |err| { + log.err("error updating terminal size: {}", .{err}); + }, + + .@"132_column" => try self.terminal.deccolm( + self.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + // We need to start a timer to prevent the emulator being hung + // forever. + .synchronized_output => { + if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); + try self.queueRender(); + }, + + .linefeed => { + self.messageWriter(.{ .linefeed_mode = enabled }); + }, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + try self.setMouseShape(.default); + } else { + self.terminal.flags.mouse_event = .none; + try self.setMouseShape(.text); + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } + + pub fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { + self.terminal.flags.mouse_shift_capture = if (v) .true else .false; + } + + pub fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { + switch (attr) { + .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), + + else => self.terminal.setAttribute(attr) catch |err| + log.warn("error setting attribute {}: {}", .{ attr, err }), + } + } + + pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screen.startHyperlink(uri, id); + } + + pub fn endHyperlink(self: *StreamHandler) !void { + self.terminal.screen.endHyperlink(); + } + + pub fn deviceAttributes( + self: *StreamHandler, + req: terminal.DeviceAttributeReq, + params: []const u16, + ) !void { + _ = params; + + // For the below, we quack as a VT220. We don't quack as + // a 420 because we don't support DCS sequences. + switch (req) { + .primary => self.messageWriter(.{ + .write_stable = "\x1B[?62;22c", + }), + + .secondary => self.messageWriter(.{ + .write_stable = "\x1B[>1;10;0c", + }), + + else => log.warn("unimplemented device attributes req: {}", .{req}), + } + } + + pub fn deviceStatusReport( + self: *StreamHandler, + req: terminal.device_status.Request, + ) !void { + switch (req) { + .operating_status => self.messageWriter(.{ .write_stable = "\x1B[0n" }), + + .cursor_position => { + const pos: struct { + x: usize, + y: usize, + } = if (self.terminal.modes.get(.origin)) .{ + .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, + } else .{ + .x = self.terminal.screen.cursor.x, + .y = self.terminal.screen.cursor.y, + }; + + // Response always is at least 4 chars, so this leaves the + // remainder for the row/column as base-10 numbers. This + // will support a very large terminal. + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint(&msg.write_small.data, "\x1B[{};{}R", .{ + pos.y + 1, + pos.x + 1, + }); + msg.write_small.len = @intCast(resp.len); + + self.messageWriter(msg); + }, + + .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = {} }), + } + } + + pub fn setCursorStyle( + self: *StreamHandler, + style: terminal.CursorStyleReq, + ) !void { + // Assume we're setting to a non-default. + self.default_cursor = false; + + switch (style) { + .default => { + self.default_cursor = true; + self.terminal.screen.cursor.cursor_style = self.default_cursor_style; + self.terminal.modes.set( + .cursor_blinking, + self.default_cursor_blink orelse true, + ); + }, + + .blinking_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_block => { + self.terminal.screen.cursor.cursor_style = .block; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_underline => { + self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.modes.set(.cursor_blinking, false); + }, + + .blinking_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, true); + }, + + .steady_bar => { + self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.modes.set(.cursor_blinking, false); + }, + + else => log.warn("unimplemented cursor style: {}", .{style}), + } + } + + pub fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { + self.terminal.setProtectedMode(mode); + } + + pub fn decaln(self: *StreamHandler) !void { + try self.terminal.decaln(); + } + + pub fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { + self.terminal.tabClear(cmd); + } + + pub fn tabSet(self: *StreamHandler) !void { + self.terminal.tabSet(); + } + + pub fn tabReset(self: *StreamHandler) !void { + self.terminal.tabReset(); + } + + pub fn saveCursor(self: *StreamHandler) !void { + self.terminal.saveCursor(); + } + + pub fn restoreCursor(self: *StreamHandler) !void { + try self.terminal.restoreCursor(); + } + + pub fn enquiry(self: *StreamHandler) !void { + log.debug("sending enquiry response={s}", .{self.enquiry_response}); + self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); + } + + pub fn scrollDown(self: *StreamHandler, count: usize) !void { + self.terminal.scrollDown(count); + } + + pub fn scrollUp(self: *StreamHandler, count: usize) !void { + self.terminal.scrollUp(count); + } + + pub fn setActiveStatusDisplay( + self: *StreamHandler, + req: terminal.StatusDisplay, + ) !void { + self.terminal.status_display = req; + } + + pub fn configureCharset( + self: *StreamHandler, + slot: terminal.CharsetSlot, + set: terminal.Charset, + ) !void { + self.terminal.configureCharset(slot, set); + } + + pub fn invokeCharset( + self: *StreamHandler, + active: terminal.CharsetActiveSlot, + slot: terminal.CharsetSlot, + single: bool, + ) !void { + self.terminal.invokeCharset(active, slot, single); + } + + pub fn fullReset( + self: *StreamHandler, + ) !void { + self.terminal.fullReset(); + try self.setMouseShape(.text); + } + + pub fn queryKittyKeyboard(self: *StreamHandler) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("querying kitty keyboard mode", .{}); + var data: termio.Message.WriteReq.Small.Array = undefined; + const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ + self.terminal.screen.kitty_keyboard.current().int(), + }); + + self.messageWriter(.{ + .write_small = .{ + .data = data, + .len = @intCast(resp.len), + }, + }); + } + + pub fn pushKittyKeyboard( + self: *StreamHandler, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("pushing kitty keyboard mode: {}", .{flags}); + self.terminal.screen.kitty_keyboard.push(flags); + } + + pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("popping kitty keyboard mode n={}", .{n}); + self.terminal.screen.kitty_keyboard.pop(@intCast(n)); + } + + pub fn setKittyKeyboard( + self: *StreamHandler, + mode: terminal.kitty.KeySetMode, + flags: terminal.kitty.KeyFlags, + ) !void { + if (comptime disable_kitty_keyboard_protocol) return; + + log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); + self.terminal.screen.kitty_keyboard.set(mode, flags); + } + + pub fn reportXtversion( + self: *StreamHandler, + ) !void { + log.debug("reporting XTVERSION: ghostty {s}", .{build_config.version_string}); + var buf: [288]u8 = undefined; + const resp = try std.fmt.bufPrint( + &buf, + "\x1BP>|{s} {s}\x1B\\", + .{ + "ghostty", + build_config.version_string, + }, + ); + const msg = try termio.Message.writeReq(self.alloc, resp); + self.messageWriter(msg); + } + + //------------------------------------------------------------------------- + // OSC + + pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { + var buf: [256]u8 = undefined; + if (title.len >= buf.len) { + log.warn("change title requested larger than our buffer size, ignoring", .{}); + return; + } + + @memcpy(buf[0..title.len], title); + buf[title.len] = 0; + + // Mark that we've seen a title + self.seen_title = true; + self.surfaceMessageWriter(.{ .set_title = buf }); + } + + pub fn setMouseShape( + self: *StreamHandler, + shape: terminal.MouseShape, + ) !void { + // Avoid changing the shape it it is already set to avoid excess + // cross-thread messaging. + if (self.terminal.mouse_shape == shape) return; + + self.terminal.mouse_shape = shape; + self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); + } + + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + // Note: we ignore the "kind" field and always use the standard clipboard. + // iTerm also appears to do this but other terminals seem to only allow + // certain. Let's investigate more. + + const clipboard_type: apprt.Clipboard = switch (kind) { + 'c' => .standard, + 's' => .selection, + 'p' => .primary, + else => .standard, + }; + + // Get clipboard contents + if (data.len == 1 and data[0] == '?') { + self.surfaceMessageWriter(.{ .clipboard_read = clipboard_type }); + return; + } + + // Write clipboard contents + self.surfaceMessageWriter(.{ + .clipboard_write = .{ + .req = try apprt.surface.Message.WriteReq.init( + self.alloc, + data, + ), + .clipboard_type = clipboard_type, + }, + }); + } + + pub fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt); + self.terminal.flags.shell_redraws_prompt = redraw; + } + + pub fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { + _ = aid; + self.terminal.markSemanticPrompt(.prompt_continuation); + } + + pub fn promptEnd(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.input); + } + + pub fn endOfInput(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.command); + } + + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + if (builtin.os.tag == .windows) { + log.warn("reportPwd unimplemented on windows", .{}); + return; + } + + const uri = std.Uri.parse(url) catch |e| { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + }; + + if (!std.mem.eql(u8, "file", uri.scheme) and + !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) + { + log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + return; + } + + // OSC 7 is a little sketchy because anyone can send any value from + // any host (such an SSH session). The best practice terminals follow + // is to valid the hostname to be local. + const host_valid = host_valid: { + const host_component = uri.host orelse break :host_valid false; + + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const host = switch (host_component) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { + break :host_valid true; + } + + // Otherwise, it must match our hostname. + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const hostname = posix.gethostname(&buf) catch |err| { + log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); + break :host_valid false; + }; + + break :host_valid std.mem.eql(u8, host, hostname); + }; + if (!host_valid) { + log.warn("OSC 7 host must be local", .{}); + return; + } + + // We need to unescape the path. We first try to unescape onto + // the stack and fall back to heap allocation if we have to. + var pathBuf: [1024]u8 = undefined; + const path, const heap = path: { + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const path = switch (uri.path) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + + // If the path doesn't have any escapes, we can use it directly. + if (std.mem.indexOfScalar(u8, path, '%') == null) + break :path .{ path, false }; + + // First try to stack-allocate + var fba = std.heap.FixedBufferAllocator.init(&pathBuf); + if (std.fmt.allocPrint(fba.allocator(), "{raw}", .{uri.path})) |v| + break :path .{ v, false } + else |_| {} + + // Fall back to heap + if (std.fmt.allocPrint(self.alloc, "{raw}", .{uri.path})) |v| + break :path .{ v, true } + else |_| {} + + // Fall back to using it directly... + log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); + break :path .{ path, false }; + }; + defer if (heap) self.alloc.free(path); + + log.debug("terminal pwd: {s}", .{path}); + try self.terminal.setPwd(path); + + // If we haven't seen a title, use our pwd as the title. + if (!self.seen_title) { + try self.changeWindowTitle(path); + self.seen_title = false; + } + } + + /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, + /// default foreground color, and background color respectively. + pub fn reportColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + terminator: terminal.osc.Terminator, + ) !void { + if (self.osc_color_report_format == .none) return; + + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color, + .background => self.background_color, + .cursor => self.cursor_color orelse self.foreground_color, + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + else => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + }, + .none => unreachable, // early return above + }; + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + pub fn setColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + const color = try terminal.color.RGB.parse(value); + + switch (kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = color; + _ = self.renderer_mailbox.push(.{ + .background_color = color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = color, + }, .{ .forever = {} }); + }, + } + } + + pub fn resetColor( + self: *StreamHandler, + kind: terminal.osc.Command.ColorKind, + value: []const u8, + ) !void { + switch (kind) { + .palette => { + const mask = &self.terminal.color_palette.mask; + if (value.len == 0) { + // Find all bit positions in the mask which are set and + // reset those indices to the default palette + var it = mask.iterator(.{}); + while (it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } else { + var it = std.mem.tokenizeScalar(u8, value, ';'); + while (it.next()) |param| { + // Skip invalid parameters + const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; + if (mask.isSet(i)) { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + } + } + } + }, + .foreground => { + self.foreground_color = self.default_foreground_color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = self.default_background_color; + _ = self.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = self.default_cursor_color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + }, + } + } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + var message = apprt.surface.Message{ .desktop_notification = undefined }; + + const title_len = @min(title.len, message.desktop_notification.title.len); + @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); + message.desktop_notification.title[title_len] = 0; + + const body_len = @min(body.len, message.desktop_notification.body.len); + @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); + message.desktop_notification.body[body_len] = 0; + + self.surfaceMessageWriter(message); + } +};