feat: add readonly surface mode (#9130)

Tried my hand at #8432

Currently lacking tests and some sort of visual indicator that the
surface is locked.

Also, not entirely sure if I needed to touch `application.zig`.

Found what needed changing with help from Copilot (and added Docs w/
Copilot), but wrote most of the code myself.
This commit is contained in:
Mitchell Hashimoto
2025-12-12 14:16:39 -08:00
committed by GitHub
14 changed files with 289 additions and 39 deletions

View File

@@ -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,
/// (native) scrolling, and copy 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
@@ -812,6 +818,30 @@ 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);
}
/// 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.
@@ -843,7 +873,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.
@@ -860,7 +890,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();
@@ -871,6 +901,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;
@@ -929,7 +962,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,
@@ -1121,7 +1154,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,
);
@@ -1353,7 +1386,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 {
@@ -1726,7 +1759,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,
@@ -2292,7 +2325,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| {
@@ -2395,7 +2428,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.
@@ -2671,7 +2704,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 },
@@ -2900,7 +2933,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 },
@@ -3126,7 +3159,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);
}
}
@@ -3290,7 +3323,7 @@ pub fn scrollCallback(
};
};
for (0..y.magnitude()) |_| {
self.io.queueMessage(.{ .write_stable = seq }, .locked);
self.queueIo(.{ .write_stable = seq }, .locked);
}
}
@@ -3511,7 +3544,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);
@@ -3534,7 +3567,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);
@@ -3555,7 +3588,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);
@@ -3572,7 +3605,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);
@@ -3601,7 +3634,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);
@@ -3753,7 +3786,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,
);
@@ -4110,7 +4143,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) {
@@ -4120,7 +4153,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);
}
}
}
@@ -4393,7 +4426,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,
);
@@ -4493,7 +4526,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,
);
@@ -4869,7 +4902,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);
@@ -4896,7 +4929,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);
@@ -4929,9 +4962,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);
}
},
@@ -5204,19 +5237,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);
},
@@ -5246,14 +5279,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);
},
@@ -5261,19 +5294,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);
},
@@ -5383,6 +5416,16 @@ 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;
},
.reset_window_size => return try self.rt_app.performAction(
.{ .surface = self },
.reset_window_size,
@@ -5488,7 +5531,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| {
@@ -5686,7 +5729,7 @@ fn writeScreenFile(
},
.url = path,
}),
.paste => self.io.queueMessage(try termio.Message.writeReq(
.paste => self.queueIo(try termio.Message.writeReq(
self.alloc,
path,
), .unlocked),
@@ -5826,7 +5869,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);
@@ -5872,7 +5915,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);

View File

@@ -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,

View File

@@ -746,6 +746,7 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.readonly,
=> {
log.warn("unimplemented action={}", .{action});
return false;

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,