diff --git a/README.md b/README.md index b4c97c0..407ce34 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,28 @@ Cthulhu is a Linux screen reader written in Go. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). +## Key echo + +The Key Echo preferences page offers three radio choices: Disable key echo, +Enable key echo, and Key echo in edit fields. The last choice limits typing +and key-name echo to writable edit controls. It uses the existing key categories +and custom echo voice settings, and Apply takes effect immediately. Existing +configurations keep their previous enabled or disabled behavior. The new setting +is `edit-fields-only` in the profile's `key-echo` table. + +Native controls must expose editable accessibility metadata. This includes +standard entries, text editors and editable browser controls. Read-only fields, +password contents, buttons, menus and terminals do not enable this mode. Controls +that omit editable metadata stay quiet. Pasted text, terminal output containing +multiple characters, live regions, navigation and deletion feedback retain their +existing presentation rules; those announcements are distinct from key echo. + +Wine Access supplies edit-control focus and typing feedback, including standard +Edit controls and its supported Scintilla editors. Key-name echo also requires +matching current X11 foreground metadata; without that evidence it stays quiet. +Wine insertion feedback uses the custom key voice when character echo is off, +and the custom character voice when character echo is on. + ## Wine Access Install the separate `wine-access` helper for Wine application accessibility. diff --git a/internal/app/app.go b/internal/app/app.go index 81ad5f6..b96cb9d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -234,6 +234,9 @@ func Run(ctx context.Context, options Options) error { } services = append(services, reviewServices...) live := &liveSettings{clipboardChanges: make(chan struct{}, 1), wineReview: wineReview} + if source, ok := adapter.(interface{ EditFieldFocused() bool }); ok { + live.editFieldFocused = source.EditFieldFocused + } live.Store(settings) if pointAdapter, ok := adapter.(pointReviewAdapter); ok { services = append(services, mouseReviewService(logger, pointAdapter, diff --git a/internal/app/document_native_test.go b/internal/app/document_native_test.go index f68ba18..f2172c4 100644 --- a/internal/app/document_native_test.go +++ b/internal/app/document_native_test.go @@ -293,7 +293,9 @@ func TestNativeBrowserEditing(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - processEventsWithInput(ctx, logger, queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil) + options := presentation.DefaultOptions() + options.KeyEchoInEditFieldsOnly = true + processEventsWithInput(ctx, logger, queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil, options) }() defer func() { cancel(); <-done }() keys := func(chord string) { @@ -321,6 +323,9 @@ func TestNativeBrowserEditing(t *testing.T) { } keys("ctrl+l") collect() + if !adapter.EditFieldFocused() { + t.Fatal("browser address bar did not enable edit-field key echo") + } for _, letter := range []string{"c", "t", "h"} { for len(queue.interrupt) > 0 { <-queue.interrupt @@ -406,6 +411,9 @@ func TestNativeBrowserEditing(t *testing.T) { // exercises real selected inline completion without external networking. keys("ctrl+l") collect() + if !adapter.EditFieldFocused() { + t.Fatal("browser address bar did not enable edit-field key echo") + } if out, err := exec.CommandContext(ctx, "xdotool", "type", "--clearmodifiers", "--delay", "20", "brave://version").CombinedOutput(); err != nil { t.Fatalf("type URL: %v %s", err, out) } @@ -414,6 +422,9 @@ func TestNativeBrowserEditing(t *testing.T) { collect() keys("ctrl+l") collect() + if !adapter.EditFieldFocused() { + t.Fatal("browser address bar did not enable edit-field key echo") + } selectedCompletion.Store(false) for _, letter := range []string{"b", "r", "a"} { keys(letter) diff --git a/internal/app/echo_preferences_test.go b/internal/app/echo_preferences_test.go index c436548..29ed2d1 100644 --- a/internal/app/echo_preferences_test.go +++ b/internal/app/echo_preferences_test.go @@ -158,3 +158,101 @@ func TestCustomEchoVoiceInheritanceAndRouting(t *testing.T) { t.Fatal("custom echo survived master disable") } } + +func TestEditFieldsOnlyKeyNamesAndVoice(t *testing.T) { + for _, tc := range []struct { + name string + only, enabled, focused, known bool + want bool + }{ + {"all fields", false, true, false, false, true}, + {"disabled", true, false, true, true, false}, + {"editor", true, true, true, true, true}, + {"button", true, true, false, true, false}, + {"unknown", true, true, false, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + settings := config.DefaultSettings() + settings.EnableKeyEcho = tc.enabled + settings.KeyEchoInEditFieldsOnly = tc.only + settings.UseCustomEchoVoice = true + settings.UseCustomEchoForKey = true + settings.UseCustomEchoForCharacter = false + live := &liveSettings{} + live.Store(settings) + if tc.known { + live.editFieldFocused = func() bool { return tc.focused } + } + listener := &fakeInputListener{events: make(chan input.Event, 1)} + listener.events <- input.NewKeyEvent(67, true, "F1") + close(listener.events) + speech := &remoteSpeechQueue{} + processInputEvents(context.Background(), slog.New(slog.DiscardHandler), speech, listener, inputEchoSettingsFromConfig(settings), live, nil) + if (len(speech.requests) != 0) != tc.want { + t.Fatalf("requests=%+v", speech.requests) + } + if tc.want && (speech.requests[0].Text != "f1" || !speech.requests[0].CustomEcho) { + t.Fatalf("key voice lost: %+v", speech.requests[0]) + } + }) + } +} + +func TestEditFieldsOnlyPreferencesApplyAndReload(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + live := &liveSettings{} + live.Store(settings) + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) + worker.live = live + values := preferences.FromSettings(settings) + values.KeyEchoInEditFieldsOnly = true + response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted { + t.Fatalf("Apply: %+v", response) + } + loaded, err := config.Load(settings.Path) + if err != nil || !loaded.EnableKeyEcho || !loaded.KeyEchoInEditFieldsOnly { + t.Fatalf("reload: %+v, %v", loaded, err) + } + if !live.Load().KeyEchoInEditFieldsOnly || !presentationSettingsFromConfig(live.Load(), false).KeyEchoInEditFieldsOnly { + t.Fatal("Apply did not reach typing policy") + } + values.KeyEchoInEditFieldsOnly = false + response = worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) + if !response.Accepted || live.Load().KeyEchoInEditFieldsOnly { + t.Fatal("could not return to unrestricted echo") + } +} + +func TestEditFieldsOnlyNativeTypingVoice(t *testing.T) { + settings := config.DefaultSettings() + settings.KeyEchoInEditFieldsOnly = true + settings.UseCustomEchoVoice = true + settings.UseCustomEchoForKey = true + settings.UseCustomEchoForCharacter = false + live := &liveSettings{} + live.Store(settings) + queue := &remoteSpeechQueue{} + state := &eventLoopState{} + state.presentationOptions = presentationSettingsFromConfig(settings, false) + presenter := eventPresenter{state: state, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)} + source := atspi.Snapshot{Role: "text", Editable: true, Focused: true} + presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Source: source, Payload: "A"}) + if len(queue.requests) != 1 || !queue.requests[0].CustomEcho || !queue.requests[0].Character { + t.Fatalf("typed key voice: %+v", queue.requests) + } + source.Role = "terminal" + source.Editable = false + presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Source: source, Payload: "b"}) + if len(queue.requests) != 1 { + t.Fatal("terminal echoed typed key") + } + source.Role = "section" + source.LiveRegion = true + source.LivePoliteness = "polite" + presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Source: source, Payload: "c"}) + if len(queue.requests) != 2 || queue.requests[1].Text != "c" || queue.requests[1].CustomEcho { + t.Fatalf("live region changed: %+v", queue.requests) + } +} diff --git a/internal/app/input_events.go b/internal/app/input_events.go index 572081d..6dfecf7 100644 --- a/internal/app/input_events.go +++ b/internal/app/input_events.go @@ -82,6 +82,9 @@ func processInputEvents( settings := live.Load() echo.settings = inputEchoSettingsFromConfig(settings) custom = customEcho(settings, "key") + if settings.KeyEchoInEditFieldsOnly && (live.editFieldFocused == nil || !live.editFieldFocused()) { + echo.settings.EnableKeyEcho = false + } } if text := echo.Text(event); text != "" { speech.Enqueue(speechRequest{Text: text, Role: "key", Cause: "key-echo", CustomEcho: custom}) diff --git a/internal/app/live_settings.go b/internal/app/live_settings.go index 0fb3975..26ce398 100644 --- a/internal/app/live_settings.go +++ b/internal/app/live_settings.go @@ -13,6 +13,7 @@ import ( // Published settings are immutable snapshots shared by the preferences, input, // and accessibility workers. Each event uses one complete snapshot. type liveSettings struct { + editFieldFocused func() bool // Installed before workers start; reads cached focus only. current atomic.Pointer[config.Settings] application atomic.Pointer[string] requestedApplication atomic.Pointer[string] diff --git a/internal/app/remote.go b/internal/app/remote.go index b9cfc6b..65497ae 100644 --- a/internal/app/remote.go +++ b/internal/app/remote.go @@ -100,13 +100,17 @@ func (presenter remoteSpeech) presentWineEditor(event remote.AccessibleEvent) bo if presenter.live != nil { settings = presenter.live.Load() } - if inserted && !wineTypingEchoEnabled(event.Name, settings) { + if inserted && (settings.KeyEchoInEditFieldsOnly && !wineEchoEditField(event) || !wineTypingEchoEnabled(event.Name, settings)) { return false } character := inserted || utf8.RuneCountInString(event.Name) == 1 custom := false if character && presenter.live != nil && (inserted || slices.Contains(kinds, "kind=7")) { - custom = customEcho(settings, "character") + kind := "character" + if inserted && !settings.EnableEchoByCharacter { + kind = "key" + } + custom = customEcho(settings, kind) } return presenter.presentRequest(speechRequest{ Text: event.Name, Role: "system", Cause: "wine-accessible-event", Append: true, diff --git a/internal/app/runtime_remote.go b/internal/app/runtime_remote.go index a482c58..9858b38 100644 --- a/internal/app/runtime_remote.go +++ b/internal/app/runtime_remote.go @@ -63,6 +63,14 @@ func newRemoteRuntimeServices(deps remoteServiceDependencies) []lifecycle.Servic return remoteHandler.presentMessage(message, "wine-accessible-event") }) wineEvents.logger = deps.logger + wineEvents.foreground, _ = deps.adapter.(foregroundMonitor) + wineEvents.titles, _ = deps.adapter.(windowTitleSource) + if deps.live != nil { + nativeEditField := deps.live.editFieldFocused + deps.live.editFieldFocused = func() bool { + return wineEvents.EditFieldFocused() || nativeEditField != nil && nativeEditField() + } + } connectWineWindowTitles(wineEvents, deps.live, deps.adapter) remoteHandler.wine = wineEvents if deps.live != nil { diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go index 97c7dbb..e74e66a 100644 --- a/internal/app/runtime_settings.go +++ b/internal/app/runtime_settings.go @@ -67,26 +67,27 @@ func inputEchoSettingsFromConfig(settings config.Settings) input.EchoSettings { func presentationSettingsFromConfig(settings config.Settings, soundEnabled bool) presentation.Options { return presentation.Options{ - Reading: &settings.Reading, - SpeakDescription: settings.SpeakDescription && !settings.Reading.OnlySpeakDisplayedText, - ReadFullRowInGUITable: settings.Reading.ReadFullRowInGUITable, - SpeechVerbosityLevel: settings.SpeechVerbosityLevel, - SpeakBlankLines: settings.Reading.SpeakBlankLines && !settings.Reading.OnlySpeakDisplayedText, - SayAllStyle: settings.SayAllStyle, - EnableSound: soundEnabled && soundConfiguration(settings).Enabled, - PlaySoundForRole: settings.PlaySoundForRole, - PlaySoundForState: settings.PlaySoundForState, - RoleSoundPresentation: settings.RoleSoundPresentation, - SpatializeObjectSounds: settings.SpatializeObjectSounds, - EnableKeyEcho: settings.EnableKeyEcho, - EnableEchoByCharacter: settings.EnableEchoByCharacter, - EnableEchoByWord: settings.EnableEchoByWord, - EnableEchoBySentence: settings.EnableEchoBySentence, - EnableAlphabeticKeys: settings.EnableAlphabeticKeys, - EnableNumericKeys: settings.EnableNumericKeys, - EnablePunctuationKeys: settings.EnablePunctuationKeys, - EnableSpace: settings.EnableSpace, - EnableDiacriticalKeys: settings.EnableDiacriticalKeys, + Reading: &settings.Reading, + SpeakDescription: settings.SpeakDescription && !settings.Reading.OnlySpeakDisplayedText, + ReadFullRowInGUITable: settings.Reading.ReadFullRowInGUITable, + SpeechVerbosityLevel: settings.SpeechVerbosityLevel, + SpeakBlankLines: settings.Reading.SpeakBlankLines && !settings.Reading.OnlySpeakDisplayedText, + SayAllStyle: settings.SayAllStyle, + EnableSound: soundEnabled && soundConfiguration(settings).Enabled, + PlaySoundForRole: settings.PlaySoundForRole, + PlaySoundForState: settings.PlaySoundForState, + RoleSoundPresentation: settings.RoleSoundPresentation, + SpatializeObjectSounds: settings.SpatializeObjectSounds, + EnableKeyEcho: settings.EnableKeyEcho, + KeyEchoInEditFieldsOnly: settings.KeyEchoInEditFieldsOnly, + EnableEchoByCharacter: settings.EnableEchoByCharacter, + EnableEchoByWord: settings.EnableEchoByWord, + EnableEchoBySentence: settings.EnableEchoBySentence, + EnableAlphabeticKeys: settings.EnableAlphabeticKeys, + EnableNumericKeys: settings.EnableNumericKeys, + EnablePunctuationKeys: settings.EnablePunctuationKeys, + EnableSpace: settings.EnableSpace, + EnableDiacriticalKeys: settings.EnableDiacriticalKeys, PlaySoundForPositionInSet: settings.PlaySoundForPositionInSet, } diff --git a/internal/app/wine_echo_focus.go b/internal/app/wine_echo_focus.go new file mode 100644 index 0000000..6fd29a8 --- /dev/null +++ b/internal/app/wine_echo_focus.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "slices" + "strings" + + "git.stormux.org/storm/cthulhu/internal/remote" +) + +func wineEchoEditField(event remote.AccessibleEvent) bool { + return strings.EqualFold(event.Role, "edit") && event.Description != "password" && + !slices.Contains(strings.Split(event.States, ", "), "read only") && + !slices.Contains(strings.Split(event.States, ", "), "password") +} + +// Raw key names need current desktop ownership as well as Wine's last control. +// Text insertion feedback already passes the helper's focused-editor checks. +func (owner *wineEventPresenter) EditFieldFocused() bool { + owner.mu.Lock() + defer owner.mu.Unlock() + if owner.closed || !owner.editable || owner.protected || owner.focused == "" || + owner.foreground == nil || owner.titles == nil { + return false + } + foreground, title := owner.foreground.Foreground(), owner.titles.ForegroundTitle() + return foreground.Client.Known && foreground.Client.Window != 0 && + foreground.Epoch == owner.echoEpoch && title.Epoch == foreground.Epoch && + title.Title.Window == foreground.Client.Window && title.Title.Wine && title.Title.Focused && + equivalentWindowTitles(title.Title.Title, owner.echoTitle) +} diff --git a/internal/app/wine_echo_focus_test.go b/internal/app/wine_echo_focus_test.go new file mode 100644 index 0000000..a7954f1 --- /dev/null +++ b/internal/app/wine_echo_focus_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/remote" +) + +func TestWineEditFieldsOnlyTypingAndVoices(t *testing.T) { + settings := config.DefaultSettings() + settings.KeyEchoInEditFieldsOnly = true + settings.UseCustomEchoVoice = true + settings.UseCustomEchoForKey = true + settings.UseCustomEchoForCharacter = false + live := &liveSettings{} + live.Store(settings) + speech := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + presenter := remoteSpeech{speech: speech, braille: braille, live: live} + owner := newWineEventPresenter(func(string) bool { return true }) + defer owner.Close() + presenter.wine = owner + for _, tc := range []struct { + role, states, description string + want bool + }{ + {"edit", "", "", true}, {"read only edit", "", "", false}, {"edit", "read only", "", false}, + {"button", "", "", false}, {"text", "", "", false}, {"edit", "", "password", false}, + } { + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "control", EventType: "focus", Role: tc.role, States: tc.states, Description: tc.description}) + owner.flush() + before := len(speech.requests) + accepted := presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "control", EventType: "text", Name: "A", States: "kind=8"}) + if accepted != tc.want || (len(speech.requests) > before) != tc.want { + t.Fatalf("role=%s states=%s protected=%s accepted=%v", tc.role, tc.states, tc.description, accepted) + } + if tc.want && (!speech.requests[before].CustomEcho || !speech.requests[before].Character || braille.text[len(braille.text)-1] != "A") { + t.Fatal("Wine key voice or braille lost") + } + } + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "focus", Role: "edit"}) + owner.flush() + settings.EnableEchoByCharacter = true + live.Store(settings) + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "text", Name: "a", States: "kind=8"}) + if speech.requests[len(speech.requests)-1].CustomEcho { + t.Fatal("character routing used key-only custom voice") + } + // Moving focus to a menu invalidates the preceding editor's text actions. + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "menu", EventType: "focus", Role: "menu item"}) + owner.flush() + if presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "text", Name: "a", States: "kind=8"}) { + t.Fatal("old editor still echoes") + } +} + +func TestWineKeyEchoRequiresCurrentDesktopFocus(t *testing.T) { + _, _, monitor, _ := reviewFocusSetup(t) + owner := newWineEventPresenter(func(string) bool { return true }) + defer owner.Close() + owner.foreground, owner.titles = monitor, monitor + focus := remote.AccessibleEvent{SourceID: "edit", EventType: "focus", Role: "edit", WindowTitle: monitor.title.Title.Title} + owner.Present(focus) + owner.flush() + if !owner.EditFieldFocused() { + t.Fatal("current Wine editor rejected") + } + monitor.state.Epoch++ + if owner.EditFieldFocused() { + t.Fatal("stale Wine editor survived foreground switch") + } + monitor.title.Epoch = monitor.state.Epoch + owner.Present(focus) + owner.flush() + if !owner.EditFieldFocused() { + t.Fatal("new focus did not restore editor") + } + monitor.title.Title.Wine = false + if owner.EditFieldFocused() { + t.Fatal("native window reused Wine focus") + } + monitor.title.Title.Wine = true + focus.Role = "menu item" + owner.Present(focus) + owner.flush() + if owner.EditFieldFocused() { + t.Fatal("menu retained editor echo") + } + owner.Close() + if owner.EditFieldFocused() { + t.Fatal("closed helper retained focus") + } +} diff --git a/internal/app/wine_events.go b/internal/app/wine_events.go index 9fb3140..fbbb446 100644 --- a/internal/app/wine_events.go +++ b/internal/app/wine_events.go @@ -29,6 +29,11 @@ type wineEventPresenter struct { pending *winePresentation focused string protected bool + editable bool + echoEpoch uint64 + echoTitle string + foreground foregroundMonitor + titles windowTitleSource window string lastKey string lastTime time.Time @@ -58,7 +63,7 @@ func wineTypingEchoEnabled(text string, settings config.Settings) bool { if utf8.RuneCountInString(text) != 1 { return false } - event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: text} + event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: text, Source: atspi.Snapshot{Role: "text", Editable: true}} return presentation.EventTextWithOptions(event, presentationSettingsFromConfig(settings, false)) != "" } @@ -75,6 +80,11 @@ func (owner *wineEventPresenter) present_event(event remote.AccessibleEvent, edi } if kind == "focus" { owner.focused = event.SourceID + owner.editable = wineEchoEditField(event) + owner.echoTitle = event.WindowTitle + if owner.foreground != nil { + owner.echoEpoch = owner.foreground.Foreground().Epoch + } owner.protected = event.Description == "password" || slices.Contains(strings.Split(event.States, ", "), "password") } else if kind != "value" && kind != "selection" && kind != "text" && kind != "name" && kind != "state" { return false @@ -88,6 +98,11 @@ func (owner *wineEventPresenter) present_event(event remote.AccessibleEvent, edi } } if kind == "state" { + if event.Role != "" { + owner.editable = wineEchoEditField(event) + } else if slices.Contains(strings.Split(event.States, ", "), "read only") { + owner.editable = false + } owner.protected = slices.Contains(strings.Split(event.States, ", "), "password") || event.Description == "password" } if owner.protected && event.SourceID == owner.focused { @@ -107,6 +122,11 @@ func (owner *wineEventPresenter) present_event(event remote.AccessibleEvent, edi // Retain action metadata so character output uses the reader's // uppercase and custom typing voices after focus/password filtering. if editor != nil { + if owner.editable { + event.Role = "edit" + } else { + event.Role = "" + } return editor(event) } return owner.present(event.Name) diff --git a/internal/app/wine_events_native_test.go b/internal/app/wine_events_native_test.go index d710795..2271057 100644 --- a/internal/app/wine_events_native_test.go +++ b/internal/app/wine_events_native_test.go @@ -34,6 +34,7 @@ func TestNativeSessionBusWineEvents(t *testing.T) { } output := &wineTestOutput{speech: make(chan speechRequest, 32), braille: make(chan string, 32)} settings := config.DefaultSettings() + settings.KeyEchoInEditFieldsOnly = true live := &liveSettings{} live.Store(settings) handler := remoteSpeech{speech: output, braille: output, live: live} diff --git a/internal/app/wine_events_test.go b/internal/app/wine_events_test.go index 5b4a22a..4ada535 100644 --- a/internal/app/wine_events_test.go +++ b/internal/app/wine_events_test.go @@ -276,6 +276,7 @@ func TestWineEditorCharacterSpeechRouting(t *testing.T) { t.Fatalf("braille changed: %v", braille.text) } } + settings.EnableEchoByCharacter = true settings.UseCustomEchoForCharacter = false live.Store(settings) presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "text", Name: "A", States: "kind=8"}) diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index a7b7e7b..8df0dd9 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -100,6 +100,7 @@ var eventTypes = []string{ "object:state-changed:invalid-entry", "object:state-changed:pressed", "object:state-changed:read-only", + "object:state-changed:editable", "object:state-changed:required", SelectedStateEvent, "object:state-changed:sensitive", @@ -112,6 +113,7 @@ type Adapter struct { webNotificationRoots map[ObjectID]bool // copied identities only; no native references foregroundReader *xorg.ForegroundReader // GLib thread only foreground atomic.Pointer[ForegroundState] + echoFocus atomic.Pointer[echoFocus] foregroundChanges chan struct{} foregroundTitle atomic.Pointer[ForegroundTitle] titleChanges chan struct{} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 605f8cc..d11811e 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -389,6 +389,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { nativeEventType := C.GoString(C.cthulhu_event_type(event)) eventType := canonicalTextChangeEventType(nativeEventType) originID := nativeObjectID(event.source) + adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID) if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 { adapter.retireLibreOfficeHeaders(event.source, eventType, originID) } diff --git a/internal/atspi/adapter_lifecycle_native.go b/internal/atspi/adapter_lifecycle_native.go index e3300fb..71d866a 100644 --- a/internal/atspi/adapter_lifecycle_native.go +++ b/internal/atspi/adapter_lifecycle_native.go @@ -25,6 +25,7 @@ func (adapter *Adapter) Start(ctx context.Context) error { return errors.New("AT-SPI adapter already started") } + adapter.echoFocus.Store(nil) adapter.loopDone = make(chan struct{}) setupDone := make(chan error, 1) go func() { @@ -56,6 +57,7 @@ func (adapter *Adapter) Start(ctx context.Context) error { C.g_object_unref(C.gpointer(focus.source)) } adapter.nativeFocus = nil + adapter.echoFocus.Store(nil) adapter.libreOfficeHeaders.reset() for _, known := range adapter.documentWindows { C.g_object_unref(C.gpointer(known.source)) diff --git a/internal/atspi/adapter_secure_native.go b/internal/atspi/adapter_secure_native.go index 306b88f..2cdb332 100644 --- a/internal/atspi/adapter_secure_native.go +++ b/internal/atspi/adapter_secure_native.go @@ -24,6 +24,7 @@ func (adapter *Adapter) SetSecureInputActive(active bool) { adapter.secureCancel() } adapter.secure.Store(active) + adapter.echoFocus.Store(nil) adapter.secureEpoch.Add(1) adapter.secureReset.Store(true) if !active { @@ -85,6 +86,11 @@ func (adapter *Adapter) enqueueSecureEvent(event Event, epoch uint64) { if adapter.secure.Load() || epoch != adapter.secureEpoch.Load() { return } + adapter.observeEchoFocus(event) + // Editable transitions refresh key echo without adding a spoken state. + if event.Type == "object:state-changed:editable" { + return + } select { case adapter.events <- event: default: diff --git a/internal/atspi/echo_focus.go b/internal/atspi/echo_focus.go new file mode 100644 index 0000000..cc27fcb --- /dev/null +++ b/internal/atspi/echo_focus.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +// IsEchoEditField requires positive editable metadata. Text roles alone also +// describe labels and read-only documents; terminals are not edit fields. +func IsEchoEditField(snapshot Snapshot) bool { + return snapshot.Editable && !snapshot.ReadOnly && !snapshot.Defunct && + snapshot.Role != "password text" && snapshot.Role != "terminal" +} + +type echoFocus struct { + object, origin, window ObjectID + epoch uint64 + editable bool +} + +// EditFieldFocused reads copied metadata only. The GLib owner publishes it; +// foreground epochs reject stale focus even before queued events are consumed. +func (adapter *Adapter) EditFieldFocused() bool { + focus := adapter.echoFocus.Load() + foreground := adapter.Foreground() + return !adapter.secure.Load() && focus != nil && focus.editable && + focus.epoch == foreground.Epoch && (!foreground.Client.Known || foreground.Client.Window != 0) +} + +func (adapter *Adapter) observeEchoFocus(event Event) { + if event.ForegroundMismatch || event.Foreground.Epoch != adapter.Foreground().Epoch { + return + } + current := adapter.echoFocus.Load() + if event.Type == WindowActivatedEvent && !event.Source.Focused { + if current != nil && event.Window != current.window { + adapter.echoFocus.Store(nil) + } + return + } + gain := event.Type == FocusEvent || event.Type == FocusedStateEvent && event.Detail1 != 0 || + event.Type == WindowActivatedEvent && event.Source.Focused + if !gain && (current == nil || current.object != event.Source.ID()) { + return + } + if !event.Source.ID().Valid() { + return + } + origin := NewObjectID(event.Source.BusName, event.OriginPath) + if !gain && current != nil { + origin = current.origin + } + adapter.echoFocus.Store(&echoFocus{object: event.Source.ID(), origin: origin, + window: event.Window, epoch: event.Foreground.Epoch, + editable: IsEchoEditField(event.Source) && event.Source.Focused}) +} + +// Retire before filtering focus losses or dead objects out of speech delivery. +func (adapter *Adapter) retireEchoFocus(kind string, detail int, source ObjectID) { + current := adapter.echoFocus.Load() + if current == nil { + return + } + matched := source == current.object || source == current.origin || source == current.window + if (kind == FocusedStateEvent && detail == 0 || kind == DefunctStateEvent && detail != 0 || + kind == WindowDestroyedEvent || kind == WindowDeactivatedEvent) && matched || + (kind == FocusEvent || kind == FocusedStateEvent && detail != 0) && source.BusName == current.object.BusName { + adapter.echoFocus.Store(nil) + } +} diff --git a/internal/atspi/echo_focus_native_test.go b/internal/atspi/echo_focus_native_test.go new file mode 100644 index 0000000..d4c8ac4 --- /dev/null +++ b/internal/atspi/echo_focus_native_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "os" + "testing" + "time" +) + +func TestNativeEchoEditFieldFocus(t *testing.T) { + if os.Getenv("CTHULHU_SPEECH_METADATA") != "1" { + t.Skip("requires private GTK speech fixture") + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + stop, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := adapter.Stop(stop); err != nil { + t.Error(err) + } + }() + tree, err := adapter.CaptureDesktop(ctx) + if err != nil { + t.Fatal(err) + } + for _, probe := range []struct { + key, name string + want bool + }{ + {"F9", "Mnemonic probe", false}, + {"F10", "Indentation probe", true}, + {"F9", "Mnemonic probe", false}, + {"F11", "Color attribute probe", true}, + {"F7", "Protected text probe", false}, + {"shift+F10", "Indentation probe", false}, + {"F10", "Indentation probe", true}, + } { + reference := findNamedReference(t, tree, probe.name) + snapshot, err := tree.Resolve(reference) + if err != nil { + t.Fatal(err) + } + pressTreeProbeKey(t, probe.key) + deadline := time.NewTimer(time.Second) + tick := time.NewTicker(5 * time.Millisecond) + matched := false + for !matched { + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-deadline.C: + t.Fatalf("%s: cached editable=%v want=%v", probe.key, adapter.EditFieldFocused(), probe.want) + case <-adapter.Events(): + case <-tick.C: + } + focus := adapter.echoFocus.Load() + // A false result must describe this control, not a focus-event gap. + matched = focus != nil && focus.object == snapshot.ID() && adapter.EditFieldFocused() == probe.want + } + deadline.Stop() + tick.Stop() + } +} diff --git a/internal/atspi/echo_focus_test.go b/internal/atspi/echo_focus_test.go new file mode 100644 index 0000000..acd3db2 --- /dev/null +++ b/internal/atspi/echo_focus_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import "testing" + +func TestEchoEditFieldRequiresEditableMetadata(t *testing.T) { + for _, tc := range []struct { + name string + snapshot Snapshot + want bool + }{ + {"entry", Snapshot{Role: "text", Editable: true}, true}, + {"web contenteditable", Snapshot{Role: "section", Editable: true}, true}, + {"editable combo", Snapshot{Role: "combo box", Editable: true}, true}, + {"unknown text", Snapshot{Role: "text"}, false}, + {"read only", Snapshot{Role: "text", Editable: true, ReadOnly: true}, false}, + {"password", Snapshot{Role: "password text", Editable: true}, false}, + {"terminal", Snapshot{Role: "terminal", Editable: true}, false}, + {"defunct", Snapshot{Role: "text", Editable: true, Defunct: true}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := IsEchoEditField(tc.snapshot); got != tc.want { + t.Fatalf("editable=%v want=%v", got, tc.want) + } + }) + } +} + +func TestEchoFocusLifetime(t *testing.T) { + adapter := New() + source := Snapshot{BusName: ":1.5", Path: "/edit", Role: "text", Editable: true, Focused: true, StatesKnown: true} + event := Event{Type: FocusedStateEvent, Detail1: 1, Source: source, Window: NewObjectID(source.BusName, "/window"), OriginPath: "/child"} + if adapter.EditFieldFocused() { + t.Fatal("unknown focus allowed") + } + gain := func() { + adapter.observeEchoFocus(event) + if !adapter.EditFieldFocused() { + t.Fatal("focused editor rejected") + } + } + gain() + // Background updates and losses do not replace the current editor. + adapter.retireEchoFocus(FocusedStateEvent, 0, NewObjectID(":1.6", "/edit")) + other := event + other.Source.Path = "/other" + other.ForegroundMismatch = true + adapter.observeEchoFocus(other) + if !adapter.EditFieldFocused() { + t.Fatal("unrelated event retired editor") + } + for _, tc := range []struct { + kind string + detail int + id ObjectID + }{ + {FocusedStateEvent, 0, source.ID()}, {FocusedStateEvent, 0, NewObjectID(source.BusName, "/child")}, + {DefunctStateEvent, 1, source.ID()}, {WindowDestroyedEvent, 0, event.Window}, {WindowDeactivatedEvent, 0, event.Window}, + {FocusedStateEvent, 1, NewObjectID(source.BusName, "/button")}, + } { + gain() + adapter.retireEchoFocus(tc.kind, tc.detail, tc.id) + if adapter.EditFieldFocused() { + t.Fatalf("stale focus after %s", tc.kind) + } + } + gain() + readonly := event + readonly.Type = "object:state-changed:read-only" + readonly.Source.ReadOnly = true + adapter.observeEchoFocus(readonly) + if adapter.EditFieldFocused() { + t.Fatal("read-only change retained echo") + } + gain() + adapter.foreground.Store(&ForegroundState{Epoch: 1}) + if adapter.EditFieldFocused() { + t.Fatal("foreground change retained echo") + } + adapter.observeEchoFocus(event) + if adapter.EditFieldFocused() { + t.Fatal("old event restored echo") + } + adapter.foreground.Store(nil) + gain() + unknownWindow := event + unknownWindow.Type = WindowActivatedEvent + unknownWindow.Window = NewObjectID(":1.6", "/window") + unknownWindow.Source = Snapshot{BusName: ":1.6", Path: "/window", Role: "frame"} + adapter.observeEchoFocus(unknownWindow) + if adapter.EditFieldFocused() { + t.Fatal("unknown window retained previous editor without X11 metadata") + } + gain() + adapter.SetSecureInputActive(true) + if adapter.EditFieldFocused() { + t.Fatal("lock retained echo") + } + adapter.SetSecureInputActive(false) + if adapter.EditFieldFocused() { + t.Fatal("unlock restored stale echo") + } +} diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go index da7f4d4..84460c5 100644 --- a/internal/config/profile_mapping.go +++ b/internal/config/profile_mapping.go @@ -149,7 +149,8 @@ func profileFromSettings(settings Settings) writableProfile { UseCustomEchoForSentence: boolPointer(settings.UseCustomEchoForSentence), EchoVoice: voicePointer(settings.EchoVoice), EchoSpeechServer: stringPointer(settings.EchoSpeechServer), - Enabled: boolPointer(settings.EnableKeyEcho), ByCharacter: boolPointer(settings.EnableEchoByCharacter), + EditFieldsOnly: boolPointer(settings.KeyEchoInEditFieldsOnly), + Enabled: boolPointer(settings.EnableKeyEcho), ByCharacter: boolPointer(settings.EnableEchoByCharacter), ByWord: boolPointer(settings.EnableEchoByWord), BySentence: boolPointer(settings.EnableEchoBySentence), Alphabetic: boolPointer(settings.EnableAlphabeticKeys), Numeric: boolPointer(settings.EnableNumericKeys), Punctuation: boolPointer(settings.EnablePunctuationKeys), Space: boolPointer(settings.EnableSpace), @@ -504,6 +505,9 @@ func apply_profile_echo_keys(settings *Settings, profile writableProfile) { if value := profile.KeyEcho.Enabled; value != nil { settings.EnableKeyEcho = *value } + if value := profile.KeyEcho.EditFieldsOnly; value != nil { + settings.KeyEchoInEditFieldsOnly = *value + } if value := profile.KeyEcho.ByCharacter; value != nil { settings.EnableEchoByCharacter = *value } diff --git a/internal/config/schema.go b/internal/config/schema.go index 1211d4e..8306771 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -181,6 +181,7 @@ type writableKeyEcho struct { EchoSpeechServer *string `toml:"speech-server,omitempty"` Enabled *bool `toml:"enabled,omitempty"` + EditFieldsOnly *bool `toml:"edit-fields-only,omitempty"` ByCharacter *bool `toml:"by-character,omitempty"` ByWord *bool `toml:"by-word,omitempty"` BySentence *bool `toml:"by-sentence,omitempty"` diff --git a/internal/config/settings.go b/internal/config/settings.go index fb7c19d..7c37100 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -150,6 +150,7 @@ type Settings struct { KeyboardLayout int CthulhuModifierKeys []string EnableKeyEcho bool + KeyEchoInEditFieldsOnly bool EnableEchoByCharacter bool EnableEchoByWord bool EnableEchoBySentence bool diff --git a/internal/preferences/echo.go b/internal/preferences/echo.go index b12c37a..55fd99c 100644 --- a/internal/preferences/echo.go +++ b/internal/preferences/echo.go @@ -26,7 +26,6 @@ func echoChecks(values *Values) []struct { id string value *bool }{ - {"keyEchoCheckButton", &values.EnableKeyEcho}, {"enableAlphabeticKeysCheckButton", &values.EnableAlphabeticKeys}, {"enableNumericKeysCheckButton", &values.EnableNumericKeys}, {"enablePunctuationKeysCheckButton", &values.EnablePunctuationKeys}, @@ -160,12 +159,17 @@ func (session *uiSession) getEchoVoice() (config.Voice, string) { } func set_echo_checks(values Values) { + setCheck("keyEchoDisabledRadio", !values.EnableKeyEcho) + setCheck("keyEchoEnabledRadio", values.EnableKeyEcho && !values.KeyEchoInEditFieldsOnly) + setCheck("keyEchoEditFieldsRadio", values.EnableKeyEcho && values.KeyEchoInEditFieldsOnly) for _, check := range echoChecks(&values) { setCheck(check.id, *check.value) } } func get_echo_checks(values *Values) { + values.EnableKeyEcho = !getCheck("keyEchoDisabledRadio") + values.KeyEchoInEditFieldsOnly = getCheck("keyEchoEditFieldsRadio") for _, check := range echoChecks(values) { *check.value = getCheck(check.id) } diff --git a/internal/preferences/native.c b/internal/preferences/native.c index 8a99d1e..f08ca4c 100644 --- a/internal/preferences/native.c +++ b/internal/preferences/native.c @@ -92,7 +92,7 @@ static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gp } static void echo_sensitivity(GtkToggleButton *button, gpointer data) { (void)button; (void)data; - int enabled = prefs_check_get("keyEchoCheckButton"); + int enabled = !prefs_check_get("keyEchoDisabledRadio"); const char *categories[] = {"enableAlphabeticKeysCheckButton", "enableNumericKeysCheckButton", "enablePunctuationKeysCheckButton", "enableSpaceCheckButton", "enableModifierKeysCheckButton", "enableFunctionKeysCheckButton", "enableActionKeysCheckButton", "enableNavigationKeysCheckButton", @@ -231,7 +231,7 @@ int prefs_init(const char *xml) { }; set_focus_chain("keyboard_layout_controls", keyboard_focus, G_N_ELEMENTS(keyboard_focus)); - const char *echo_toggles[] = {"keyEchoCheckButton", "useCustomEchoVoiceCheckButton", "useCustomEchoSpeechServerCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"}; + const char *echo_toggles[] = {"keyEchoDisabledRadio", "keyEchoEnabledRadio", "keyEchoEditFieldsRadio", "useCustomEchoVoiceCheckButton", "useCustomEchoSpeechServerCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"}; for (unsigned i = 0; i < G_N_ELEMENTS(echo_toggles); i++) g_signal_connect(prefs_widget(echo_toggles[i]), "toggled", G_CALLBACK(echo_sensitivity), NULL); echo_sensitivity(NULL, NULL); g_signal_connect(prefs_widget("enableSoundCheckButton"), "toggled", G_CALLBACK(sound_sensitivity), NULL); diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui index cb53dac..fb56817 100644 --- a/internal/preferences/preferences.ui +++ b/internal/preferences/preferences.ui @@ -196,7 +196,9 @@ Braille neververtical127 - Enable key echoTrue + Disable key echoTrue + Enable key echoTruekeyEchoDisabledRadio + Key echo in edit fieldsTruekeyEchoDisabledRadio Enable alphabetic keysTrue Enable numeric keysTrue Enable punctuation keysTrue diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go index e9ca15d..d8861d9 100644 --- a/internal/preferences/protocol.go +++ b/internal/preferences/protocol.go @@ -41,6 +41,7 @@ type Values struct { SpatializeObjectSounds bool RoleSoundPresentation string EnableKeyEcho bool + KeyEchoInEditFieldsOnly bool EnableAlphabeticKeys bool EnableNumericKeys bool EnablePunctuationKeys bool diff --git a/internal/preferences/values.go b/internal/preferences/values.go index 10839d8..e5ace3a 100644 --- a/internal/preferences/values.go +++ b/internal/preferences/values.go @@ -42,6 +42,7 @@ func FromSettings(settings config.Settings) Values { Reading: settings.Reading, SpeakDescription: settings.SpeakDescription, EnableKeyEcho: settings.EnableKeyEcho, + KeyEchoInEditFieldsOnly: settings.KeyEchoInEditFieldsOnly, EnableAlphabeticKeys: settings.EnableAlphabeticKeys, EnableNumericKeys: settings.EnableNumericKeys, EnablePunctuationKeys: settings.EnablePunctuationKeys, @@ -92,6 +93,7 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) { settings.ProgressBar = values.ProgressBar settings.RoleSoundPresentation = values.RoleSoundPresentation settings.EnableKeyEcho = values.EnableKeyEcho + settings.KeyEchoInEditFieldsOnly = values.KeyEchoInEditFieldsOnly settings.EnableAlphabeticKeys = values.EnableAlphabeticKeys settings.EnableNumericKeys = values.EnableNumericKeys settings.EnablePunctuationKeys = values.EnablePunctuationKeys diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index bb65381..d85dd99 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -28,27 +28,28 @@ import ( ) type Options struct { - Reading *config.ReadingSettings - EnableSound bool - PlaySoundForRole bool - PlaySoundForState bool - RoleSoundPresentation string - SpatializeObjectSounds bool - SoundCueAvailable func(string) bool - SpeakDescription bool - ReadFullRowInGUITable bool - SpeechVerbosityLevel int - SpeakBlankLines bool - SayAllStyle string - EnableKeyEcho bool - EnableEchoByCharacter bool - EnableEchoByWord bool - EnableEchoBySentence bool - EnableAlphabeticKeys bool - EnableNumericKeys bool - EnablePunctuationKeys bool - EnableSpace bool - EnableDiacriticalKeys bool + Reading *config.ReadingSettings + EnableSound bool + PlaySoundForRole bool + PlaySoundForState bool + RoleSoundPresentation string + SpatializeObjectSounds bool + SoundCueAvailable func(string) bool + SpeakDescription bool + ReadFullRowInGUITable bool + SpeechVerbosityLevel int + SpeakBlankLines bool + SayAllStyle string + EnableKeyEcho bool + KeyEchoInEditFieldsOnly bool + EnableEchoByCharacter bool + EnableEchoByWord bool + EnableEchoBySentence bool + EnableAlphabeticKeys bool + EnableNumericKeys bool + EnablePunctuationKeys bool + EnableSpace bool + EnableDiacriticalKeys bool PlaySoundForPositionInSet bool } @@ -249,7 +250,7 @@ func EventSpeechWithOptions(event atspi.Event, options Options) Speech { return Speech{Text: event.Source.Text} } case atspi.TextInsertedEvent: - if !shouldEchoInsertion(event.Payload, options) { + if !shouldEchoInsertion(event.Payload, insertionEchoOptions(event, options)) { return Speech{} } if event.Payload == " " { diff --git a/internal/presentation/typing_echo.go b/internal/presentation/typing_echo.go index 991fd3d..8b814ea 100644 --- a/internal/presentation/typing_echo.go +++ b/internal/presentation/typing_echo.go @@ -31,6 +31,7 @@ func TypingEchoText(event atspi.Event, previousText string, options Options) str } func TypingEcho(event atspi.Event, previousText string, options Options) (string, string) { + options = insertionEchoOptions(event, options) kind := "" if event.Type == atspi.TextInsertedEvent && utf8.RuneCountInString(event.Payload) == 1 { kind = "key" @@ -112,6 +113,14 @@ func firstRune(value string) rune { return r } +// Edit-fields-only changes typing echo, not pasted text or application output. +func insertionEchoOptions(event atspi.Event, options Options) Options { + if options.KeyEchoInEditFieldsOnly && !atspi.IsEchoEditField(event.Source) { + options.EnableKeyEcho = false + } + return options +} + func shouldEchoInsertion(text string, options Options) bool { if text == "" { return false diff --git a/internal/presentation/typing_echo_test.go b/internal/presentation/typing_echo_test.go index 9b60cc6..2445333 100644 --- a/internal/presentation/typing_echo_test.go +++ b/internal/presentation/typing_echo_test.go @@ -123,3 +123,44 @@ func TestTypingEchoTextAppliesWordAndSentenceSettings(t *testing.T) { t.Fatalf("disabled key echo sentence = %q", got) } } + +func TestTypingEchoInEditFieldsOnly(t *testing.T) { + options := DefaultOptions() + options.KeyEchoInEditFieldsOnly = true + options.EnableEchoByWord = true + options.EnableEchoBySentence = true + for _, tc := range []struct { + name string + source atspi.Snapshot + want bool + }{ + {"entry", atspi.Snapshot{Role: "text", Editable: true}, true}, + {"contenteditable", atspi.Snapshot{Role: "section", Editable: true}, true}, + {"readonly", atspi.Snapshot{Role: "text", Editable: true, ReadOnly: true}, false}, + {"terminal", atspi.Snapshot{Role: "terminal"}, false}, + {"unknown", atspi.Snapshot{Role: "text"}, false}, + {"password", atspi.Snapshot{Role: "password text", Editable: true}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, payload := range []string{"a", " "} { + event := atspi.Event{Type: atspi.TextInsertedEvent, Source: tc.source, Payload: payload} + event.Source.TextBeforeCaret = "Hello. " + text, _ := TypingEcho(event, "", options) + if (text != "") != tc.want { + t.Fatalf("payload=%q echo=%q wanted enabled=%v", payload, text, tc.want) + } + } + if tc.source.Role != "password text" { + event := atspi.Event{Type: atspi.TextInsertedEvent, Source: tc.source, Payload: "pasted or application text"} + if got := TypingEchoText(event, "", options); got != event.Payload { + t.Fatalf("ordinary text lost: %q", got) + } + event.Type = atspi.TextDeletedEvent + event.Payload = "a" + if got := EventTextWithOptions(event, options); got != "a" { + t.Fatalf("deletion lost: %q", got) + } + } + }) + } +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 9c68c73..806be88 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -21,7 +21,7 @@ web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation a 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,in-progress,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20.,,Physical Xorg speech and sound listening pending.,,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. 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. config-speech-preview,src/cthulhu/speechdispatcherfactory.py,speech,Apply supported active-profile speech settings,Storm Dragon,internal/config internal/speech internal/app,in-progress,go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1,,,Pending human listening gate,,Applies enablement description punctuation capitalization and default voice settings. -typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,in-progress,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer heard the configured printable echo behavior in minimal tests on 2026-09-12.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. +typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,in-progress,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer heard the configured printable echo behavior in minimal tests on 2026-09-12.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. Preferences offer disabled enabled and edit-fields-only radio choices; edit-fields-only uses native editable metadata and Wine editor focus while preserving configured echo voices. Automated focus and persistence checks pass; live speech and physical Braille acceptance for the new mode remain pending. typing-nonprintable-boundary-echo,src/cthulhu/typing_echo_presenter.py,input,Apply non-printable key and word or sentence echo settings,Storm Dragon,internal/config internal/input internal/platform/xorg internal/atspi internal/presentation internal/app,in-progress,go test ./internal/config ./internal/input ./internal/platform/xorg ./internal/atspi ./internal/presentation ./internal/app -count=1,Pending physical Xorg category chord and pass-through matrix,,Pending human listening for modifier function action navigation dead-key word and sentence echo,,XRecord copies stable non-printable names from the active keymap and suppresses configured Cthulhu modifier chords. Word and sentence echo use bounded text before the caret and retain the prior line across a newline. Printable delivery remains AT-SPI based to avoid duplicate XRecord speech. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. keyboard-speech-interrupt,src/cthulhu/input_event.py,input,Interrupt speech on non-consuming Control or Shift press,Storm Dragon,internal/input internal/platform/xorg internal/app,in-progress,go test ./internal/input ./internal/platform/xorg ./internal/app -count=1,Maintainer accepted Control and Shift interruption without stolen or stuck keys in the Stage 1 physical Xorg checklist on 2026-09-11.,,Speech interruption was audible during the accepted physical Xorg checklist.,,XRecord observation and speech-worker wiring are implemented; broader X11 matrix remains pending. where-am-i-basic,src/cthulhu/where_am_i_presenter.py,command,Repeat basic information about the current focused accessible,Storm Dragon,internal/input internal/platform/xorg internal/app internal/presentation,in-progress,go test ./internal/input ./internal/platform/xorg ./internal/app -count=1; python3 tests/native_x11.py,Private X11 proof covers unmodified desktop KP_Enter consumption and balanced replay of desktop Cthulhu+Return; maintainer accepted the corrected physical desktop binding on Xorg on 2026-09-13,,Maintainer confirmed both the basic spoken result and corrected desktop binding on 2026-09-13,,Uses the latest accepted immutable focus or managed-descendant snapshot and the Python no-location message. keyboardLayout selects desktop KP_Enter or laptop Cthulhu+Return. Detailed context repeated-press behavior and selection details remain pending. diff --git a/tests/gtk_tree.py b/tests/gtk_tree.py index caeb949..33046b2 100644 --- a/tests/gtk_tree.py +++ b/tests/gtk_tree.py @@ -88,6 +88,7 @@ def on_key_press(_window, event): mnemonic_button.grab_focus() return True if event.keyval == Gdk.KEY_F10: + indentation_entry.set_editable(not bool(event.state & Gdk.ModifierType.SHIFT_MASK)) indentation_entry.grab_focus() return True if event.keyval == Gdk.KEY_F11: diff --git a/tests/native_atspi.py b/tests/native_atspi.py index 92edb93..e81f8cd 100644 --- a/tests/native_atspi.py +++ b/tests/native_atspi.py @@ -24,6 +24,7 @@ def run_inside(): return if os.environ.get("CTHULHU_SPEECH_METADATA") == "1": run_probe("list") + run_probe("list", echo_focus=True) return # Keep the original ListBox defunct proof; GtkBox also forwards additions. for container in ("list", "box"): @@ -104,7 +105,7 @@ def run_table_probe(multi: bool = False, screen_lock: bool = False) -> None: fixture.wait(timeout=3) -def run_probe(container, flat=False, terminal=False, terminal_mode=""): +def run_probe(container, flat=False, terminal=False, terminal_mode="", echo_focus=False): environment = dict(os.environ, CTHULHU_TREE_CONTAINER=container) environment["CTHULHU_VTE_MODE"] = terminal_mode fixture = subprocess.Popen([ @@ -137,6 +138,7 @@ def run_probe(container, flat=False, terminal=False, terminal_mode=""): "./internal/review" if flat else "./internal/atspi", "-run", "^TestNativeMouseReviewPointerAndHitTest$" if os.environ.get("CTHULHU_MOUSE_REVIEW") == "1" else + "^TestNativeEchoEditFieldFocus$" if echo_focus else "^TestNativeSpeechMetadata$" if os.environ.get("CTHULHU_SPEECH_METADATA") == "1" else "^TestNativeTerminalFlatReview$" if terminal else "^TestNativeFlatReview$" if flat else "^TestNativeTreeCaptureDefunctAndShutdownInvalidation$", "-count=1", diff --git a/tests/native_preferences.py b/tests/native_preferences.py index 1eaf49e..f56be76 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -1051,10 +1051,14 @@ def inside(): page + " page did not open") # Verify keyboard access, master sensitivity, and echo protocol values. master = named("Enable key echo") + disabled = named("Disable key echo") + edit_fields = named("Key echo in edit fields") + for radio in (disabled, master, edit_fields): + assert radio.getRole() == pyatspi.ROLE_RADIO_BUTTON, radio.name alphabetic = named("Enable alphabetic keys") assert master.getState().contains(pyatspi.STATE_CHECKED) assert alphabetic.getState().contains(pyatspi.STATE_CHECKED) - focus(master); key("space") + focus(disabled); key("space") wait_for(lambda: not alphabetic.getState().contains(pyatspi.STATE_SENSITIVE), "master did not disable categories") focus(named("Apply")); key("space") off = request() @@ -1067,6 +1071,22 @@ def inside(): wait_for(lambda: master.getState().contains(pyatspi.STATE_SENSITIVE), "key echo master stayed insensitive") focus(master); key("space") wait_for(lambda: master.getState().contains(pyatspi.STATE_CHECKED), "key echo master did not re-enable") + # The three choices are one keyboard radio group. Tab exits the group, + # Shift+Tab returns to the selected radio, and Apply reloads the mode. + key("Down") + wait_for(lambda: edit_fields.getState().contains(pyatspi.STATE_CHECKED), "Down did not select edit-fields-only") + assert not master.getState().contains(pyatspi.STATE_CHECKED) + key("Tab") + wait_for(lambda: alphabetic.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not leave echo radio group") + key("shift+Tab") + wait_for(lambda: edit_fields.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not return to selected radio") + focus(named("Apply")); key("space") + edit_only = request() + assert edit_only["Values"]["EnableKeyEcho"] and edit_only["Values"]["KeyEchoInEditFieldsOnly"], edit_only + send(dict(initial, Values=edit_only["Values"], Message="Edit fields echo saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "edit-fields Apply stayed busy") + assert edit_fields.getState().contains(pyatspi.STATE_CHECKED) + assert alphabetic.getState().contains(pyatspi.STATE_SENSITIVE) focus(named("Use custom echo voice")); key("space") echo_rate = named("Key echo rate") wait_for(lambda: echo_rate.getState().contains(pyatspi.STATE_SENSITIVE), "custom voice controls disabled") @@ -1088,7 +1108,7 @@ def inside(): ev = echo_saved["Values"] assert ev["ProgressBar"] == expectedProgress, ev assert echo_saved["Save"] and echo_saved["Close"] and ev["EnableKeyEcho"], echo_saved - assert ev["UseCustomEchoVoice"] and ev["UseCustomEchoForWord"], ev + assert ev["UseCustomEchoVoice"] and ev["UseCustomEchoForWord"] and ev["KeyEchoInEditFieldsOnly"], ev assert ev["EchoVoice"]["Rate"] == 83 and ev["EchoVoice"]["AveragePitch"] == 7.5 and ev["EchoVoice"]["Gain"] == 10, ev assert ev["EchoVoice"]["Family"]["Name"] == "Test voice", ev send(dict(initial, Values=ev, Message="Echo voice saved."))