// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "sync" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/sound" ) type documentContextCapturer interface { CaptureDocumentContext(context.Context, atspi.ObjectID) (atspi.DocumentContext, error) } type browseKeyOwner interface{ SetBrowseNavigation(bool) } type commandGrabState interface{ CommandGrabActive() bool } type browserModeResult struct { focus atspi.ObjectID generation uint64 owner atspi.DocumentContext origin review.NavigationOrigin err error } // The event loop owns policy. One joined worker resolves only the latest focus; // a generation check rejects results overtaken by another focus or caret event. type browserModeController struct { source any keys browseKeyOwner capturer documentContextCapturer mutex sync.Mutex focus atspi.ObjectID origin review.NavigationOrigin generation uint64 captureParent context.Context captureContext context.Context captureCancel context.CancelFunc captureTimeout time.Duration wake chan struct{} results chan browserModeResult toggles chan input.Command stickyModes map[string]review.DocumentMode cancel context.CancelFunc done chan struct{} document atspi.ObjectID navigation atspi.ObjectID navigationDocument atspi.ObjectID navigationFocus atspi.ObjectID navigationInput input.Activity navigationAt time.Time commandAt time.Time state review.DocumentModeState mode review.DocumentMode ready bool resolving bool } func newBrowserModeController(source any, keys browseKeyOwner, capturer documentContextCapturer) *browserModeController { return &browserModeController{source: source, keys: keys, capturer: capturer, captureTimeout: 2 * time.Second, wake: make(chan struct{}, 1), results: make(chan browserModeResult, 1), toggles: make(chan input.Command, 8), stickyModes: make(map[string]review.DocumentMode)} } func (browser *browserModeController) invalidateCaptureContextLocked() { if browser.captureParent == nil { return } if browser.captureCancel != nil { browser.captureCancel() browser.captureCancel = nil } browser.captureContext, browser.captureCancel = context.WithCancel(browser.captureParent) } func (browser *browserModeController) RecentInput() input.Activity { return recentInput(browser.source) } func (browser *browserModeController) Start(ctx context.Context) error { ctx, browser.cancel = context.WithCancel(ctx) browser.mutex.Lock() browser.captureParent = ctx browser.captureContext = ctx browser.captureCancel = nil browser.mutex.Unlock() browser.done = make(chan struct{}) go func() { defer close(browser.done) for { select { case <-ctx.Done(): return case <-browser.wake: } if ctx.Err() != nil { return } browser.mutex.Lock() focus, generation, origin, captureContext := browser.focus, browser.generation, browser.origin, browser.captureContext browser.mutex.Unlock() deadline, cancel := context.WithTimeout(captureContext, browser.captureTimeout) owner, err := browser.capturer.CaptureDocumentContext(deadline, focus) cancel() browser.mutex.Lock() current := focus == browser.focus && generation == browser.generation browser.mutex.Unlock() if !current { continue } if errors.Is(err, context.DeadlineExceeded) && captureContext.Err() == nil { // Retry a slow provider while this focus is still current. A focus // or load change cancels the wait and leaves its own wake pending. timer := time.NewTimer(200 * time.Millisecond) select { case <-timer.C: case <-captureContext.Done(): case <-ctx.Done(): } timer.Stop() if captureContext.Err() == nil && ctx.Err() == nil { select { case browser.wake <- struct{}{}: default: } } continue } select { case browser.results <- browserModeResult{focus: focus, generation: generation, owner: owner, origin: origin, err: err}: case <-ctx.Done(): return } } }() return nil } func (browser *browserModeController) Stop(ctx context.Context) error { browser.keys.SetBrowseNavigation(false) if browser.cancel == nil { return nil } browser.cancel() select { case <-browser.done: return nil case <-ctx.Done(): return ctx.Err() } } func (browser *browserModeController) follow(focus atspi.ObjectID) { browser.followWithOrigin(focus, review.NativeNavigation) } func (browser *browserModeController) followCaret(focus atspi.ObjectID) { browser.followWithOrigin(focus, review.CaretNavigation) } func (browser *browserModeController) clear() { browser.nativeButtonFocus(false) browser.mutex.Lock() browser.focus = atspi.ObjectID{} browser.document = atspi.ObjectID{} browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} browser.navigationFocus = atspi.ObjectID{} browser.generation++ browser.resolving = false browser.ready = false browser.mode = review.InactiveMode browser.state = review.DocumentModeState{} browser.invalidateCaptureContextLocked() browser.mutex.Unlock() browser.keys.SetBrowseNavigation(false) } func (browser *browserModeController) nativeButtonFocus(focused bool) { if keys, ok := browser.keys.(interface{ SetNativeButtonFocus(bool) }); ok { keys.SetNativeButtonFocus(focused) } } func (browser *browserModeController) followWithOrigin(focus atspi.ObjectID, origin review.NavigationOrigin) { browser.mutex.Lock() if focus.Valid() && focus == browser.focus && (browser.ready || browser.resolving) && (origin == browser.origin || browser.ready && browser.mode == review.BrowseMode) { browser.mutex.Unlock() return } browser.focus = focus browser.navigationFocus = atspi.ObjectID{} browser.origin = origin browser.generation++ browser.resolving = true browser.invalidateCaptureContextLocked() browser.mutex.Unlock() if origin != review.CaretNavigation { browser.ready = false browser.keys.SetBrowseNavigation(false) } select { case browser.wake <- struct{}{}: default: } } func (browser *browserModeController) accept(result browserModeResult, speech speechEnqueuer) { browser.mutex.Lock() current := result.focus == browser.focus && result.generation == browser.generation browser.mutex.Unlock() if !current { return } browser.resolving = false if result.err != nil { browser.ready = false browser.mode = review.InactiveMode browser.keys.SetBrowseNavigation(false) return } if result.owner.TopLevel.ID() != browser.document { browser.document = result.owner.TopLevel.ID() browser.state = review.DocumentModeState{} browser.state.SetSticky(browser.stickyModes[browser.document.BusName]) } browser.ready = true modeInput := runtimeModeInput(result.owner) modeInput.Origin = result.origin mode := browser.state.Update(modeInput, review.DefaultModeSettings()) browser.set(mode, speech, false) } func (browser *browserModeController) set(mode review.DocumentMode, speech speechEnqueuer, manual bool) { previous := browser.mode browser.mode = mode browser.keys.SetBrowseNavigation(mode == review.BrowseMode) // Python starts web content in browse mode. Entering ordinary document // content is therefore silent; only an actual automatic mode change (for // example, browse to focus for an entry) is announced. if mode == review.InactiveMode || (!manual && (mode == previous || previous == review.InactiveMode && mode == review.BrowseMode)) { return } text := "Browse mode" cue := "browse_mode" if mode == review.FocusMode { text = "Focus mode" cue = "focus_mode" } if manual { speech.Interrupt() } // Append automatic changes so they do not replace the focused control. speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual}) } func (browser *browserModeController) toggle(speech speechEnqueuer) { if !browser.ready || browser.mode == review.InactiveMode { speech.Enqueue(speechRequest{Text: "Not in web document content.", Role: "system", Cause: "document-mode"}) return } delete(browser.stickyModes, browser.document.BusName) browser.set(browser.state.Toggle(), speech, true) } // Until table layout/PDF/action semantics are resolved, ambiguous widgets stay // in focus mode. Editable and composite ancestors also keep letters available // to the application. This is a deliberate conservative runtime subset of the // more complete reference policy, not guessed false values for missing traits. func runtimeModeInput(owner atspi.DocumentContext) review.ModeInput { result := review.ModeInput{Context: owner} for _, ancestor := range owner.Ancestors { if ancestor.Editable { result.Traits.ContentEditableWithEmbeddedObjects = true } switch ancestor.Role { case "table", "table cell", "tree table": result.Traits.GridDescendant = true } } return result } func (browser *browserModeController) navigated(position review.DocumentPosition, focus atspi.ObjectID) { browser.mutex.Lock() browser.focus = position.Snapshot.ID() browser.origin = position.Origin browser.generation++ browser.navigationFocus = focus browser.navigation = position.Snapshot.ID() browser.navigationDocument = position.Document.ID() browser.navigationInput = browser.RecentInput() browser.navigationAt = time.Now() browser.invalidateCaptureContextLocked() browser.mutex.Unlock() browser.document = position.Document.ID() browser.state = review.DocumentModeState{} browser.state.SetSticky(browser.stickyModes[browser.document.BusName]) browser.mode = browser.state.Current() browser.ready = true browser.keys.SetBrowseNavigation(browser.mode == review.BrowseMode) } func (browser *browserModeController) acceptsDocumentResult(result review.Result) bool { browser.mutex.Lock() focus := browser.focus anchor := browser.navigationFocus browser.mutex.Unlock() // A pending chrome/other-document focus must not be overwritten by an // old structural result. Native focus on the target itself is its echo. if !browser.ready && focus != result.DocumentFocus && focus != result.DocumentPosition.Snapshot.ID() { return false } if browser.mode == review.InactiveMode { return result.DocumentFragment && focus.Valid() && result.DocumentFocus == focus } if (result.DocumentCaretCommand != 0 || result.DocumentActivate) && browser.mode != review.BrowseMode { return false } if result.Err != nil { // Caret navigation retains the worker's native focus anchor while the // browser cursor follows a text leaf. Failures have no target position; // match that anchor so a timeout cannot turn into a silently dead key. return result.DocumentFocus.Valid() && (result.DocumentFocus == focus || browser.ready && result.DocumentFocus == anchor) } if result.DocumentActivate { return result.DocumentFocus.Valid() && (result.DocumentFocus == focus || browser.ready && result.DocumentFocus == anchor) } return result.DocumentPosition.Document.ID() == browser.document } // A load can reuse both the document path and the last focused object path. // Force context resolution and retire the previous document generation. func (browser *browserModeController) loaded(focus atspi.ObjectID) { browser.mutex.Lock() browser.focus = atspi.ObjectID{} browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} browser.navigationAt = time.Time{} browser.navigationFocus = atspi.ObjectID{} browser.invalidateCaptureContextLocked() browser.mutex.Unlock() browser.document = atspi.ObjectID{} browser.mode = review.InactiveMode browser.state = review.DocumentModeState{} browser.follow(focus) } // Loading retires the old document while keeping window ownership with the // event loop. A later busy:false, load-stopped, or load-complete can resolve // the current document again. func (browser *browserModeController) loading() { browser.mutex.Lock() browser.generation++ browser.resolving = false browser.ready = false browser.focus = atspi.ObjectID{} browser.document = atspi.ObjectID{} browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} browser.navigationFocus = atspi.ObjectID{} browser.navigationAt = time.Time{} browser.mode = review.InactiveMode browser.state = review.DocumentModeState{} browser.invalidateCaptureContextLocked() browser.mutex.Unlock() browser.keys.SetBrowseNavigation(false) } func (browser *browserModeController) observeCommand(command input.Command) { target, _, _ := documentReviewCommand(command) browser.mutex.Lock() browser.commandAt = time.Now() if target == 0 { browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} browser.navigationAt = time.Time{} } browser.mutex.Unlock() } // recentConsumedCommand reports whether X11 just dispatched one of Cthulhu's // passive command grabs. Some toolkits emit a transient window deactivation as // a side effect of that grab. Python Cthulhu ignores the same event when its // last keyboard event was consumed. func (browser *browserModeController) recentConsumedCommand() bool { if state, ok := browser.keys.(commandGrabState); ok && state.CommandGrabActive() { return true } browser.mutex.Lock() defer browser.mutex.Unlock() return !browser.commandAt.IsZero() && time.Since(browser.commandAt) <= 100*time.Millisecond } // A new unshifted browse arrow is owned by Go. Retain its cursor when the // previous placement's native caret arrives after that arrow was queued. func (browser *browserModeController) retainsBrowseCaret(event atspi.Event) bool { browser.mutex.Lock() defer browser.mutex.Unlock() return browser.ready && browser.mode == review.BrowseMode && browser.navigationDocument.Valid() && browser.navigationDocument.BusName == event.Source.BusName } func (browser *browserModeController) suppressNavigationEcho(event atspi.Event) bool { if event.Type == atspi.WindowActivatedEvent || isMenuRole(event.Source.Role) { return false } browser.mutex.Lock() activity := browser.RecentInput() // A fast repeat can press the next structural-navigation key before // Chromium emits the caret/focus events caused by the previous placement. // Keep a very short grace period for typing-intent keys (the single-letter // structural bindings), while native caret and focus-navigation keys still // end suppression immediately. recentStructural := activity.Intent == input.TypingIntent && !browser.navigationAt.IsZero() && time.Since(browser.navigationAt) <= 100*time.Millisecond if activity.Sequence != browser.navigationInput.Sequence && !recentStructural { browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} browser.navigationAt = time.Time{} } target, document := browser.navigation, browser.navigationDocument browser.mutex.Unlock() if document.Valid() && event.Type == atspi.AccessibleNameEvent && event.Source.ID().BusName == document.BusName { return true } if event.Type != atspi.CaretEvent && !isFocusEvent(event.Type) { return false } if target.Valid() && event.Source.ID() == target { return true } // Chromium commonly reports the caret or focus on a newly-created child // of the structural target rather than on the target itself. The child can // have an identity which was not present in the captured tree, so ancestry // cannot be resolved reliably here. Navigation suppression is already // bounded by the next native input sequence; within that bound, treat // focus/caret events from the target's browser application as echoes. This // prevents a child static/link announcement from replacing the structural // presentation and from moving the independent document cursor to an // uncaptured identity. return document.Valid() && event.Source.ID().BusName == document.BusName } func (browser *browserModeController) sticky(mode review.DocumentMode, speech speechEnqueuer) { if !browser.ready || browser.mode == review.InactiveMode { speech.Enqueue(speechRequest{Text: "Not in web document content.", Role: "system", Cause: "document-mode"}) return } if len(browser.stickyModes) >= 16 { clear(browser.stickyModes) } browser.stickyModes[browser.document.BusName] = mode browser.state.SetSticky(mode) browser.mode = mode browser.keys.SetBrowseNavigation(mode == review.BrowseMode) text := "Browse mode is sticky." if mode == review.FocusMode { text = "Focus mode is sticky." } speech.Interrupt() speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "document-mode"}) }