Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,462 @@
|
||||
// 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"})
|
||||
}
|
||||
Reference in new issue
Block a user