From 3e2429b8a5bcdeeb0e3fa0927b21e19481e81812 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 28 Oct 2026 22:06:04 -0400 Subject: [PATCH] Add autofocus mode. --- README.md | 14 +++++ internal/app/app.go | 8 +++ internal/app/browser_mode.go | 24 +++++++- internal/app/browser_mode_test.go | 53 +++++++++++++++++ internal/app/document_native_test.go | 73 ++++++++++++++++++++++++ internal/app/event_review_results.go | 1 + internal/config/profile_mapping.go | 10 +++- internal/config/schema.go | 2 + internal/config/settings.go | 2 + internal/config/structural_focus_test.go | 45 +++++++++++++++ internal/preferences/general.go | 2 + internal/preferences/preferences.ui | 1 + internal/preferences/protocol.go | 2 + internal/preferences/protocol_test.go | 16 ++++++ internal/preferences/values.go | 3 +- internal/version/version.go | 2 +- testdata/parity-ledger.csv | 2 +- tests/native_browser.py | 1 + tests/native_preferences.py | 26 ++++++++- 19 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 internal/config/structural_focus_test.go diff --git a/README.md b/README.md index 8a41239..20b997d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,20 @@ bounded against excessive output. See [speech queue verification](docs/testing/speech-queue.md) for automated checks and the listening procedure. +## Automatic focus mode during structural navigation + +In Preferences → General, enable **Automatic focus mode during structural +navigation** to enter focus mode when a structural command reaches a form +field or another widget that needs keyboard interaction. For example, press +E to move to a text entry, then type directly. Ordinary headings and links +keep browse mode. Like Orca, this option is off by default. + +The option is available in global and application preferences. Apply saves it; +application choices override the global value. Sticky browse mode still keeps +browse mode, and sticky focus mode still keeps focus mode. Use Cthulhu+A to +switch back to browse mode when finished interacting. +See [automatic focus verification](docs/testing/structural-focus.md). + ## Key echo The Key Echo preferences page offers three radio choices: Disable key echo, diff --git a/internal/app/app.go b/internal/app/app.go index ec1a9eb..1631d57 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -32,6 +32,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/platform/session" "git.stormux.org/storm/cthulhu/internal/platform/wayland" "git.stormux.org/storm/cthulhu/internal/platform/xorg" + "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/sound" "git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/wine" @@ -250,6 +251,13 @@ func Run(ctx context.Context, options Options) error { live.editFieldFocused = source.EditFieldFocused } live.Store(settings) + if browser != nil { + browser.settings = func() review.ModeSettings { + settings := review.DefaultModeSettings() + settings.StructuralTriggersFocus = live.Load().StructuralNavigationTriggersFocusMode + return settings + } + } if pointAdapter, ok := adapter.(pointReviewAdapter); ok { services = append(services, mouseReviewService(logger, pointAdapter, gatedSpeech{speech: speechOutput, gate: focusGate}, brailleOutput, live, focusGate)) diff --git a/internal/app/browser_mode.go b/internal/app/browser_mode.go index b87c106..31a9feb 100644 --- a/internal/app/browser_mode.go +++ b/internal/app/browser_mode.go @@ -32,6 +32,7 @@ type browserModeResult struct { // 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 { + settings func() review.ModeSettings source any keys browseKeyOwner capturer documentContextCapturer @@ -228,9 +229,30 @@ func (browser *browserModeController) accept(result browserModeResult, speech sp browser.ready = true modeInput := runtimeModeInput(result.owner) modeInput.Origin = result.origin - mode := browser.state.Update(modeInput, review.DefaultModeSettings()) + mode := browser.state.Update(modeInput, browser.modeSettings()) browser.set(mode, speech, false) } +func (browser *browserModeController) modeSettings() review.ModeSettings { + if browser.settings != nil { + return browser.settings() + } + return review.DefaultModeSettings() +} + +// The review worker supplies the target's owned ancestry from the same +// document used for navigation. No accessibility calls run on the event loop. +func (browser *browserModeController) navigationMode(result review.Result, speech speechEnqueuer) { + settings := browser.modeSettings() + if result.DocumentPosition.Origin != review.StructuralNavigation || !settings.StructuralTriggersFocus { + return + } + owner := atspi.DocumentContext{Document: result.DocumentPosition.Document, TopLevel: result.DocumentPosition.Document, Ancestors: result.DocumentCaretContext} + modeInput := runtimeModeInput(owner) + modeInput.Origin = review.StructuralNavigation + mode := browser.state.Update(modeInput, settings) + browser.set(mode, speech, false) +} + func (browser *browserModeController) set(mode review.DocumentMode, speech speechEnqueuer, manual bool) { previous := browser.mode browser.mode = mode diff --git a/internal/app/browser_mode_test.go b/internal/app/browser_mode_test.go index e5340ef..7227e96 100644 --- a/internal/app/browser_mode_test.go +++ b/internal/app/browser_mode_test.go @@ -753,3 +753,56 @@ func TestStickyBrowserModeSurvivesNavigationChromeAndWindowSuspension(t *testing } } } + +func TestStructuralNavigationAutomaticFocus(t *testing.T) { + for _, tc := range []struct { + name, role string + enabled bool + origin review.NavigationOrigin + sticky, want review.DocumentMode + }{ + {name: "default entry", role: "entry", want: review.BrowseMode}, + {name: "entry opt in", role: "entry", enabled: true, want: review.FocusMode}, + {name: "combo opt in", role: "combo box", enabled: true, want: review.FocusMode}, + {name: "heading stays browse", role: "heading", enabled: true, want: review.BrowseMode}, + {name: "link stays browse", role: "link", enabled: true, want: review.BrowseMode}, + {name: "sticky browse", role: "entry", enabled: true, sticky: review.BrowseMode, want: review.BrowseMode}, + {name: "sticky focus", role: "heading", enabled: true, sticky: review.FocusMode, want: review.FocusMode}, + {name: "caret unchanged", role: "entry", enabled: true, origin: review.CaretNavigation, want: review.BrowseMode}, + } { + t.Run(tc.name, func(t *testing.T) { + keys := &modeKeys{} + browser := newBrowserModeController(nil, keys, nil) + browser.settings = func() review.ModeSettings { + settings := review.DefaultModeSettings() + settings.StructuralTriggersFocus = tc.enabled + return settings + } + owner := modeContext(t, tc.role, false) + browser.stickyModes[owner.TopLevel.ID().BusName] = tc.sticky + origin := tc.origin + if origin == review.NativeNavigation { + origin = review.StructuralNavigation + } + result := review.Result{DocumentPosition: review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0], Origin: origin}, DocumentCaretContext: owner.Ancestors} + queue := &fakeSpeechQueue{spoken: make(chan string, 4)} + browser.navigated(result.DocumentPosition, owner.TopLevel.ID()) + browser.navigationMode(result, queue) + if browser.mode != tc.want || keys.enabled != (tc.want == review.BrowseMode) { + t.Fatalf("mode=%v browse keys=%t, want %v", browser.mode, keys.enabled, tc.want) + } + if tc.want == review.FocusMode && tc.sticky == review.InactiveMode { + select { + case message := <-queue.spoken: + if message != "Focus mode" { + t.Fatalf("announcement=%q", message) + } + default: + t.Fatal("missing focus mode announcement") + } + } else if len(queue.spoken) != 0 { + t.Fatal("unnecessary mode announcement") + } + }) + } +} diff --git a/internal/app/document_native_test.go b/internal/app/document_native_test.go index 73bf1fd..1a29bbc 100644 --- a/internal/app/document_native_test.go +++ b/internal/app/document_native_test.go @@ -482,6 +482,14 @@ func (queue *readAllProbeQueue) Enqueue(request speechRequest) { } func TestNativeBrowserModes(t *testing.T) { + testNativeBrowserModes(t, false) +} + +func TestNativeBrowserStructuralFocus(t *testing.T) { + testNativeBrowserModes(t, true) +} + +func testNativeBrowserModes(t *testing.T, structuralFocus bool) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } @@ -519,6 +527,13 @@ func TestNativeBrowserModes(t *testing.T) { } defer worker.Stop(context.Background()) browser := newBrowserModeController(listener, commands, adapter) + var automaticFocus atomic.Bool + automaticFocus.Store(structuralFocus) + browser.settings = func() review.ModeSettings { + settings := review.DefaultModeSettings() + settings.StructuralTriggersFocus = automaticFocus.Load() + return settings + } if err = browser.Start(ctx); err != nil { t.Fatal(err) } @@ -750,6 +765,64 @@ func TestNativeBrowserModes(t *testing.T) { t.Fatal("could not focus first document heading after reload") } browseReady() + if structuralFocus { + keys("h") + wait("Reading section, heading level 2") + browseReady() + keys("e") + waitPrefix("Display name") + wait("Focus mode") + if commands.BrowseNavigationEnabled() { + t.Fatal("automatic focus retained browse keys") + } + keys("h") + wait("h") + tree, err := adapter.CaptureDocument(ctx, worker.Focus()) + if err != nil { + t.Fatal(err) + } + ref, err := tree.Reference(worker.Focus()) + if err != nil { + t.Fatal(err) + } + document, err := tree.Document(ctx, ref) + if err != nil { + t.Fatal(err) + } + typedInEntry := false + for _, node := range document.Nodes { + if node.Snapshot.Name == "Display name" && node.Snapshot.Role == "entry" { + entry, err := tree.Reference(node.Snapshot.ID()) + if err != nil { + t.Fatal(err) + } + // Captured document text can predate the edit. Read the native + // field directly to prove delivery rather than key echo alone. + text, err := adapter.ReadDocumentCaret(ctx, entry, 0, atspi.DocumentCaretLine) + if err != nil { + t.Fatal(err) + } + typedInEntry = text.Text == "h" + } + } + if !typedInEntry { + t.Fatal("typing did not reach the structurally selected entry") + } + keys("Insert+a") + wait("Browse mode") + browseReady() + keys("h") + wait("Ordinary unfocusable heading, heading level 2") + browseReady() + automaticFocus.Store(false) + keys("shift+e") + waitPrefix("Secret") + browseReady() + keys("shift+e") + waitPrefix("Display name") + browseReady() + return + } keys("h") wait("Reading section, heading level 2") if err := worker.ReadFlat(review.Line, 0); err != nil { diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index d80e0dd..878c057 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -193,6 +193,7 @@ func (handler *eventReviewHandler) handle(result review.Result) { if browser != nil { objectReview.SetFlatOrigin(state.current.ID(), result.DocumentPosition.Offset) browser.navigated(result.DocumentPosition, objectReview.Focus()) + browser.navigationMode(result, speech) } } } else if result.FlatUnit != 0 { diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go index 2c1c488..a622f63 100644 --- a/internal/config/profile_mapping.go +++ b/internal/config/profile_mapping.go @@ -69,9 +69,10 @@ func profileFromSettings(settings Settings) writableProfile { Pronunciations: pronunciationSlicePointer(settings.Pronunciations), TextAttributes: writableTextAttributes{Choices: textAttributeSlicePointer(settings.TextAttributes.Choices), Indicator: intPointer(settings.TextAttributes.Indicator)}, TimeFormat: stringPointer(settings.TimeFormat), DateFormat: stringPointer(settings.DateFormat), - SpeakDescription: boolPointer(settings.SpeakDescription), - SpeechVerbosityLevel: intPointer(settings.SpeechVerbosityLevel), - SayAllStyle: stringPointer(settings.SayAllStyle), + StructuralNavigationTriggersFocusMode: boolPointer(settings.StructuralNavigationTriggersFocusMode), + SpeakDescription: boolPointer(settings.SpeakDescription), + SpeechVerbosityLevel: intPointer(settings.SpeechVerbosityLevel), + SayAllStyle: stringPointer(settings.SayAllStyle), Reading: writableReading{ AutomaticallyReadClipboardChanges: boolPointer(settings.Reading.AutomaticallyReadClipboardChanges), AutomaticallyReadTitleChanges: boolPointer(settings.Reading.AutomaticallyReadTitleChanges), @@ -277,6 +278,9 @@ func apply_profile_reading(settings *Settings, profile writableProfile) { if value := profile.Presentation.TextAttributes.Indicator; value != nil { settings.TextAttributes.Indicator = *value } + if value := profile.Presentation.StructuralNavigationTriggersFocusMode; value != nil { + settings.StructuralNavigationTriggersFocusMode = *value + } if value := profile.Presentation.SpeakDescription; value != nil { settings.SpeakDescription = *value } diff --git a/internal/config/schema.go b/internal/config/schema.go index c16cca6..e447eaf 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -66,6 +66,8 @@ type writableProgressBar struct { } type writablePresentation struct { + StructuralNavigationTriggersFocusMode *bool `toml:"structural-navigation-triggers-focus-mode,omitempty"` + TimeFormat *string `toml:"time-format,omitempty"` DateFormat *string `toml:"date-format,omitempty"` SpeakDescription *bool `toml:"speak-description,omitempty"` diff --git a/internal/config/settings.go b/internal/config/settings.go index ec9b322..75b915f 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -113,6 +113,8 @@ func DefaultProgressBarSettings() ProgressBarSettings { } type Settings struct { + StructuralNavigationTriggersFocusMode bool + OCR OCRSettings ProgressBar ProgressBarSettings TimeFormat string diff --git a/internal/config/structural_focus_test.go b/internal/config/structural_focus_test.go new file mode 100644 index 0000000..a8c96a6 --- /dev/null +++ b/internal/config/structural_focus_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestStructuralFocusDefaultPersistenceAndApplicationOverride(t *testing.T) { + settings := DefaultSettings() + if settings.StructuralNavigationTriggersFocusMode { + t.Fatal("automatic structural focus must default off") + } + omitted, err := Load(writeConfig(t, "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\n")) + if err != nil || omitted.StructuralNavigationTriggersFocusMode { + t.Fatalf("omitted setting = %t, %v", omitted.StructuralNavigationTriggersFocusMode, err) + } + store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) + if err != nil { + t.Fatal(err) + } + settings.StructuralNavigationTriggersFocusMode = true + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil || !loaded.StructuralNavigationTriggersFocusMode { + t.Fatalf("saved setting = %t, %v", loaded.StructuralNavigationTriggersFocusMode, err) + } + inherited, _, err := store.LoadForApplication("Editor") + if err != nil || !inherited.StructuralNavigationTriggersFocusMode { + t.Fatalf("inherited setting = %t, %v", inherited.StructuralNavigationTriggersFocusMode, err) + } + loaded.StructuralNavigationTriggersFocusMode = false + if err := store.SaveForApplication("Editor", loaded); err != nil { + t.Fatal(err) + } + overridden, found, err := store.LoadForApplication("Editor") + if err != nil || !found || overridden.StructuralNavigationTriggersFocusMode { + t.Fatalf("application override = %t, %t, %v", overridden.StructuralNavigationTriggersFocusMode, found, err) + } + global, err := store.Load() + if err != nil || !global.StructuralNavigationTriggersFocusMode { + t.Fatalf("application override changed global = %t, %v", global.StructuralNavigationTriggersFocusMode, err) + } +} diff --git a/internal/preferences/general.go b/internal/preferences/general.go index e2731db..d325312 100644 --- a/internal/preferences/general.go +++ b/internal/preferences/general.go @@ -41,6 +41,7 @@ func formatIndex(options []datetime.Option, value string) int { } func (session *uiSession) set_general_values(values Values) { + setCheck("structuralNavigationFocusCheckButton", values.StructuralNavigationTriggersFocusMode) session.progressBar = values.ProgressBar session.setKeyBindings(values) setCombo("progressBarOutputCombo", map[string]int{"off": 0, "beep": 1, "speak": 2, "both": 3}[values.ProgressBar.OutputMode]) @@ -51,6 +52,7 @@ func (session *uiSession) set_general_values(values Values) { } func (session *uiSession) get_general_values(values *Values) error { + values.StructuralNavigationTriggersFocusMode = getCheck("structuralNavigationFocusCheckButton") values.KeyBindings = input.CloneBindingOverrides(session.bindingDraft) values.KeyboardLayout = 1 if getCheck("generalLaptopButton") { diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui index 69e9694..e020bd7 100644 --- a/internal/preferences/preferences.ui +++ b/internal/preferences/preferences.ui @@ -64,6 +64,7 @@ False False + Automatic _focus mode during structural navigationTrueTrueSwitch to focus mode when structural navigation reaches a form field or other interactive widget. Progress _bar output:TrueprogressBarOutputCombo0 FalseOffBeepSpeakBeep and speak Report back_ground progress barsTrueTrue diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go index b17dd42..fa393c2 100644 --- a/internal/preferences/protocol.go +++ b/internal/preferences/protocol.go @@ -16,6 +16,8 @@ import ( ) type Values struct { + StructuralNavigationTriggersFocusMode bool + Pronunciations []config.PronunciationEntry TextAttributes config.TextAttributeSettings OCR config.OCRSettings diff --git a/internal/preferences/protocol_test.go b/internal/preferences/protocol_test.go index 8b606ca..7e69485 100644 --- a/internal/preferences/protocol_test.go +++ b/internal/preferences/protocol_test.go @@ -191,3 +191,19 @@ func TestCapitalCheckboxesRoundTrip(t *testing.T) { } } } + +func TestStructuralFocusValuesRoundTrip(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + if values.StructuralNavigationTriggersFocusMode { + t.Fatal("automatic structural focus defaults on") + } + values.StructuralNavigationTriggersFocusMode = true + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if !FromSettings(merged).StructuralNavigationTriggersFocusMode { + t.Fatal("preference lost during Apply") + } +} diff --git a/internal/preferences/values.go b/internal/preferences/values.go index 59951d4..98844d6 100644 --- a/internal/preferences/values.go +++ b/internal/preferences/values.go @@ -12,7 +12,7 @@ import ( ) func FromSettings(settings config.Settings) Values { - return Values{OCR: settings.OCR, Pronunciations: append([]config.PronunciationEntry(nil), settings.Pronunciations...), + return Values{StructuralNavigationTriggersFocusMode: settings.StructuralNavigationTriggersFocusMode, OCR: settings.OCR, Pronunciations: append([]config.PronunciationEntry(nil), settings.Pronunciations...), TextAttributes: config.TextAttributeSettings{Choices: append([]config.TextAttributeChoice(nil), settings.TextAttributes.Choices...), Indicator: settings.TextAttributes.Indicator}, KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...), KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat, @@ -74,6 +74,7 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) { settings.Pronunciations = append([]config.PronunciationEntry(nil), values.Pronunciations...) settings.TextAttributes = config.TextAttributeSettings{Choices: append([]config.TextAttributeChoice(nil), values.TextAttributes.Choices...), Indicator: values.TextAttributes.Indicator} settings.OCR = values.OCR + settings.StructuralNavigationTriggersFocusMode = values.StructuralNavigationTriggersFocusMode settings.KeyboardLayout = values.KeyboardLayout settings.CthulhuModifierKeys = append([]string(nil), values.CthulhuModifierKeys...) settings.KeyBindings = input.CloneBindingOverrides(values.KeyBindings) diff --git a/internal/version/version.go b/internal/version/version.go index 02846ac..cb3a5af 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.10.27" +const Version = "2026.10.28" const Channel = "testing" var Build = Version diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 6a69bb4..4e1fa53 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -20,7 +20,7 @@ terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the chara terminal-text-change-echo,src/cthulhu/scripts/terminal/script.py,terminal,Speak sanitized inserted or deleted text from terminals and editable text controls,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,"Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is pending.",,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,"Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending." managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. ssip-speak-cancel,src/cthulhu/speechdispatcherfactory.py,speech,Send speech and cancellation through SSIP,Storm Dragon,internal/speech,in-progress,go test ./internal/speech -count=1,Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11.,,Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending. -web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Complete Python structural settings and dialogs remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. +web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation after browser document replacement,Storm Dragon,internal/atspi internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass. web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed document content at the browser caret,Storm Dragon,internal/atspi internal/review internal/app internal/presentation,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. October 24 audit fixes validate dirty hypertext ancestors before crossing objects; refreshed retries include inserted parent text. Word whitespace uses bounded native character blocks and caret ranges have indexed lookup. Existing native navigation regressions pass; see docs/testing/browser-audit-fixes.md for limitations and remaining work. config-read-preview,src/cthulhu/backends/toml_backend.py,settings,Read existing Cthulhu TOML and merge the startup profile,Storm Dragon,internal/config,implemented,go test ./internal/config ./cmd/cthulhu -count=1,,,,,Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work. diff --git a/tests/native_browser.py b/tests/native_browser.py index 55985a6..c745426 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -12,6 +12,7 @@ from native_test_session import run_private_session BROWSER_PROOFS = { + "structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50), "disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"), "live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"), "replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"), diff --git a/tests/native_preferences.py b/tests/native_preferences.py index 9ab90f0..6281d87 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -706,7 +706,7 @@ def inside(): assert process.wait(timeout=5) == 0, "successful OK did not close" print("PASS: repeated Apply restores button focus, passive success status, focused rejection, OK closes") return - if application: + if application and os.environ.get("CTHULHU_PREFS_STRUCTURAL_FOCUS_ONLY") != "1": focus(named("Desktop")) key("Right") wait_for(lambda: named("Laptop").getState().contains(pyatspi.STATE_CHECKED), "application preferences keyboard layout could not be changed") @@ -951,6 +951,30 @@ def inside(): assert responses.read_remaining() == "", "Escape submitted changes" print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape") return + if os.environ.get("CTHULHU_PREFS_STRUCTURAL_FOCUS_ONLY") == "1": + checkbox = named("Automatic focus mode during structural navigation") + assert checkbox.getRole() == pyatspi.ROLE_CHECK_BOX + assert not checkbox.getState().contains(pyatspi.STATE_CHECKED) + focus(checkbox) + key("Tab") + wait_for(lambda: not checkbox.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave automatic focus checkbox") + key("shift+Tab") + wait_for(lambda: checkbox.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to automatic focus checkbox") + for enabled in (True, False): + key("space") + wait_for(lambda: checkbox.getState().contains(pyatspi.STATE_CHECKED) == enabled, "automatic focus checkbox did not toggle") + focus(named("Apply")); key("space") + applied = request() + assert applied["Values"]["StructuralNavigationTriggersFocusMode"] == enabled, applied + send(dict(initial, Values=applied["Values"], Message="Preferences saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy") + assert checkbox.getState().contains(pyatspi.STATE_CHECKED) == enabled + focus(checkbox) + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + assert responses.read_remaining() == "", "Cancel submitted changes" + print("PASS: automatic structural focus checkbox, default, Tab/Shift+Tab, enable/disable, Apply/reload, Cancel") + return if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1": focus(named("Desktop")) for page in ("Sounds", "Speech"):