diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..72bbb57a8 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -934,6 +934,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index ce35fa42d..583357077 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -620,6 +620,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_SELECTION_CHANGED: + selectionChanged(app, target: target) + case GHOSTTY_ACTION_READONLY: setReadonly(app, target: target, v: action.action.readonly) @@ -1070,6 +1073,27 @@ extension Ghostty { } } + private static func selectionChanged( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch target.tag { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("selection changed 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: .ghosttySelectionDidChange, + object: surfaceView + ) + + default: + assertionFailure() + } + } + private static func setReadonly( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 03211862f..757970e00 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -360,6 +360,9 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + /// The active selection changed + static let ghosttySelectionDidChange = Notification.Name("com.mitchellh.ghostty.ghosttySelectionDidChange") + /// Readonly mode changed static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 8c22c0cdf..afb7b0e9b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -76,6 +76,9 @@ extension Ghostty { // Cancellable for search state needle changes private var searchNeedleCancellable: AnyCancellable? + // Cancellable for the debounced accessibility selection-change post. + private var accessibilitySelectionCancellable: AnyCancellable? + // Whether the pointer should be visible or not @Published private(set) var pointerStyle: CursorStyle = .horizontalText @@ -286,6 +289,16 @@ extension Ghostty { } } + // A drag can emit multiple selection changes. Debounce so screen + // readers hear one announcement once the selection settles. + accessibilitySelectionCancellable = NotificationCenter.default + .publisher(for: .ghosttySelectionDidChange, object: self) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + NSAccessibility.post(element: self, notification: .selectedTextChanged) + } + // Before we initialize the surface we want to register our notifications // so there is no window where we can't receive them. let center = NotificationCenter.default diff --git a/src/Surface.zig b/src/Surface.zig index 99c740c89..f6f5e7b99 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1217,7 +1217,7 @@ fn selectionScrollTick(self: *Surface) !void { // We modified our viewport and selection so we need to queue // a render. - try self.io.terminal.screens.active.select(selection); + try self.setSelection(selection); try self.queueRender(); } @@ -2326,19 +2326,46 @@ fn copySelectionToClipboards( }; } -/// Set the selection contents. +/// Set the active selection and notify the apprt on a genuine state +/// transition. All selection mutations route through here rather than +/// `screen.select` directly so the notification fires consistently. To +/// also copy per `copy_on_select`, use `setSelectionAndCopy`. /// /// This must be called with the renderer mutex held. fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { + // Compute the transition before `select` below, which untracks (frees) + // the previous selection's tracked pins; reading them after would be a + // use-after-free. + const prev_ = self.io.terminal.screens.active.selection; + const changed = changed: { + const prev = prev_ orelse break :changed sel_ != null; + const sel = sel_ orelse break :changed true; + break :changed !sel.eql(prev); + }; + try self.io.terminal.screens.active.select(sel_); + if (changed) { + _ = self.rt_app.performAction( + .{ .surface = self }, + .selection_changed, + {}, + ) catch |err| { + log.warn("apprt failed selection_changed notification err={}", .{err}); + }; + } +} + +/// Set a selection and, per `copy_on_select`, copy it to the clipboard. +/// For committing selection gestures (mouse release, select-all binding). +/// +/// This must be called with the renderer mutex held. +fn setSelectionAndCopy(self: *Surface, sel: terminal.Selection) !void { + try self.setSelection(sel); + // If copy on select is false then exit early. if (self.config.copy_on_select == .false) return; - // Set our selection clipboard. If the selection is cleared we do not - // clear the clipboard. - const sel = sel_ orelse return; - switch (self.config.copy_on_select) { .false => unreachable, // handled above with an early exit @@ -3836,7 +3863,7 @@ pub fn mouseButtonCallback( if (self.config.copy_on_select != .false) { const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { - try self.setSelection(terminal.Selection.init( + try self.setSelectionAndCopy(terminal.Selection.init( prev.start(), prev.end(), prev.rectangle, @@ -3981,16 +4008,16 @@ pub fn mouseButtonCallback( else => unreachable, } - // We set the selection directly rather than use `setSelection` because - // we want to avoid copying the selection to the selection clipboard. - // For left mouse clicks we only set the clipboard on release. + // Use `setSelection` (not `setSelectionAndCopy`) here to avoid + // touching the selection clipboard: for left mouse clicks we only + // copy on release. if (press_selection) |selection| { - try self.io.terminal.screens.active.select(selection); + try self.setSelection(selection); try self.queueRender(); } else if (self.mouse.selection_gesture.left_click_count == 1 and self.io.terminal.screens.active.selection != null) { - try self.io.terminal.screens.active.select(null); + try self.setSelection(null); try self.queueRender(); } } @@ -4056,13 +4083,13 @@ pub fn mouseButtonCallback( // If there is a link at this position, we want to // select the link. Otherwise, select the word. if (try self.linkAtPos(pos)) |link| { - try self.setSelection(link.selection); + try self.setSelectionAndCopy(link.selection); } else { const sel = screen.selectWord( pin, self.config.selection_word_chars, ) orelse break :sel; - try self.setSelection(sel); + try self.setSelectionAndCopy(sel); } try self.queueRender(); @@ -4460,7 +4487,7 @@ pub fn mousePressureCallback( ); } - try self.io.terminal.screens.active.select(sel orelse break :select); + try self.setSelection(sel orelse break :select); try self.queueRender(); } } @@ -4665,7 +4692,7 @@ pub fn cursorPosCallback( } // Update our selection based on the gesture state - try self.io.terminal.screens.active.select(drag_selection); + try self.setSelection(drag_selection); } } @@ -5416,7 +5443,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { - try self.setSelection(s); + try self.setSelectionAndCopy(s); try self.queueRender(); } }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f6865af83..4728d73ce 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -295,6 +295,10 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Called when the active selection changes. The apprt should read the + /// current selection itself; this carries no payload. + selection_changed, + /// Undo the last action. See the "undo" keybinding for more /// details on what can and cannot be undone. undo, @@ -396,6 +400,7 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + selection_changed, undo, redo, check_for_updates, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 107510b43..a627086eb 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -743,6 +743,9 @@ pub const Application = extern struct { .ring_bell => Action.ringBell(target), + // GTK has no accessibility consumer for this yet. + .selection_changed => {}, + .scrollbar => Action.scrollbar(target, value), .set_title => Action.setTitle(target, value),