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

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