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 @@