Files

123 lines
5.4 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/review"
)
func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activity input.Activity, intent input.Intent) bool {
state := handler.state
logger := handler.logger
speech := handler.speech
browser := handler.browser
objectReview := handler.objectReview
debounce := handler.debounce
if event.Type == atspi.CaretEvent {
// A reader started while a terminal already has focus may never
// receive a focus-gain event for it. Caret movement caused by the
// user's own typing on the focused terminal is enough to anchor
// flat review; without this, review stays focusless until a
// workspace switch replays focus. Restricting this to typing and
// delete input keeps a background terminal's own output churn
// from claiming review focus.
if objectReview != nil && event.Source.Role == "terminal" && event.Source.Focused &&
(!state.focusedID.Valid() || state.focusedID == event.Source.ID()) &&
(intent == input.TypingIntent || intent == input.DeleteIntent) {
objectReview.SyncFocus(event.Source.ID())
if !state.focusedID.Valid() {
state.owner.follow(event)
state.focusedID = event.Source.ID()
state.setCurrent(event.Source)
}
}
if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID &&
state.current.ID() == state.focusedID && state.current.Role == "combo box" {
// The combo's name/value event presents this selection, even when
// provider requests delay it past the normal caret coalescing time.
logger.Debug("skipped read-only combo editor caret", "combo_path", event.Source.ReadOnlyCombo.Path)
return true
}
if event.Source.ID() == state.selectionCaretID {
state.selectionCaretID = atspi.ObjectID{}
if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond {
logger.Debug("skipped caret covered by text selection")
return true
}
}
if (event.Source.Autocomplete || activity.Sequence != 0 &&
(event.Source.Role == "spin button" || event.Source.Role == "button" || event.Source.Role == "link")) &&
event.Source.ID() == state.focusCaretID && activity.Sequence == state.focusCaretSequence &&
!activity.Selecting &&
(intent == input.NoIntent || intent == input.OtherIntent) {
// Address bars, spin buttons, and Gecko buttons/links move their
// text caret on focus. With the same input sequence and no
// caret-navigation intent, this must not interrupt the label.
logger.Debug("skipped control caret caused by focus", "role", event.Source.Role)
return true
}
if browser != nil && browser.ready && !event.Source.Editable {
state.setCurrent(event.Source)
if objectReview != nil {
objectReview.SyncFocus(event.Source.ID())
if err := objectReview.SyncDocumentCaret(event.Source.ID(), event.Detail1); err != nil {
logger.Debug("could not queue native document caret", "error", err)
}
}
}
if browser != nil && browser.ready && !event.Source.Editable && event.Source.TextCaretKnown && event.Source.Role != "password text" {
if state.documentSelection.id != event.Source.ID() {
state.documentSelection.reset(atspi.Snapshot{BusName: event.Source.BusName, Path: event.Source.Path})
}
fragmentLimit := selectionFragmentLimit
if activity.Selecting && intent == input.LineIntent {
fragmentLimit = lineSelectionFragmentLimit
}
if message := state.documentSelection.changedWithLimit(event.Source, fragmentLimit); message != "" {
state.pending.caret.clear()
speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: event.Type})
return true
}
}
// Any newer caret event supersedes an outstanding visual-line
// assembly, even when the new event can use its already-captured
// current-object line.
state.documentLineFocus = atspi.ObjectID{}
state.documentLineRequest = 0
state.documentLineEvent = atspi.Event{}
// Python's default non-layout web mode reads the current object's
// line. A blank browse caret is not a reason to scan the whole
// document; focus mode still assembles visual lines across objects.
if browser != nil && browser.ready && browser.mode == review.FocusMode &&
intent == input.LineIntent && !event.Source.Editable && objectReview != nil {
state.setCurrent(event.Source)
objectReview.SyncFocus(event.Source.ID())
if request, err := objectReview.ReadDocumentLine(event.Source.ID(), event.Detail1); err == nil {
state.documentLineFocus = event.Source.ID()
state.documentLineRequest = request
state.documentLineEvent = event
logger.Debug("queued document caret line", "role", event.Source.Role)
return true
}
}
if event.Source.Path != "" && event.Source.Path == state.suppressNextCaretPath {
state.suppressNextCaretPath = ""
logger.Debug("skipped caret event already covered by text change", "type", event.Type, "role", event.Source.Role)
return true
}
// Keep a short coalescing window for selection/focus events without
// adding the terminal focus delay to every caret movement.
state.pending.caret.schedule(event, intent, min(debounce, 20*time.Millisecond))
return true
}
return false
}