123 lines
5.4 KiB
Go
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
|
|
}
|