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.
This commit is contained in:
deedra committed 2026-10-14 22:32:37 -07:00
1 parent 6754c8ef6c
commit 15d527c619
5 files changed
+183 -3

No files matched your search

+9 -1
View File
@@ -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,
+22
View File
@@ -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)
}
+19 -2
View File
@@ -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 {
@@ -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)
}
})
}
}