// 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 { 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.current = 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.current = 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 }