// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package review import "git.stormux.org/storm/cthulhu/internal/atspi" // DocumentMode is a policy result, not permission to install keyboard grabs. // Inactive suspends web commands outside a live document, including chrome. type DocumentMode uint8 const ( InactiveMode DocumentMode = iota BrowseMode FocusMode ) // ModeSettings mirrors the three Python web navigation-origin preferences. type ModeSettings struct { StructuralTriggersFocus bool CaretTriggersFocus bool NativeTriggersFocus bool } func DefaultModeSettings() ModeSettings { return ModeSettings{NativeTriggersFocus: true} } // ModeTraits contains semantic facts which the current owned snapshots do not // yet capture. A future native resolver must supply these before runtime key // routing uses this policy; false must not stand in for an unknown query. type ModeTraits struct { LayoutOnlyTable bool TextBlock bool NamedActionWithoutUsefulChildren bool PDFViewer bool GridDescendant bool ContentEditableWithEmbeddedObjects bool } type ModeInput struct { Context atspi.DocumentContext Traits ModeTraits Origin NavigationOrigin SayAll bool UnmodifiedArrow bool PreviousNavigableTooltip bool } // DocumentModeState belongs to one browser policy owner. It performs no native // work, speech, or input mutation. Its zero value starts in browse mode. The // owner serializes calls and resets it when that browser owner is retired. // Chrome/application suspension does not discard sticky state. type DocumentModeState struct { current DocumentMode sticky DocumentMode } func (state *DocumentModeState) Current() DocumentMode { if state.current == FocusMode { return FocusMode } return BrowseMode } func (state *DocumentModeState) Sticky() DocumentMode { return state.sticky } // Toggle is a manual toggle: like Python it clears either sticky mode. func (state *DocumentModeState) Toggle() DocumentMode { if state.Current() == FocusMode { state.current = BrowseMode } else { state.current = FocusMode } state.sticky = InactiveMode return state.current } func (state *DocumentModeState) SetSticky(mode DocumentMode) { if mode != BrowseMode && mode != FocusMode { return } state.current, state.sticky = mode, mode } // Update follows scripts/web/script.py:useFocusMode precedence. The caller // resolves a fresh DocumentContext; stale identities must not reuse a cached // context. Inactive results never change the remembered web mode. func (state *DocumentModeState) Update(input ModeInput, settings ModeSettings) DocumentMode { if !input.Context.Document.Valid() || !input.Context.TopLevel.Valid() || len(input.Context.Ancestors) == 0 || input.Context.Ancestors[0].Defunct || !input.Context.Ancestors[0].StatesKnown { return InactiveMode } mode := state.decide(input, settings) state.current = mode return mode } func (state *DocumentModeState) decide(input ModeInput, settings ModeSettings) DocumentMode { if state.sticky != InactiveMode { return state.sticky } if input.SayAll { return BrowseMode } switch input.Origin { case StructuralNavigation: if !settings.StructuralTriggersFocus { return BrowseMode } case CaretNavigation: if !settings.CaretTriggersFocus && !input.PreviousNavigableTooltip { return BrowseMode } default: if !settings.NativeTriggersFocus { return state.Current() } } if focusModeWidget(input) { return FocusMode } if state.Current() != FocusMode { return BrowseMode } role := input.Context.Ancestors[0].Role if input.UnmodifiedArrow && (role == "link" || role == "radio button") { return FocusMode } if hasModeAncestor(input, "embedded") { if role == "document web" || role == "tool tip" && input.Context.Ancestors[0].Focused { return BrowseMode } return FocusMode } return BrowseMode } func focusModeWidget(input ModeInput) bool { object := input.Context.Ancestors[0] traits := input.Traits if object.Editable || object.Expandable && object.Focusable && object.Role != "link" { return true } switch object.Role { case "combo box", "entry", "list box", "menu", "menu item", "check menu item", "radio menu item", "page tab", "password text", "progress bar", "slider", "spin button", "tool bar", "tree item", "tree table", "tree": return true case "table", "table cell": if traits.LayoutOnlyTable { return false } case "list item": return hasModeAncestor(input, "list box") } if object.HasPopup && (object.Role == "button" || object.Role == "push button" || object.Role == "toggle button") { return true } if (object.Role == "embedded" || object.Role == "table" || object.Role == "table cell") && !traits.TextBlock && !traits.NamedActionWithoutUsefulChildren && !traits.PDFViewer { return true } return traits.GridDescendant || hasModeAncestor(input, "menu") || hasModeAncestor(input, "tool bar") || traits.ContentEditableWithEmbeddedObjects } func hasModeAncestor(input ModeInput, role string) bool { for _, ancestor := range input.Context.Ancestors[1:] { if ancestor.Role == role { return true } } return false }