core: send selection_changed notification

The core had no signal to the apprt when the active selection changed,
so a consumer (e.g. a screen reader) kept reading a stale selection
until some unrelated query refreshed it.

This change adds a payload-less selection_changed action that's fired on
a selection state transition. The apprt reads the current selection
through the normal read path.

This consolidates selection state changes so the notification fires
consistently: all sites route through setSelection rather than calling
screen.select directly, including the mouse paths that previously
bypassed it for clipboard timing.

The new setSelectionAndCopy extends setSelection with the additional
'copy_on_select' behavior.

On macOS, this posts .ghosttySelectionDidChange, which is debounced
before posting a NSAccessibility .selectedTextChanged notification.

GTK has no consumer yet and no-ops the action.
This commit is contained in:
Jon Parise
2026-06-01 11:05:17 -04:00
parent 6246c288ae
commit c4e1ab8883
7 changed files with 93 additions and 17 deletions

View File

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

View File

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

View File

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