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
commit
7a1ff7779b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
119
src/Surface.zig
119
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,
|
||||
/// (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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -746,6 +746,7 @@ pub const Application = extern struct {
|
|||
.check_for_updates,
|
||||
.undo,
|
||||
.redo,
|
||||
.readonly,
|
||||
=> {
|
||||
log.warn("unimplemented action={}", .{action});
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue