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) + } + }) + } +}