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

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