Files

186 lines
5.9 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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
}