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.
pull/9876/head^2
Mitchell Hashimoto 2025-12-12 14:16:39 -08:00 committed by GitHub
commit 7a1ff7779b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 289 additions and 39 deletions

View File

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

View File

@ -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?
@ -544,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")

View File

@ -47,6 +47,7 @@
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
<outlet property="menuReadonly" destination="xpe-ia-Yjw" id="MMT-Sl-AfD"/>
<outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
@ -328,6 +329,12 @@
<action selector="changeTitle:" target="-1" id="XuL-QB-Q9l"/>
</connections>
</menuItem>
<menuItem title="Terminal Read-only" id="xpe-ia-Yjw">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleReadonly:" target="-1" id="Gqx-wT-K9v"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="Vkj-tP-dMZ"/>
<menuItem title="Quick Terminal" id="1pv-LF-NBJ">
<modifierMask key="keyEquivalentModifierMask"/>

View File

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

View File

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

View File

@ -116,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 {
@ -757,6 +764,96 @@ 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 {
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 {
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)
.onTapGesture {
showingPopover = true
}
.backport.pointerStyle(.link)
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover)
}
}
.padding(8)
Spacer()
}
.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)
)
}
}
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
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't

View File

@ -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 }
@ -1416,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")
@ -1499,6 +1515,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)
@ -1975,6 +1999,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
case #selector(findHide):
return searchState != nil
case #selector(toggleReadonly):
item.state = readonly ? .on : .off
return true
default:
return true
}

View File

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

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,