From 12bb2f3f4775fe1f203e7e0ec4c93ebc7c51062f Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Fri, 10 Oct 2025 12:30:55 -0600 Subject: [PATCH 01/13] feat: add readonly surface mode --- include/ghostty.h | 1 + src/Surface.zig | 31 ++++++++++++++++++++++++++++- src/apprt/action.zig | 6 ++++++ src/apprt/gtk/class/application.zig | 4 ++++ src/input/Binding.zig | 11 ++++++++++ src/input/command.zig | 6 ++++++ 6 files changed, 58 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..cd716e38f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,6 +797,7 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 8cd8d253b..951ef14ef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -145,6 +145,12 @@ focused: bool = true, /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + /// Used to send notifications that long running commands have finished. /// Requires that shell integration be active. Should represent a nanosecond /// precision timestamp. It does not necessarily need to correspond to the @@ -871,6 +877,9 @@ pub fn deactivateInspector(self: *Surface) void { /// True if the surface requires confirmation to quit. This should be called /// by apprt to determine if the surface should confirm before quitting. pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + // If the child has exited, then our process is certainly not alive. // We check this first to avoid the locking overhead below. if (self.child_exited) return false; @@ -2559,6 +2568,12 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; + // If the surface is in read-only mode, we consume the key event here + // without sending it to the PTY. + if (self.readonly) { + return .consumed; + } + // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); @@ -3267,7 +3282,9 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - if (self.io.terminal.screens.active_key == .alternate and + // Additionally, we don't send cursor keys if the surface is in read-only mode. + if (!self.readonly and + self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3393,6 +3410,9 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); + // If the surface is in read-only mode, do not send mouse reports to the PTY + if (self.readonly) return; + // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above @@ -5383,6 +5403,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_readonly => { + self.readonly = !self.readonly; + return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_readonly, + {}, + ); + }, + .reset_window_size => return try self.rt_app.performAction( .{ .surface = self }, .reset_window_size, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..83e2f5011 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,6 +139,11 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, + /// Toggle whether the surface is in read-only mode. When read-only, + /// no input is sent to the PTY but terminal-level operations like + /// selections, scrolling, and copy/paste keybinds still work. + toggle_readonly, + /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -335,6 +340,7 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, + toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..bbf408e02 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,6 +724,10 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), + .toggle_readonly => { + // The readonly state is managed in Surface.zig. + return true; + }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e1c636ab7..d368c48b2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -552,6 +552,16 @@ pub const Action = union(enum) { /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + /// Resize the current split in the specified direction and amount in /// pixels. The two arguments should be joined with a comma (`,`), /// like in `resize_split:up,10`. @@ -1241,6 +1251,7 @@ pub const Action = union(enum) { .new_split, .goto_split, .toggle_split_zoom, + .toggle_readonly, .resize_split, .equalize_splits, .inspector, diff --git a/src/input/command.zig b/src/input/command.zig index 639fc6e39..ce218718f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -485,6 +485,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the zoom state of the current split.", }}, + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + .equalize_splits => comptime &.{.{ .action = .equalize_splits, .title = "Equalize Splits", From 547bcd261dcbd25bfab99d3fb00c2f93af994605 Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Tue, 21 Oct 2025 09:57:14 -0600 Subject: [PATCH 02/13] fix: removed apprt action for toggle_readonly --- include/ghostty.h | 1 - src/Surface.zig | 6 +----- src/apprt/action.zig | 6 ------ src/apprt/gtk/class/application.zig | 4 ---- 4 files changed, 1 insertion(+), 16 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cd716e38f..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,7 +797,6 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, - GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 951ef14ef..7bfdad665 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5405,11 +5405,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; - return try self.rt_app.performAction( - .{ .surface = self }, - .toggle_readonly, - {}, - ); + return true; }, .reset_window_size => return try self.rt_app.performAction( diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 83e2f5011..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,11 +139,6 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, - /// Toggle whether the surface is in read-only mode. When read-only, - /// no input is sent to the PTY but terminal-level operations like - /// selections, scrolling, and copy/paste keybinds still work. - toggle_readonly, - /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -340,7 +335,6 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, - toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index bbf408e02..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,10 +724,6 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), - .toggle_readonly => { - // The readonly state is managed in Surface.zig. - return true; - }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), From b58ac983cfeecded082c7f51fe9149062952907e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 07:29:42 -0800 Subject: [PATCH 03/13] docs changes --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index e4ba605f6..1ea7a7201 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -147,7 +147,7 @@ selection_scroll_active: bool = false, /// True if the surface is in read-only mode. When read-only, no input /// is sent to the PTY but terminal-level operations like selections, -/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// (native) scrolling, and copy keybinds still work. Warn before quit is /// always enabled in this state. readonly: bool = false, From 29fdb541d56f980afa53b6ea3ef7c8985317e1de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 12:00:20 -0800 Subject: [PATCH 04/13] make all IO message queueing go through queueIo so we can intercept --- src/Surface.zig | 85 ++++++++++++++++++++++++------------------- src/termio/Termio.zig | 5 ++- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1ea7a7201..819972509 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -818,6 +818,15 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } +/// Queue a message for the IO thread. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + self.io.queueMessage(msg, mutex); +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -849,7 +858,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = true }, .unlocked); + self.queueIo(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -866,7 +875,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = false }, .unlocked); + self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -938,7 +947,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We always use an allocating message because we don't know // the length of the title and this isn't a performance critical // path. - self.io.queueMessage(.{ + self.queueIo(.{ .write_alloc = .{ .alloc = self.alloc, .data = data, @@ -1130,7 +1139,7 @@ fn selectionScrollTick(self: *Surface) !void { // If our screen changed while this is happening, we stop our // selection scroll. if (self.mouse.left_click_screen != t.screens.active_key) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -1362,7 +1371,7 @@ fn reportColorScheme(self: *Surface, force: bool) void { .dark => "\x1B[?997;1n", }; - self.io.queueMessage(.{ .write_stable = output }, .unlocked); + self.queueIo(.{ .write_stable = output }, .unlocked); } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { @@ -1735,7 +1744,7 @@ pub fn updateConfig( errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - self.io.queueMessage(.{ + self.queueIo(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, @@ -2301,7 +2310,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { self.balancePaddingIfNeeded(); // Notify the terminal - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); // Update our terminal default size if necessary. self.recomputeInitialSize() catch |err| { @@ -2404,7 +2413,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { } // Mail the IO thread - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); } /// Recalculate the balanced padding if needed. @@ -2686,7 +2695,7 @@ pub fn keyCallback( } errdefer write_req.deinit(); - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2915,7 +2924,7 @@ fn endKeySequence( if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -3141,7 +3150,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; self.renderer_state.mutex.unlock(); - self.io.queueMessage(.{ .focused = focused }, .unlocked); + self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3307,7 +3316,7 @@ pub fn scrollCallback( }; }; for (0..y.magnitude()) |_| { - self.io.queueMessage(.{ .write_stable = seq }, .locked); + self.queueIo(.{ .write_stable = seq }, .locked); } } @@ -3532,7 +3541,7 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = 6, } }, .locked); @@ -3555,7 +3564,7 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(i), } }, .locked); @@ -3576,7 +3585,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3593,7 +3602,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3622,7 +3631,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3774,7 +3783,7 @@ pub fn mouseButtonCallback( // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .unlocked, ); @@ -4131,7 +4140,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -4141,7 +4150,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } } @@ -4414,7 +4423,7 @@ pub fn cursorPosCallback( // Stop selection scrolling when inside the viewport within a 1px buffer // for fullscreen windows, but only when selection scrolling is active. if (pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -4514,7 +4523,7 @@ pub fn cursorPosCallback( if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = true }, .locked, ); @@ -4890,7 +4899,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, full_data, ), .unlocked); @@ -4917,7 +4926,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, text, ), .unlocked); @@ -4950,9 +4959,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); } else { - self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); + self.queueIo(.{ .write_stable = ck.application }, .unlocked); } }, @@ -5225,19 +5234,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (self.io.terminal.screens.active_key == .alternate) return false; } - self.io.queueMessage(.{ + self.queueIo(.{ .clear_screen = .{ .history = true }, }, .unlocked); }, .scroll_to_top => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .top = {} }, }, .unlocked); }, .scroll_to_bottom => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .bottom = {} }, }, .unlocked); }, @@ -5267,14 +5276,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_up => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = -1 * rows }, }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = rows }, }, .unlocked); }, @@ -5282,19 +5291,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.size.grid().rows); const delta: isize = @intFromFloat(@trunc(fraction * rows)); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = delta }, }, .unlocked); }, .scroll_page_lines => |lines| { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = lines }, }, .unlocked); }, .jump_to_prompt => |delta| { - self.io.queueMessage(.{ + self.queueIo(.{ .jump_to_prompt = @intCast(delta), }, .unlocked); }, @@ -5514,7 +5523,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; }, - .io => self.io.queueMessage(.{ .crash = {} }, .unlocked), + .io => self.queueIo(.{ .crash = {} }, .unlocked), }, .adjust_selection => |direction| { @@ -5712,7 +5721,7 @@ fn writeScreenFile( }, .url = path, }), - .paste => self.io.queueMessage(try termio.Message.writeReq( + .paste => self.queueIo(try termio.Message.writeReq( self.alloc, path, ), .unlocked), @@ -5852,7 +5861,7 @@ fn completeClipboardPaste( }; for (vecs) |vec| if (vec.len > 0) { - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, vec, ), .unlocked); @@ -5898,7 +5907,7 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, buf, ), .unlocked); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 53df00433..7263418a7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -22,6 +22,9 @@ const configpkg = @import("../config.zig"); const log = std.log.scoped(.io_exec); +/// Mutex state argument for queueMessage. +pub const MutexState = enum { locked, unlocked }; + /// Allocator alloc: Allocator, @@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { pub fn queueMessage( self: *Termio, msg: termio.Message, - mutex: enum { locked, unlocked }, + mutex: MutexState, ) void { self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, From 0bf3642939122bcc0beea45929f4d5d4ea14335a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:07:43 -0800 Subject: [PATCH 05/13] core: manage read-only through queueIo --- src/Surface.zig | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 819972509..19dc086dd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -819,11 +819,26 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { } /// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. fn queueIo( self: *Surface, msg: termio.Message, mutex: termio.Termio.MutexState, ) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + self.io.queueMessage(msg, mutex); } @@ -3291,9 +3306,7 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - // Additionally, we don't send cursor keys if the surface is in read-only mode. - if (!self.readonly and - self.io.terminal.screens.active_key == .alternate and + if (self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3402,10 +3415,9 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! const MouseReportAction = enum { press, release, motion }; /// Returns true if mouse reporting is enabled both in the config and -/// the terminal state, and the surface is not in read-only mode. +/// the terminal state. fn isMouseReporting(self: *const Surface) bool { - return !self.readonly and - self.config.mouse_reporting and + return self.config.mouse_reporting and self.io.terminal.flags.mouse_event != .none; } @@ -3420,9 +3432,6 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); - // Callers must verify the surface is not in read-only mode - assert(!self.readonly); - // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above From dc7bc3014e1ea4033f07af372e86f34d400182bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:13:53 -0800 Subject: [PATCH 06/13] add apprt action to notify apprt of surface readonly state --- include/ghostty.h | 8 ++++++++ src/Surface.zig | 5 +++++ src/apprt/action.zig | 9 +++++++++ src/apprt/gtk/class/application.zig | 1 + 4 files changed, 23 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..a75fdc245 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -573,6 +573,12 @@ typedef enum { GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + // apprt.action.DesktopNotification.C typedef struct { const char* title; @@ -837,6 +843,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, } ghostty_action_tag_e; typedef union { @@ -874,6 +881,7 @@ typedef union { ghostty_action_start_search_s start_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 19dc086dd..45b629865 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5424,6 +5424,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); return true; }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..608081a46 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -314,6 +314,9 @@ pub const Action = union(Key) { /// The currently selected search match index (1-based). search_selected: SearchSelected, + /// The readonly state of the surface has changed. + readonly: Readonly, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -375,6 +378,7 @@ pub const Action = union(Key) { end_search, search_total, search_selected, + readonly, }; /// Sync with: ghostty_action_u @@ -532,6 +536,11 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const Readonly = enum(c_int) { + off, + on, +}; + pub const MouseVisibility = enum(c_int) { visible, hidden, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..efca498b4 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -746,6 +746,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .readonly, => { log.warn("unimplemented action={}", .{action}); return false; From ec2638b3c6e3ceb870e459380fa0f91a46a392a2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:41:32 -0800 Subject: [PATCH 07/13] macos: readonly badge --- macos/Sources/Ghostty/Ghostty.App.swift | 28 +++++++++++ macos/Sources/Ghostty/Package.swift | 4 ++ macos/Sources/Ghostty/SurfaceView.swift | 47 +++++++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 13 +++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 ++ 5 files changed, 95 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index aff3edbc7..4788a4376 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -588,6 +588,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_READONLY: + setReadonly(app, target: target, v: action.action.readonly) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) @@ -1010,6 +1013,31 @@ extension Ghostty { } } + private static func setReadonly( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_readonly_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set readonly does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyDidChangeReadonly, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON, + ] + ) + + default: + assertionFailure() + } + } + private static func moveTab( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4b3eb60aa..258857e8e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -391,6 +391,10 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + + /// Readonly mode changed + static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") + static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") /// Toggle maximize of current window diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ba678db59..c027162ab 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -104,6 +104,11 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge() + } + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -757,6 +762,48 @@ extension Ghostty { } } + // MARK: Readonly Badge + + /// A badge overlay that indicates a surface is in readonly mode. + /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. + struct ReadonlyBadge: View { + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) + + var body: some View { + VStack { + HStack { + Spacer() + + HStack(spacing: 5) { + Image(systemName: "eye.fill") + .font(.system(size: 12)) + Text("Read-only") + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .foregroundStyle(badgeColor) + } + .padding(8) + + Spacer() + } + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Read-only terminal") + } + + private var badgeBackground: some View { + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5) + ) + } + } + #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 130df6f44..d8670e644 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -123,6 +123,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -333,6 +336,11 @@ extension Ghostty { selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeReadonly(_:)), + name: .ghosttyDidChangeReadonly, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -703,6 +711,11 @@ extension Ghostty { bell = true } + @objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) { + guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return } + readonly = value + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 09c41c0b5..568a93314 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,6 +43,9 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil + + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From ceb1b5e587c7a769f33ca8e0d208ce3067cb2947 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:50:20 -0800 Subject: [PATCH 08/13] macos: add a read-only menu item in View --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 7 +++++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8baee3d89..e10547bbc 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? @IBOutlet private var menuChangeTabTitle: NSMenuItem? + @IBOutlet private var menuReadonly: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index d009b9c62..a321061dd 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -47,6 +47,7 @@ + @@ -328,6 +329,12 @@ + + + + + + diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d8670e644..853a6d51c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1512,6 +1512,14 @@ extension Ghostty { } } + @IBAction func toggleReadonly(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "toggle_readonly" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) @@ -1988,6 +1996,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { case #selector(findHide): return searchState != nil + case #selector(toggleReadonly): + item.state = readonly ? .on : .off + return true + default: return true } From 173d8efd90536afc53316cbc00f3628dae3fd3df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:55:02 -0800 Subject: [PATCH 09/13] macos: add to context menu --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e10547bbc..043d85e1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -545,6 +545,7 @@ class AppDelegate: NSObject, self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 853a6d51c..d26545ebc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1429,6 +1429,9 @@ extension Ghostty { item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") + item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "eye.fill") + item.state = readonly ? .on : .off menu.addItem(.separator()) item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") From 22b8809858088d8760c5601e4ec1658d6be964d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:01:35 -0800 Subject: [PATCH 10/13] macos: add a popover to the readonly badge with info --- macos/Sources/Ghostty/SurfaceView.swift | 54 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c027162ab..3bdcaafe6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -106,7 +106,9 @@ extension Ghostty { // Readonly indicator badge if surfaceView.readonly { - ReadonlyBadge() + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } } // Progress report @@ -767,6 +769,10 @@ extension Ghostty { /// A badge overlay that indicates a surface is in readonly mode. /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. struct ReadonlyBadge: View { + let onDisable: () -> Void + + @State private var showingPopover = false + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) var body: some View { @@ -784,12 +790,18 @@ extension Ghostty { .padding(.vertical, 4) .background(badgeBackground) .foregroundStyle(badgeColor) + .onTapGesture { + showingPopover = true + } + .backport.pointerStyle(.link) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover) + } } .padding(8) Spacer() } - .allowsHitTesting(false) .accessibilityElement(children: .ignore) .accessibilityLabel("Read-only terminal") } @@ -803,6 +815,44 @@ extension Ghostty { ) } } + + struct ReadonlyPopoverView: View { + let onDisable: () -> Void + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "eye.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Read-Only Mode") + .font(.system(size: 13, weight: .semibold)) + } + + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Disable") { + onDisable() + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + .frame(width: 280) + } + } #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view From 43b4ed5bc0c20d6a39d20260a924c308f065d43e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:12:02 -0800 Subject: [PATCH 11/13] macos: only show readonly badge on AppKit --- macos/Sources/Ghostty/SurfaceView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 3bdcaafe6..eaf935df9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,14 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - - // Readonly indicator badge - if surfaceView.readonly { - ReadonlyBadge { - surfaceView.toggleReadonly(nil) - } - } - + .allowsHitTesting(false) // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -123,6 +116,13 @@ extension Ghostty { } #if canImport(AppKit) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } + } + // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { From 19e0864688e0ce53d030d7d66eb474e9ccba816e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:14:14 -0800 Subject: [PATCH 12/13] macos: unintended change --- macos/Sources/Ghostty/SurfaceView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index eaf935df9..82232dd89 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,7 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - .allowsHitTesting(false) + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { From 182cb35bae0f7dc45cb6f98374a6babf42e73401 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:15:43 -0800 Subject: [PATCH 13/13] core: remove readonly check --- src/Surface.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 45b629865..a3b306fef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2592,12 +2592,6 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; - // If the surface is in read-only mode, we consume the key event here - // without sending it to the PTY. - if (self.readonly) { - return .consumed; - } - // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock();