From 15d527c6196f194f9857697620e193823150eb52 Mon Sep 17 00:00:00 2001 From: deedra waters Date: Wed, 14 Oct 2026 22:32:37 -0700 Subject: [PATCH] Respect Cthulhu key echo settings for Wine typing Wine editor insertion events previously reached speech and Braille without checking the user's typing echo preferences. Turning off key echo or an individual character category therefore left Wine typing feedback enabled. Read the live settings for each insertion and reuse the native text presentation policy for alphabetic, numeric, punctuation, symbol, space and diacritical input. Honor character echo and the master key echo switch; apply the action-key setting to the helper's tab and new-line labels. Reject unexpected multi-character insertion labels rather than treating these ordered character events as paste notifications. Preserve navigation, selection and deletion feedback when typing echo is disabled. Retain repeated characters, case, uppercase/custom voice routing, focus ordering and the existing stale-source/password protections. Add regression coverage for live disable/re-enable, independent categories, Unicode, character-echo overrides, and unaffected editor actions. Extend the private D-Bus test to exercise settings changes through the real Wine service signature. Document behavior, current word/sentence echo limits, and live Notepad acceptance checks in the README. Validation: - Reproduced disabled typing reaching output with the new test before fixing. - Full go test -race ./... -count=1 passed outside the socket-restricted sandbox. - Private-session TestNativeSessionBusWineEvents passed with -race. - Focused typing/editor regressions passed, including a fresh pre-commit run. - go vet passed for internal/app, presentation, remote and wine. - Reader and BrlAPI helper builds passed; reader --version succeeded. - gofmt and git diff --check passed; independent review found no blockers. Live audible speech, physical keyboard and physical Braille acceptance remain pending. No running reader was replaced. Changes target deedra. --- README.md | 16 +++ internal/app/remote.go | 10 +- internal/app/wine_events.go | 22 ++++ internal/app/wine_events_native_test.go | 21 +++- .../app/wine_typing_echo_settings_test.go | 117 ++++++++++++++++++ 5 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 internal/app/wine_typing_echo_settings_test.go diff --git a/README.md b/README.md index b16803e..08859af 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,19 @@ location follows the new user location, the old `/usr` location follows the new system locations, and the old name on `PATH` follows the new name. Restart Cthulhu after changing the selected helper. Keep the launcher, its `.exe.so` sidecar, and the native hooks together in the installed directory. + +Wine typing feedback in standard Edit controls follows the current key echo +settings, including the master switch, character echo, and alphabetic, numeric, +punctuation, space and diacritical categories. Tabs and line breaks follow +key echo's action-key setting. Applying preferences takes effect without +restarting the helper. Navigation, selection and deletion feedback remain +available when typing echo is disabled. Word and sentence typing echo are not +currently supplied by the Wine helper. + +For a live check, type repeated letters, numbers, punctuation and spaces in +Wine Notepad, change the corresponding echo settings and apply them, then +continue typing. Disable key echo and verify that typing stays quiet while +arrows, selection and Backspace still report editing feedback. Re-enable it +and check capital/lowercase voices and password exclusion. Automated tests +cover queued speech and Braille; audible speech and a physical Braille display +still require live verification. diff --git a/internal/app/remote.go b/internal/app/remote.go index 26819cd..b9cfc6b 100644 --- a/internal/app/remote.go +++ b/internal/app/remote.go @@ -22,6 +22,7 @@ import ( "strings" "unicode/utf8" + "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/remote" "git.stormux.org/storm/cthulhu/internal/wine" "github.com/godbus/dbus/v5" @@ -95,10 +96,17 @@ func (presenter remoteSpeech) presentWineEditor(event remote.AccessibleEvent) bo // character identity for navigation and Backspace as well as typing. kinds := strings.Fields(event.States) inserted := slices.Contains(kinds, "kind=8") + settings := config.DefaultSettings() + if presenter.live != nil { + settings = presenter.live.Load() + } + if inserted && !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(presenter.live.Load(), "character") + custom = customEcho(settings, "character") } return presenter.presentRequest(speechRequest{ Text: event.Name, Role: "system", Cause: "wine-accessible-event", Append: true, diff --git a/internal/app/wine_events.go b/internal/app/wine_events.go index f2bf370..9fb3140 100644 --- a/internal/app/wine_events.go +++ b/internal/app/wine_events.go @@ -7,7 +7,11 @@ import ( "strings" "sync" "time" + "unicode/utf8" + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/remote" ) @@ -40,6 +44,24 @@ func newWineEventPresenter(present func(string) bool) *wineEventPresenter { return &wineEventPresenter{present: present} } +// wineTypingEchoEnabled applies the native insertion policy to the helper's +// character labels. Navigation and deletion do not pass through this filter. +func wineTypingEchoEnabled(text string, settings config.Settings) bool { + switch text { + case "tab", "new line": + return settings.EnableKeyEcho && settings.EnableActionKeys + case "space": + text = " " + } + // The helper sends one accepted character per insertion. Do not treat an + // unexpected label as a multi-character paste, which has a different policy. + if utf8.RuneCountInString(text) != 1 { + return false + } + event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: text} + return presentation.EventTextWithOptions(event, presentationSettingsFromConfig(settings, false)) != "" +} + func (owner *wineEventPresenter) Present(event remote.AccessibleEvent) bool { return owner.present_event(event, nil) } diff --git a/internal/app/wine_events_native_test.go b/internal/app/wine_events_native_test.go index 4ec557a..d710795 100644 --- a/internal/app/wine_events_native_test.go +++ b/internal/app/wine_events_native_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/remote" "github.com/godbus/dbus/v5" ) @@ -32,7 +33,10 @@ func TestNativeSessionBusWineEvents(t *testing.T) { t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus") } output := &wineTestOutput{speech: make(chan speechRequest, 32), braille: make(chan string, 32)} - handler := remoteSpeech{speech: output, braille: output} + settings := config.DefaultSettings() + live := &liveSettings{} + live.Store(settings) + handler := remoteSpeech{speech: output, braille: output, live: live} owner := newWineEventPresenter(func(message string) bool { return handler.presentMessage(message, "wine-accessible-event") }) @@ -71,6 +75,19 @@ func TestNativeSessionBusWineEvents(t *testing.T) { for _, text := range []string{"A", "a", "É", "é"} { send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8"}, true) } + settings.EnableKeyEcho = false + live.Store(settings) + for _, text := range []string{"A", "space", "tab", "new line"} { + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "kind=8"}, false) + } + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "e", States: "kind=7"}, true) + settings.EnableKeyEcho = true + settings.EnableAlphabeticKeys = false + live.Store(settings) + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "A", States: "kind=8"}, false) + settings.EnableAlphabeticKeys = true + live.Store(settings) + send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "A", States: "kind=8"}, true) send(remote.AccessibleEvent{SourceID: "check", EventType: "focus", Name: "Option", Role: "check box", States: "not checked"}, true) owner.flush() send(remote.AccessibleEvent{SourceID: "check", EventType: "name", Name: "Renamed", Role: "check box", States: "not checked"}, true) @@ -87,7 +104,7 @@ func TestNativeSessionBusWineEvents(t *testing.T) { send(remote.AccessibleEvent{SourceID: "no", EventType: "focus", Name: "No", Role: "client"}, true) owner.flush() owner.Close() - for _, want := range []string{"edit, hello", "l", "l", "e selected", "e deleted", "A", "a", "É", "é", "Option, check box, not checked", "Renamed, check box, not checked", "Renamed, check box, checked, unavailable", "Yes", "No"} { + for _, want := range []string{"edit, hello", "l", "l", "e selected", "e deleted", "A", "a", "É", "é", "e", "A", "Option, check box, not checked", "Renamed, check box, not checked", "Renamed, check box, checked, unavailable", "Yes", "No"} { select { case request := <-output.speech: if request.Text != want || !request.Append { diff --git a/internal/app/wine_typing_echo_settings_test.go b/internal/app/wine_typing_echo_settings_test.go new file mode 100644 index 0000000..5e68b69 --- /dev/null +++ b/internal/app/wine_typing_echo_settings_test.go @@ -0,0 +1,117 @@ +// 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 TestWineTypingEchoUsesLiveSettings(t *testing.T) { + settings := config.DefaultSettings() + live := &liveSettings{} + live.Store(settings) + speech := &remoteSpeechQueue{} + braille := &remoteBrailleQueue{running: true} + presenter := remoteSpeech{speech: speech, braille: braille, live: live} + owner := newWineEventPresenter(func(message string) bool { + return presenter.presentMessage(message, "wine-accessible-event") + }) + defer owner.Close() + presenter.wine = owner + presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "focus", Role: "edit"}) + owner.flush() + check := func(text, kind string, want bool) { + t.Helper() + beforeSpeech, beforeBraille := len(speech.requests), len(braille.text) + accepted := presenter.PresentAccessibleEvent(remote.AccessibleEvent{ + SourceID: "edit", EventType: "text", Name: text, States: "start=1 end=1 kind=" + kind, + }) + if accepted != want { + t.Errorf("%q kind=%s accepted=%v, want %v", text, kind, accepted, want) + } + if !want { + if len(speech.requests) != beforeSpeech || len(braille.text) != beforeBraille { + t.Errorf("disabled typing %q reached output", text) + } + return + } + if len(speech.requests) != beforeSpeech+1 || speech.requests[beforeSpeech].Text != text || + len(braille.text) != beforeBraille+1 || braille.text[beforeBraille] != text { + t.Errorf("enabled action %q missing from speech or braille", text) + } + } + for _, text := range []string{"A", "a", "a", "É", "𐐀", "7", "!", "😀", "space", "tab", "new line"} { + check(text, "8", true) + } + settings.EnableKeyEcho = false + settings.EnableEchoByCharacter = true + live.Store(settings) + for _, text := range []string{"a", "7", "!", "space", "tab", "new line"} { + check(text, "8", false) + } + // Reading, deletion and selection remain available with key echo disabled. + for _, action := range []struct{ text, kind string }{ + {"a", "5"}, {"a", "7"}, {"line of text", "1"}, {"a selected", "2"}, {"a deleted", "4"}, + } { + check(action.text, action.kind, true) + } + settings.EnableKeyEcho = true + settings.EnableEchoByCharacter = false + settings.EnableAlphabeticKeys = false + settings.EnableNumericKeys = false + settings.EnablePunctuationKeys = false + settings.EnableSpace = false + settings.EnableActionKeys = false + live.Store(settings) + for _, text := range []string{"A", "é", "𐐀", "7", "!", "😀", "space", "tab", "new line", "́"} { + check(text, "8", false) + } + settings.EnableEchoByCharacter = true + live.Store(settings) + for _, text := range []string{"A", "é", "7", "!", "😀", "space", "́"} { + check(text, "8", true) + } + for _, text := range []string{"tab", "new line"} { + check(text, "8", false) + } + settings.EnableEchoByCharacter = false + settings.EnableDiacriticalKeys = true + settings.EnableActionKeys = true + live.Store(settings) + for _, text := range []string{"́", "tab", "new line"} { + check(text, "8", true) + } + settings = config.DefaultSettings() + live.Store(settings) + check("a", "8", true) +} + +func TestWineTypingEchoFiltersCategoriesIndependently(t *testing.T) { + for _, tc := range []struct { + name, blocked, allowed string + disable func(*config.Settings) + }{ + {"alphabetic", "É", "7", func(s *config.Settings) { s.EnableAlphabeticKeys = false }}, + {"numeric", "7", "a", func(s *config.Settings) { s.EnableNumericKeys = false }}, + {"punctuation", "!", "a", func(s *config.Settings) { s.EnablePunctuationKeys = false }}, + {"symbol", "😀", "a", func(s *config.Settings) { s.EnablePunctuationKeys = false }}, + {"space", "space", "a", func(s *config.Settings) { s.EnableSpace = false }}, + {"action", "tab", "space", func(s *config.Settings) { s.EnableActionKeys = false }}, + } { + t.Run(tc.name, func(t *testing.T) { + settings := config.DefaultSettings() + tc.disable(&settings) + if wineTypingEchoEnabled(tc.blocked, settings) { + t.Errorf("disabled category echoed %q", tc.blocked) + } + if !wineTypingEchoEnabled(tc.allowed, settings) { + t.Errorf("unrelated category suppressed %q", tc.allowed) + } + }) + } +}