463 lines
17 KiB
Go
463 lines
17 KiB
Go
// 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"})
|
|
}
|