pull/12902/merge
Jon Parise 2026-06-03 12:27:01 -07:00 committed by GitHub
commit 9a33a11976
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 93 additions and 17 deletions

View File

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

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

View File

@ -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();
}
},

View File

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

View File

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