diff --git a/README.md b/README.md
index 407ce34..6b2456f 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,27 @@ 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.
+Enable **Use custom echo voice** to choose the echo synthesizer, voice, rate,
+pitch and volume. The synthesizer starts with the main speech synthesizer unless
+a separate echo synthesizer was previously configured. No additional synthesizer
+checkbox is needed. Under **Use the custom voice for:**, choose **Key names**
+(such as Enter, Shift and arrows), **Typed characters**, **Words** or **Sentences**.
+These choices select the voice; the corresponding echo settings still control
+what is spoken. Turn off custom echo voice to use the main speech settings.
+
+Changing the custom echo synthesizer, person, rate, pitch or volume speaks the
+selected value using the echo voice and its draft settings, before Apply.
+Tab and Shift+Tab announce the field label in the main voice, followed by its
+current value in the echo voice. Other controls use the main voice.
+Apply saves the changes; Cancel restores the previous settings.
+
+For the combined reader, GTK and recorded-speech regression check, build a test
+binary and run `CTHULHU_BINARY=/path/to/test-binary CTHULHU_PREFS_ECHO_RUNTIME_ONLY=1 /usr/bin/python3 tests/native_preferences.py`.
+This uses private Xvfb, D-Bus and SSIP services. Live listening still needs a
+separate check: Tab and Shift+Tab across all five fields, change synthesizer
+and person with arrows and their popups, and confirm labels use the main voice
+while values finish in the selected echo voice.
+
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
diff --git a/internal/app/app.go b/internal/app/app.go
index b96cb9d..b353304 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -310,6 +310,9 @@ func Run(ctx context.Context, options Options) error {
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true})
brailleOutput.FlashConfirmation(message, live.Load().Braille)
}
+ preferences.announceEchoPreview = func(label, value string) {
+ speechOutput.Enqueue(echo_preview_speech(label, value))
+ }
services = append(services, lifecycle.Service{Name: "preferences", Start: preferences.Start, Stop: preferences.Stop})
notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList)
diff --git a/internal/app/configured_speaker.go b/internal/app/configured_speaker.go
index a272848..517139f 100644
--- a/internal/app/configured_speaker.go
+++ b/internal/app/configured_speaker.go
@@ -241,6 +241,9 @@ func (speaker *hardwareSpeaker) speak_character(ctx context.Context, text string
return speaker.speak(ctx, text, echo, kind)
}
func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error {
+ if kind == "echo" {
+ return speaker.SpeakEcho(ctx, text)
+ }
return speaker.speak(ctx, text, false, kind)
}
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go
index 487d325..ed28a41 100644
--- a/internal/app/echo_speaker_test.go
+++ b/internal/app/echo_speaker_test.go
@@ -18,6 +18,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
+ "git.stormux.org/storm/cthulhu/internal/preferences"
"git.stormux.org/storm/cthulhu/internal/remote"
)
@@ -34,8 +35,9 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatal(err)
}
type utterance struct {
- module, text string
- rate, pitch string
+ module, text string
+ rate, pitch string
+ volume, voice string
}
spoken := make(chan utterance, 10)
var handlers sync.WaitGroup
@@ -53,6 +55,8 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
defer conn.Close()
reader := bufio.NewReader(conn)
module, rate, pitch := "", "", ""
+ volume, voice := "", ""
+ notifications := false
for {
line, err := reader.ReadString('\n')
if err != nil {
@@ -60,6 +64,11 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
}
command := strings.TrimSpace(line)
response := "200 OK\r\n"
+ if command == "SET SELF NOTIFICATION ALL on" {
+ notifications = true
+ } else if command == "SET SELF NOTIFICATION ALL off" {
+ notifications = false
+ }
if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") {
module = strings.TrimPrefix(command, "SET SELF OUTPUT_MODULE ")
}
@@ -69,6 +78,12 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
if strings.HasPrefix(command, "SET SELF PITCH ") {
pitch = strings.TrimPrefix(command, "SET SELF PITCH ")
}
+ if strings.HasPrefix(command, "SET SELF VOLUME ") {
+ volume = strings.TrimPrefix(command, "SET SELF VOLUME ")
+ }
+ if strings.HasPrefix(command, "SET SELF SYNTHESIS_VOICE ") {
+ voice = strings.TrimPrefix(command, "SET SELF SYNTHESIS_VOICE ")
+ }
if command == "SET SELF OUTPUT_MODULE rejected" {
response = "400 Module unavailable\r\n"
}
@@ -76,7 +91,7 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id)
}
if strings.HasPrefix(command, "CHAR ") {
- spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch}
+ spoken <- utterance{module, strings.TrimPrefix(command, "CHAR "), rate, pitch, volume, voice}
}
if command == "SPEAK" {
var text strings.Builder
@@ -90,8 +105,11 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
}
text.WriteString(strings.TrimSuffix(part, "\r\n"))
}
- spoken <- utterance{module, text.String(), rate, pitch}
+ spoken <- utterance{module, text.String(), rate, pitch, volume, voice}
response = "230 OK\r\n200 QUEUED\r\n"
+ if notifications {
+ response += fmt.Sprintf("701-1\r\n701-%d\r\n701 BEGIN\r\n702-1\r\n702-%d\r\n702 END\r\n", id, id)
+ }
}
if _, err := conn.Write([]byte(response)); err != nil {
return
@@ -141,6 +159,47 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
}
check(false, "", "")
check(true, "echo-module", "62")
+ // Preview unsaved echo selections through the real SSIP clients. Only the
+ // numeric request uses that endpoint; subsequent dialog speech stays main.
+ previewWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
+ previewValues := preferences.FromSettings(settings)
+ previewRate, previewPitch, previewVolume := 70.0, 6.0, 10.0
+ previewValues.EchoSpeechServer = "preview-module"
+ previewValues.EchoVoice = config.Voice{Rate: &previewRate, AveragePitch: &previewPitch, Gain: &previewVolume,
+ Family: &config.VoiceFamily{Name: "Preview voice", Lang: "en"}}
+ previewSpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
+ previewWorker.announceEchoPreview = func(label, value string) {
+ if err := previewSpeech.deliver(ctx, echo_preview_speech(label, value)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, test := range []struct{ slider, text string }{{"rate", "70.0"}, {"pitch", "6.0"}, {"volume", "10.0"}, {"synthesizer", "preview-module"}, {"person", "Preview voice (en)"}} {
+ if response := previewWorker.preview(ctx, preferences.Request{Values: previewValues, Preview: true, EchoPreview: test.slider, EchoPreviewFocus: true}); !response.Accepted {
+ t.Fatalf("echo preview rejected: %+v", response)
+ }
+ select {
+ case got := <-spoken:
+ if !strings.HasPrefix(got.text, "Key echo ") || got.module != "" || got.voice != "" {
+ t.Fatalf("echo focus label did not use the normal voice: %+v", got)
+ }
+ case <-ctx.Done():
+ t.Fatal("echo focus label missing")
+ }
+ select {
+ case got := <-spoken:
+ if got.text != test.text || got.module != "preview-module" || got.voice != "Preview voice" ||
+ got.rate != "42" || got.pitch != "30" || got.volume != "100" {
+ t.Fatalf("echo slider SSIP request = %+v", got)
+ }
+ case <-ctx.Done():
+ t.Fatal("echo slider SSIP request missing")
+ }
+ check(false, "", "")
+ }
+ if response := previewWorker.restorePreview(ctx); !response.Accepted {
+ t.Fatalf("restore echo preview: %+v", response)
+ }
+ check(true, "echo-module", "62")
checkCharacters := func(wantNormal string) {
t.Helper()
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go
index 52e815d..f23e824 100644
--- a/internal/app/event_presentation.go
+++ b/internal/app/event_presentation.go
@@ -37,6 +37,12 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
foreground := presenter.foreground
inputSource := presenter.inputSource
live := presenter.live
+ if live != nil && state.echoPreview.owns(event, live.preferencesPID.Load()) {
+ state.comboHistory.observe(event)
+ // Focus labels and values are serialized by the private preview path.
+ // GTK selection descendants belong to that same presentation.
+ return
+ }
browser := presenter.browser
ownedAutocompletePopup := state.ownedAutocompletePopup
if foreground != nil && !eventBypassesXTermHandoff(event) &&
diff --git a/internal/app/event_state.go b/internal/app/event_state.go
index 845384e..533cb48 100644
--- a/internal/app/event_state.go
+++ b/internal/app/event_state.go
@@ -98,6 +98,7 @@ type eventPresentationState struct {
lastLiveText string
lastLiveAt time.Time
comboHistory comboPresentation
+ echoPreview echoPreviewPresentation
// Retain this identity after the history window closes so delayed GTK
// events cannot enter speech history.
historyWindow atspi.ObjectID
@@ -167,6 +168,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
state.lastLiveText, state.lastLiveAt = "", time.Time{}
state.comboHistory = comboPresentation{}
+ state.echoPreview = echoPreviewPresentation{}
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
state.lastAutocompletePopupItem = atspi.ObjectID{}
state.autocompleteTyping = autocompleteEcho{}
diff --git a/internal/app/live_settings.go b/internal/app/live_settings.go
index 26ce398..985e902 100644
--- a/internal/app/live_settings.go
+++ b/internal/app/live_settings.go
@@ -15,6 +15,7 @@ import (
type liveSettings struct {
editFieldFocused func() bool // Installed before workers start; reads cached focus only.
current atomic.Pointer[config.Settings]
+ preferencesPID atomic.Int64 // Owned by the preferences window lifetime; zero when closed.
application atomic.Pointer[string]
requestedApplication atomic.Pointer[string]
clipboardChanges chan struct{}
diff --git a/internal/app/preferences.go b/internal/app/preferences.go
index bd07a7b..b3ed939 100644
--- a/internal/app/preferences.go
+++ b/internal/app/preferences.go
@@ -55,16 +55,17 @@ type preferencesWorker struct {
bindings interface {
SetKeyBindings(context.Context, []input.BindingOverride) error
}
- braille brailleSettingsUpdater
- documentReview *review.Worker
- socket string
- speechCommands chan input.Command
- remoteChanges chan remoteSettingsChange
- speechSetting int
- announce func(string)
- announceApply func(string)
- catalog speech.Catalog
- discover func(context.Context, string) (speech.Catalog, error)
+ braille brailleSettingsUpdater
+ documentReview *review.Worker
+ socket string
+ speechCommands chan input.Command
+ remoteChanges chan remoteSettingsChange
+ speechSetting int
+ announce func(string)
+ announceApply func(string)
+ announceEchoPreview func(string, string)
+ catalog speech.Catalog
+ discover func(context.Context, string) (speech.Catalog, error)
}
func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker {
diff --git a/internal/app/preferences_echo_preview_test.go b/internal/app/preferences_echo_preview_test.go
new file mode 100644
index 0000000..b15f4ee
--- /dev/null
+++ b/internal/app/preferences_echo_preview_test.go
@@ -0,0 +1,279 @@
+// Copyright (c) 2026 Storm Dragon
+// Authors: Storm Dragon, Angus Kola
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "reflect"
+ "strings"
+ "testing"
+
+ "git.stormux.org/storm/cthulhu/internal/atspi"
+ "git.stormux.org/storm/cthulhu/internal/braille"
+ "git.stormux.org/storm/cthulhu/internal/config"
+ "git.stormux.org/storm/cthulhu/internal/input"
+ "git.stormux.org/storm/cthulhu/internal/preferences"
+)
+
+type echoPreviewUpdater func(context.Context, config.Settings) error
+
+func (update echoPreviewUpdater) UpdateConfiguration(ctx context.Context, settings config.Settings) error {
+ return update(ctx, settings)
+}
+
+func TestPreferencesEchoSliderPreviewsDraftWithoutChangingMainVoice(t *testing.T) {
+ settings := config.DefaultSettings()
+ settings.SpeechOutputModule = "main-module"
+ updater := &voiceConfigurationRecorder{}
+ worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater)
+ live := &liveSettings{}
+ live.Store(settings)
+ worker.live = live
+ values := preferences.FromSettings(settings)
+ rate, pitch, volume := 78.2, 6.3, 10.0
+ values.UseCustomEchoVoice, values.UseCustomEchoSpeechServer = true, true
+ values.EchoSpeechServer = "echo-module"
+ values.EchoVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &volume,
+ Family: &config.VoiceFamily{Name: "Echo voice", Lang: "en"}}
+ // Unapplied Speech-tab choices must not become the normal dialog voice.
+ values.SpeechOutputModule = "unapplied-module"
+ values.VoiceType = "uppercase"
+ values.Rate = &rate
+ for _, test := range []struct{ slider, want string }{{"rate", "78.2"}, {"pitch", "6.3"}, {"volume", "10.0"}, {"synthesizer", "echo-module"}, {"person", "Echo voice (en)"}} {
+ announced := ""
+ worker.announceEchoPreview = func(_ string, value string) {
+ if updater.calls == 0 || !reflect.DeepEqual(updater.settings.EchoVoice, values.EchoVoice) {
+ t.Fatal("value announced before installing draft echo voice")
+ }
+ announced = value
+ }
+ response := worker.preview(t.Context(), preferences.Request{Values: values, Preview: true, EchoPreview: test.slider})
+ if !response.Accepted || !response.Preview || announced != test.want {
+ t.Fatalf("%s: response=%+v speech=%q", test.slider, response, announced)
+ }
+ if updater.settings.SpeechOutputModule != settings.SpeechOutputModule ||
+ !reflect.DeepEqual(updater.settings.DefaultVoice, settings.DefaultVoice) ||
+ !reflect.DeepEqual(updater.settings.SystemVoice, settings.SystemVoice) {
+ t.Fatal("echo preview changed ordinary voices")
+ }
+ if updater.settings.EchoSpeechServer != "echo-module" || !updater.settings.UseCustomEchoVoice {
+ t.Fatal("echo preview ignored the selected module")
+ }
+ if !reflect.DeepEqual(worker.settings, settings) || !reflect.DeepEqual(live.Load(), settings) {
+ t.Fatal("preview committed session or live settings")
+ }
+ }
+ if response := worker.restorePreview(t.Context()); !response.Accepted || !reflect.DeepEqual(updater.settings, settings) {
+ t.Fatal("Cancel did not restore committed speech settings")
+ }
+}
+
+func TestPreferencesEchoPreviewRejectsInvalidDraftAndBackendFailure(t *testing.T) {
+ for _, test := range []string{"unknown slider", "disabled", "missing value", "out of range", "backend failure"} {
+ t.Run(test, func(t *testing.T) {
+ calls := 0
+ settings := config.DefaultSettings()
+ worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, echoPreviewUpdater(func(context.Context, config.Settings) error {
+ calls++
+ return errors.New("preview unavailable")
+ }))
+ worker.announceEchoPreview = func(string, string) { t.Fatal("rejected preview spoke a value") }
+ values := preferences.FromSettings(settings)
+ volume := 5.0
+ values.UseCustomEchoVoice = true
+ values.EchoVoice.Gain = &volume
+ request := preferences.Request{Values: values, Preview: true, EchoPreview: "volume"}
+ switch test {
+ case "unknown slider":
+ request.EchoPreview = "other"
+ case "disabled":
+ request.Values.UseCustomEchoVoice = false
+ case "missing value":
+ request.Values.EchoVoice.Gain = nil
+ case "out of range":
+ volume = 11
+ }
+ if response := worker.preview(t.Context(), request); response.Accepted || !response.Preview {
+ t.Fatalf("invalid preview response: %+v", response)
+ }
+ wantCalls := 0
+ if test == "backend failure" {
+ wantCalls = 1
+ }
+ if calls != wantCalls {
+ t.Fatalf("backend updates=%d, want %d", calls, wantCalls)
+ }
+ })
+ }
+}
+
+func TestEchoPreviewValueKeepsBrailleWithoutDuplicateSpeech(t *testing.T) {
+ settings := config.DefaultSettings()
+ live := &liveSettings{}
+ live.Store(settings)
+ live.preferencesPID.Store(123)
+ source := atspi.Snapshot{BusName: ":1.42", Path: "/echo-volume", Name: "Key echo volume",
+ Role: "slider", Focused: true, ValueText: "5.0", ApplicationPID: 123}
+ state := &eventLoopState{}
+ state.current, state.focusedID = source, source.ID()
+ queue := &remoteSpeechQueue{}
+ frames := frameRecorder{frames: make(chan braille.Frame, 2)}
+ logger := slog.New(slog.DiscardHandler)
+ presenter := eventPresenter{state: state, logger: logger, speech: queue, live: live}
+ handler := accessibilityEventHandler{state: state, logger: logger, live: live, brailleOutput: frames}
+ for _, kind := range []string{atspi.ValueChangedEvent, atspi.AccessibleValueEvent} {
+ source.ValueText = "5.1"
+ event := atspi.NewEvent(kind, source)
+ handler.acceptFocusedEvent(event, input.Activity{})
+ presenter.speak(event)
+ if len(queue.requests) != 0 {
+ t.Fatal("AT-SPI repeated the echo preview in the main voice")
+ }
+ if len(frames.frames) != 1 {
+ t.Fatal("echo preview suppressed Braille value updates")
+ }
+ if frame := <-frames.frames; !strings.Contains(frame.Text, "5.1") {
+ t.Fatalf("Braille did not receive the updated value: %+v", frame)
+ }
+ }
+ presenter.speak(atspi.NewEvent(atspi.FocusEvent, source))
+ if len(queue.requests) != 0 {
+ t.Fatal("AT-SPI repeated the separately queued focus preview")
+ }
+}
+
+func TestPreferencesEchoComboDefaultValuesUseDraftVoice(t *testing.T) {
+ settings := config.DefaultSettings()
+ updater := &voiceConfigurationRecorder{}
+ worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, updater)
+ values := preferences.FromSettings(settings)
+ values.UseCustomEchoVoice, values.UseCustomEchoSpeechServer = true, true
+ values.EchoSpeechServer, values.EchoVoice.Family = "", nil
+ announced := ""
+ worker.announceEchoPreview = func(_ string, value string) { announced = value }
+ for _, control := range []string{"synthesizer", "person"} {
+ if response := worker.preview(t.Context(), preferences.Request{Values: values, Preview: true, EchoPreview: control}); !response.Accepted || announced != "Default" {
+ t.Fatalf("%s default preview: response=%+v text=%q", control, response, announced)
+ }
+ }
+}
+
+func TestEchoPreviewGTKSelectedMenuItemCannotRepeat(t *testing.T) {
+ // Reproduce the native log sequence: combo focus, name update, then a
+ // selected menu-item snapshot whose OriginPath is the original combo.
+ for _, control := range []string{"Key echo speech synthesizer", "Key echo person"} {
+ settings := config.DefaultSettings()
+ live := &liveSettings{}
+ live.Store(settings)
+ live.preferencesPID.Store(123)
+ adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
+ source := atspi.Snapshot{BusName: ":1.42", Path: "/combo", Role: "combo box",
+ Name: control, ValueText: "Default", Focused: true, ApplicationPID: 123}
+ adapter.events <- atspi.NewEvent(atspi.FocusEvent, source)
+ source.ValueText = "Selected voice"
+ adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, source)
+ selected := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: source.BusName,
+ Path: "/selected", Name: "Selected voice", Role: "menu item", Selected: true})
+ selected.OriginPath = source.Path
+ adapter.events <- selected
+ adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, source)
+ adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: source.BusName,
+ Path: "/apply", Name: "Apply", Role: "button", Focused: true})
+ close(adapter.events)
+ queue := &remoteSpeechQueue{}
+ if err := processEventsWithSettings(t.Context(), slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, live); err != nil {
+ t.Fatal(err)
+ }
+ if len(queue.requests) != 1 || queue.requests[0].Text != "Apply, button" {
+ t.Fatalf("%s: native duplicate escaped preview ownership: %+v", control, queue.requests)
+ }
+ }
+}
+
+func TestEchoPreviewSelectionOwnershipIsBoundedToChildAndControls(t *testing.T) {
+ preview := echoPreviewPresentation{}
+ source := atspi.Snapshot{BusName: ":1.42", Path: "/combo", Name: "Key echo person", Role: "combo box", ApplicationPID: 123}
+ if !preview.owns(atspi.NewEvent(atspi.FocusEvent, source), 123) {
+ t.Fatal("owned focus was not recognized")
+ }
+ selected := atspi.NewEvent(atspi.SelectionChangedEvent, atspi.Snapshot{BusName: source.BusName, Path: "/item", Role: "menu item"})
+ selected.OriginPath = source.Path
+ if !preview.owns(selected, 123) {
+ t.Fatal("selected descendant was not recognized")
+ }
+ selected.Source.BusName = ":1.43"
+ if preview.owns(selected, 123) {
+ t.Fatal("selection in another application was suppressed")
+ }
+ selected.Source.BusName = source.BusName
+ selected.OriginPath = "/other-combo"
+ if preview.owns(selected, 123) {
+ t.Fatal("selection in another combo was suppressed")
+ }
+ selected.OriginPath = source.Path
+ if preview.owns(selected, 0) || preview.owns(selected, 456) {
+ t.Fatal("selection ownership survived preferences exit or replacement")
+ }
+ if preview.owns(atspi.NewEvent(atspi.FocusEvent, source), 456) {
+ t.Fatal("old child acquired the new preferences session")
+ }
+}
+
+type echoPreviewWaitSpeaker struct{ controlledSpeaker }
+
+func (speaker *echoPreviewWaitSpeaker) SpeakAndWait(ctx context.Context, text string, _ func()) error {
+ return speaker.call(ctx, "default:"+text)
+}
+
+func (speaker *echoPreviewWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, _ func()) error {
+ return speaker.call(ctx, voice+":"+text)
+}
+
+func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) {
+ speaker := &echoPreviewWaitSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}}
+ worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
+ if err := worker.Start(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { worker.Stop(context.Background()) })
+ worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice"))
+ close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
+ label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person")
+ worker.Enqueue(speechRequest{Text: "Later control"})
+ select {
+ case call := <-speaker.calls:
+ t.Fatalf("queued output overtook the focus label: %s", call.text)
+ default:
+ }
+ close(label.done)
+ value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice")
+ select {
+ case call := <-speaker.calls:
+ t.Fatalf("queued output cut off the echo value: %s", call.text)
+ default:
+ }
+ close(value.done)
+ close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
+ close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later control").done)
+}
+
+func TestEchoPreviewFocusSpeechRemainsInterruptible(t *testing.T) {
+ speaker := &echoPreviewWaitSpeaker{controlledSpeaker{calls: make(chan speechCall, 8)}}
+ worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
+ if err := worker.Start(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { worker.Stop(context.Background()) })
+ worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice"))
+ close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
+ label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person")
+ close(label.done)
+ value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice")
+ worker.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 100})
+ close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
+ close(value.done)
+}
diff --git a/internal/app/preferences_preview.go b/internal/app/preferences_preview.go
index bc7b933..6d06586 100644
--- a/internal/app/preferences_preview.go
+++ b/internal/app/preferences_preview.go
@@ -7,12 +7,17 @@ package app
import (
"context"
"errors"
+ "fmt"
+ "strings"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/preferences"
)
func (worker *preferencesWorker) preview(ctx context.Context, request preferences.Request) preferences.Response {
+ if request.EchoPreview != "" {
+ return worker.preview_echo_voice(ctx, request)
+ }
next := worker.settings
if worker.editingActive {
next = worker.editing
@@ -48,6 +53,81 @@ func (worker *preferencesWorker) preview(ctx context.Context, request preference
return response
}
+func (worker *preferencesWorker) preview_echo_voice(ctx context.Context, request preferences.Request) preferences.Response {
+ response := worker.response("Speech settings previewed.", false)
+ response.Preview = true
+ var value *float64
+ text := ""
+ label := ""
+ switch request.EchoPreview {
+ case "rate":
+ value = request.Values.EchoVoice.Rate
+ label = "Key echo rate"
+ case "pitch":
+ value = request.Values.EchoVoice.AveragePitch
+ label = "Key echo pitch"
+ case "volume":
+ value = request.Values.EchoVoice.Gain
+ label = "Key echo volume"
+ case "synthesizer":
+ label = "Key echo speech synthesizer"
+ text = request.Values.EchoSpeechServer
+ if text == "" {
+ text = "Default"
+ }
+ case "person":
+ label = "Key echo person"
+ text = "Default"
+ if family := request.Values.EchoVoice.Family; family != nil {
+ text = family.Name
+ if language := strings.TrimSuffix(family.Lang+"-"+family.Dialect, "-"); language != "" {
+ text += " (" + language + ")"
+ }
+ }
+ }
+ if value != nil {
+ text = fmt.Sprintf("%.1f", *value)
+ }
+ if !request.Values.UseCustomEchoVoice || text == "" {
+ response.Message = "Choose a custom echo voice and a valid echo control value."
+ return response
+ }
+ // Preview only echo settings. Control labels and ordinary output retain
+ // the active profile's main voice; the draft is never committed here.
+ next := worker.settings
+ next.UseCustomEchoVoice = true
+ next.UseCustomEchoSpeechServer = request.Values.UseCustomEchoSpeechServer
+ next.EchoSpeechServer = request.Values.EchoSpeechServer
+ next.EchoVoice = request.Values.EchoVoice
+ if err := config.Validate(next); err != nil {
+ response.Message = "Speech settings were not previewed: " + err.Error()
+ return response
+ }
+ if err := worker.updateSpeech(ctx, next); err != nil {
+ response.Message = "Speech settings were not previewed: " + err.Error()
+ return response
+ }
+ if worker.announceEchoPreview != nil {
+ if !request.EchoPreviewFocus {
+ label = ""
+ }
+ worker.announceEchoPreview(label, text)
+ }
+ response.Accepted = true
+ return response
+}
+
+func echo_preview_speech(label, value string) speechRequest {
+ parts := []readAllPart{}
+ if label != "" {
+ parts = append(parts, readAllPart{Text: label, VoiceType: "default"})
+ }
+ parts = append(parts, readAllPart{Text: value, VoiceType: "echo"})
+ // Both parts belong to one cancellable request and wait for completion.
+ // No second voice can cancel the first part merely by being queued next.
+ return speechRequest{Text: value, Role: "text", Cause: "echo-voice-preview", ReadAll: parts}
+}
+
func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response {
response := worker.response("Speech settings restored.", false)
if err := worker.updateSpeech(ctx, worker.settings); err != nil {
diff --git a/internal/app/preferences_preview_events.go b/internal/app/preferences_preview_events.go
new file mode 100644
index 0000000..71a5663
--- /dev/null
+++ b/internal/app/preferences_preview_events.go
@@ -0,0 +1,47 @@
+// Copyright (c) 2026 Storm Dragon
+// Authors: Storm Dragon, Angus Kola
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app
+
+import "git.stormux.org/storm/cthulhu/internal/atspi"
+
+// The event loop remembers exactly five control identities for one preferences
+// child. A new child or focus reset retires them. GTK reports combo selections
+// as menu-item descendants with the owning combo in OriginPath.
+type echoPreviewPresentation struct {
+ pid int64
+ controls [5]atspi.ObjectID
+}
+
+func (preview *echoPreviewPresentation) owns(event atspi.Event, pid int64) bool {
+ if preview.pid != pid {
+ *preview = echoPreviewPresentation{pid: pid}
+ }
+ if pid <= 0 {
+ return false
+ }
+ index := -1
+ switch event.Source.Name {
+ case "Key echo rate":
+ index = 0
+ case "Key echo pitch":
+ index = 1
+ case "Key echo volume":
+ index = 2
+ case "Key echo speech synthesizer":
+ index = 3
+ case "Key echo person":
+ index = 4
+ }
+ if index >= 0 && int64(event.Source.ApplicationPID) == pid &&
+ (index < 3 && event.Source.Role == "slider" || index >= 3 && event.Source.Role == "combo box") {
+ preview.controls[index] = event.Source.ID()
+ return true
+ }
+ if event.Type == atspi.SelectionChangedEvent && event.Source.Role == "menu item" {
+ owner := atspi.NewObjectID(event.Source.BusName, event.OriginPath)
+ return owner.Valid() && (owner == preview.controls[3] || owner == preview.controls[4])
+ }
+ return false
+}
diff --git a/internal/app/preferences_session.go b/internal/app/preferences_session.go
index a1d0451..36982ad 100644
--- a/internal/app/preferences_session.go
+++ b/internal/app/preferences_session.go
@@ -65,6 +65,10 @@ func (worker *preferencesWorker) window(ctx context.Context) (result error) {
out.Close()
return err
}
+ if worker.live != nil {
+ worker.live.preferencesPID.Store(int64(command.Process.Pid))
+ defer worker.live.preferencesPID.Store(0)
+ }
defer func() {
stop()
in.Close()
diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go
index 3c1e565..24e0fc2 100644
--- a/internal/app/speech_delivery.go
+++ b/internal/app/speech_delivery.go
@@ -92,7 +92,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
}
}
text := worker.transformText(worker.line_text(part.Text, part.Line))
- worker.logDelivery(ctx, request, text, index)
+ delivery := request
+ delivery.VoiceType, delivery.CustomEcho = part.VoiceType, part.VoiceType == "echo"
+ worker.logDelivery(ctx, delivery, text, index)
if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" {
worker.history.add(text, request.Role)
}
diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go
index d11811e..9d61ca7 100644
--- a/internal/atspi/adapter_event_callback_native.go
+++ b/internal/atspi/adapter_event_callback_native.go
@@ -111,6 +111,10 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
}
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
snapshot.ApplicationProcess = adapter.applicationProcess(target)
+ if snapshot.Role == "slider" ||
+ snapshot.Role == "combo box" && (snapshot.Name == "Key echo speech synthesizer" || snapshot.Name == "Key echo person") {
+ snapshot.ApplicationPID = int(C.atspi_accessible_get_process_id(target, nil))
+ }
if snapshot.Role == "combo box" && strings.EqualFold(snapshot.ApplicationProcess, "firefox") {
snapshot.Autocomplete = snapshot.Autocomplete ||
isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0)
diff --git a/internal/atspi/model.go b/internal/atspi/model.go
index cab012c..277de9c 100644
--- a/internal/atspi/model.go
+++ b/internal/atspi/model.go
@@ -122,6 +122,8 @@ type Snapshot struct {
ApplicationName string
// ApplicationProcess is resolved from the provider PID, not page content.
ApplicationProcess string
+ // ApplicationPID identifies owned preferences controls during voice previews.
+ ApplicationPID int
// ToolkitName is captured for name-change events to select web policy
// even before document focus or an address-bar event has arrived.
ToolkitName string
diff --git a/internal/config/validation.go b/internal/config/validation.go
index 0cd9e2f..fcb1492 100644
--- a/internal/config/validation.go
+++ b/internal/config/validation.go
@@ -137,7 +137,7 @@ func validate(settings Settings) error {
}
if settings.SpeechServerFactory == "hardwarefactory" {
if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer {
- return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable that option for hardware speech")
+ return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable custom echo voice for hardware speech")
}
switch settings.SpeechOutputModule {
case "litetalk", "doubletalk", "tripletalk", "dectalk":
diff --git a/internal/preferences/echo.go b/internal/preferences/echo.go
index 55fd99c..7cca5c5 100644
--- a/internal/preferences/echo.go
+++ b/internal/preferences/echo.go
@@ -39,7 +39,6 @@ func echoChecks(values *Values) []struct {
{"enableEchoByWordCheckButton", &values.EnableEchoByWord},
{"enableEchoBySentenceCheckButton", &values.EnableEchoBySentence},
{"useCustomEchoVoiceCheckButton", &values.UseCustomEchoVoice},
- {"useCustomEchoSpeechServerCheckButton", &values.UseCustomEchoSpeechServer},
{"useCustomEchoForKeyCheckButton", &values.UseCustomEchoForKey},
{"useCustomEchoForCharacterCheckButton", &values.UseCustomEchoForCharacter},
{"useCustomEchoForWordCheckButton", &values.UseCustomEchoForWord},
@@ -48,6 +47,10 @@ func echoChecks(values *Values) []struct {
}
func (session *uiSession) setEchoVoice(values Values) {
+ module := values.SpeechOutputModule
+ if values.UseCustomEchoSpeechServer {
+ module = values.EchoSpeechServer
+ }
session.echoModules = []string{""}
for module := range session.catalog {
if module != "" {
@@ -56,26 +59,21 @@ func (session *uiSession) setEchoVoice(values Values) {
}
sort.Strings(session.echoModules[1:])
selected := 0
- if values.EchoSpeechServer != "" {
+ if module != "" {
selected = -1
- for i, module := range session.echoModules {
- if module == values.EchoSpeechServer {
+ for i, candidate := range session.echoModules {
+ if candidate == module {
selected = i
}
}
if selected < 0 {
selected = len(session.echoModules)
- session.echoModules = append(session.echoModules, values.EchoSpeechServer)
+ session.echoModules = append(session.echoModules, module)
}
}
labels := append([]string(nil), session.echoModules...)
labels[0] = "Default"
comboItems("echoSpeechServers", labels, selected)
- // The toggle state is installed by setValues before the next GTK iteration.
- module := ""
- if values.UseCustomEchoSpeechServer {
- module = values.EchoSpeechServer
- }
session.populateEchoFamilies(module, values.EchoVoice.Family)
for _, item := range []struct {
id string
@@ -129,10 +127,7 @@ func (session *uiSession) selectedEchoModule() string {
}
func (session *uiSession) refreshEchoVoices() {
- module := ""
- if getCheck("useCustomEchoSpeechServerCheckButton") {
- module = session.selectedEchoModule()
- }
+ module := session.selectedEchoModule()
if module != session.activeEchoModule {
session.populateEchoFamilies(module, nil)
}
diff --git a/internal/preferences/native.c b/internal/preferences/native.c
index f08ca4c..08ba9b3 100644
--- a/internal/preferences/native.c
+++ b/internal/preferences/native.c
@@ -6,6 +6,10 @@
static GtkBuilder *builder;
static int action;
static int voice_changed;
+static int echoPreview;
+static gboolean echoPreviewFocus;
+static const char *echoControls[] = {"echoRateScale", "echoPitchScale", "echoVolumeScale",
+ "echoSpeechServers", "echoSpeechFamilies"};
// Borrowed from the builder for the duration of one Apply/OK request.
static GtkWidget *busy_focus;
void prefs_set_action(int value) { action = value; }
@@ -73,6 +77,29 @@ static void voice_value_changed(GtkWidget *control, gpointer data) {
(void)control; (void)data;
voice_changed = 1;
}
+static gboolean echo_control_focused(GtkWidget *control) {
+ GtkWidget *focus = gtk_window_get_focus(GTK_WINDOW(prefs_widget("window")));
+ return focus && (focus == control || gtk_widget_is_ancestor(focus, control));
+}
+static void echo_value_changed(GtkWidget *control, gpointer data) {
+ if (echo_control_focused(control) && prefs_check_get("useCustomEchoVoiceCheckButton")) {
+ echoPreview = GPOINTER_TO_INT(data);
+ echoPreviewFocus = FALSE;
+ }
+}
+static void echo_focus_changed(GtkWindow *window, GtkWidget *focus, gpointer data) {
+ (void)window; (void)data;
+ echoPreview = 0;
+ if (!focus || !prefs_check_get("useCustomEchoVoiceCheckButton")) return;
+ for (unsigned i = 0; i < G_N_ELEMENTS(echoControls); i++) {
+ GtkWidget *control = prefs_widget(echoControls[i]);
+ if (focus == control || gtk_widget_is_ancestor(focus, control)) {
+ echoPreview = i + 1;
+ echoPreviewFocus = TRUE;
+ return;
+ }
+ }
+}
static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gpointer data) {
(void)data;
if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE;
@@ -99,9 +126,8 @@ static void echo_sensitivity(GtkToggleButton *button, gpointer data) {
"enableDiacriticalKeysCheckButton"};
for (unsigned i = 0; i < G_N_ELEMENTS(categories); i++) gtk_widget_set_sensitive(prefs_widget(categories[i]), enabled);
int custom = prefs_check_get("useCustomEchoVoiceCheckButton");
- const char *voice[] = {"useCustomEchoSpeechServerCheckButton", "echoSpeechFamilies", "echoRateScale", "echoPitchScale", "echoVolumeScale"};
+ const char *voice[] = {"echoSpeechServers", "echoSpeechFamilies", "echoRateScale", "echoPitchScale", "echoVolumeScale"};
for (unsigned i = 0; i < G_N_ELEMENTS(voice); i++) gtk_widget_set_sensitive(prefs_widget(voice[i]), custom);
- gtk_widget_set_sensitive(prefs_widget("echoSpeechServers"), custom && prefs_check_get("useCustomEchoSpeechServerCheckButton"));
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForKeyCheckButton"), custom && enabled);
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton"));
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForWordCheckButton"), custom && prefs_check_get("enableEchoByWordCheckButton"));
@@ -159,9 +185,12 @@ int prefs_init(const char *xml) {
atk_object_set_description(gtk_widget_get_accessible(control),
"Up and Down change by 1; Left and Right change by 0.1.");
}
+ for (unsigned i = 0; i < G_N_ELEMENTS(echoControls); i++)
+ g_signal_connect(prefs_widget(echoControls[i]), i < 3 ? "value-changed" : "changed",
+ G_CALLBACK(echo_value_changed), GINT_TO_POINTER(i + 1));
+ g_signal_connect(prefs_widget("window"), "set-focus", G_CALLBACK(echo_focus_changed), NULL);
const char *voiceCombos[] = {
"speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo",
- "echoSpeechServers", "echoSpeechFamilies",
};
for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) {
g_signal_connect(prefs_widget(voiceCombos[i]), "changed",
@@ -231,7 +260,7 @@ int prefs_init(const char *xml) {
};
set_focus_chain("keyboard_layout_controls", keyboard_focus,
G_N_ELEMENTS(keyboard_focus));
- const char *echo_toggles[] = {"keyEchoDisabledRadio", "keyEchoEnabledRadio", "keyEchoEditFieldsRadio", "useCustomEchoVoiceCheckButton", "useCustomEchoSpeechServerCheckButton", "enableEchoByCharacterCheckButton", "enableEchoByWordCheckButton", "enableEchoBySentenceCheckButton"};
+ const char *echo_toggles[] = {"keyEchoDisabledRadio", "keyEchoEnabledRadio", "keyEchoEditFieldsRadio", "useCustomEchoVoiceCheckButton", "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);
@@ -262,6 +291,14 @@ void prefs_set_application(const char *name) {
void prefs_iterate(void) { for (int i = 0; i < 64 && g_main_context_pending(NULL); i++) g_main_context_iteration(NULL, FALSE); }
int prefs_action(void) { int result = action; action = 0; return result; }
int prefs_voice_changed(void) { int result = voice_changed; voice_changed = 0; return result; }
+int prefs_echo_preview(int *focused) {
+ int result = echoPreview;
+ echoPreview = 0;
+ if (focused) *focused = echoPreviewFocus;
+ if (result && (!echo_control_focused(prefs_widget(echoControls[result - 1])) ||
+ !prefs_check_get("useCustomEchoVoiceCheckButton"))) return 0;
+ return result;
+}
void prefs_busy(int busy, int can_save) {
gtk_widget_set_sensitive(prefs_widget("notebook"), !busy);
gtk_widget_set_sensitive(prefs_widget("apply"), !busy);
diff --git a/internal/preferences/native.h b/internal/preferences/native.h
index f701232..aa03d3f 100644
--- a/internal/preferences/native.h
+++ b/internal/preferences/native.h
@@ -7,6 +7,7 @@ void prefs_set_application(const char *name);
void prefs_iterate(void);
int prefs_action(void);
int prefs_voice_changed(void);
+int prefs_echo_preview(int *focused);
void prefs_busy(int busy, int can_save);
void prefs_present(void);
void prefs_close(void);
diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui
index fb56817..8b56bcb 100644
--- a/internal/preferences/preferences.ui
+++ b/internal/preferences/preferences.ui
@@ -209,16 +209,19 @@
-
+
+
-
-
-
-
+
Enable echo by characterTrue
Enable echo by wordTrue
Enable echo by sentenceTrue
diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go
index d8861d9..6c2b425 100644
--- a/internal/preferences/protocol.go
+++ b/internal/preferences/protocol.go
@@ -90,6 +90,11 @@ type Request struct {
// Preview applies the default voice controls to the running speech
// backend without committing session or persistent settings.
Preview bool
+ // EchoPreview identifies the focused echo control (rate, pitch, volume,
+ // synthesizer or person). Only its value uses the draft echo voice.
+ EchoPreview string `json:",omitempty"`
+ // Focus previews include the control label in the normal voice before its value.
+ EchoPreviewFocus bool `json:",omitempty"`
// Cancel restores the last committed settings after a live preview.
Cancel bool
}
diff --git a/internal/preferences/speech.go b/internal/preferences/speech.go
index 8d2e716..3f2b919 100644
--- a/internal/preferences/speech.go
+++ b/internal/preferences/speech.go
@@ -342,6 +342,9 @@ func (session *uiSession) get_speech_values(values *Values) error {
values.UppercaseVoice, values.HyperlinkVoice, values.SystemVoice = session.voiceDrafts[1], session.voiceDrafts[2], session.voiceDrafts[3]
values.VoiceType = []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
values.EchoVoice, values.EchoSpeechServer = session.getEchoVoice()
+ // The synthesizer dropdown now owns this choice directly. Retain the
+ // existing settings field so older profiles keep their meaning on load.
+ values.UseCustomEchoSpeechServer = true
values.SpeechServerFactory = "speechdispatcherfactory"
if session.activeSpeechSystem == 1 {
values.SpeechServerFactory = "hardwarefactory"
diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go
index a3b4578..2d79d56 100644
--- a/internal/preferences/ui.go
+++ b/internal/preferences/ui.go
@@ -70,6 +70,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
tentacles.populate(initial.Tentacles)
var tentaclePending *tentacle.ManagementRequest
C.prefs_voice_changed()
+ C.prefs_echo_preview(nil)
status(initial.Message, false)
canSave, busy := initial.CanSave, false
previewPending, previewDirty := false, false
@@ -122,6 +123,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
if response.Accepted {
session.setValues(response.Values)
C.prefs_voice_changed()
+ C.prefs_echo_preview(nil)
}
C.prefs_busy(0, boolean(canSave))
status(response.Message, !response.Accepted)
@@ -182,7 +184,15 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
C.prefs_busy(1, boolean(canSave))
continue
}
- if !previewDirty || previewPending || busy {
+ if previewPending || busy {
+ continue
+ }
+ echoPreview := ""
+ var echoFocus C.int
+ if control := int(C.prefs_echo_preview(&echoFocus)); control != 0 {
+ echoPreview = []string{"rate", "pitch", "volume", "synthesizer", "person"}[control-1]
+ }
+ if !previewDirty && echoPreview == "" {
continue
}
values, err := session.getValues()
@@ -191,7 +201,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
previewDirty = false
continue
}
- if err := encoder.Encode(Request{Values: values, Preview: true}); err != nil {
+ if err := encoder.Encode(Request{Values: values, Preview: true, EchoPreview: echoPreview, EchoPreviewFocus: echoFocus != 0}); err != nil {
return err
}
previewPending = true
diff --git a/tests/native_echo_preview.py b/tests/native_echo_preview.py
new file mode 100644
index 0000000..a3ca110
--- /dev/null
+++ b/tests/native_echo_preview.py
@@ -0,0 +1,209 @@
+"""Real reader/preferences/GTK flow against a private, completion-aware SSIP server."""
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import tempfile
+import threading
+import time
+
+from native_x11 import wait_for
+
+
+class PreviewSpeechServer:
+ def __init__(self, path):
+ self.listener = socket.socket(socket.AF_UNIX)
+ self.listener.bind(str(path))
+ self.listener.listen()
+ self.lock = threading.RLock()
+ self.clients = {}
+ self.spoken = []
+ threading.Thread(target=self.accept_clients, daemon=True).start()
+
+ def accept_clients(self):
+ while True:
+ try:
+ connection, _ = self.listener.accept()
+ except OSError:
+ return
+ with self.lock:
+ clientId = len(self.clients) + 1
+ client = {"socket": connection, "lock": threading.RLock(), "notifications": False,
+ "module": "main", "voice": "", "active": None}
+ self.clients[clientId] = client
+ threading.Thread(target=self.serve_client, args=(clientId, client), daemon=True).start()
+
+ def reply(self, client, text):
+ with client["lock"]:
+ try:
+ client["socket"].sendall(text.encode())
+ except OSError:
+ pass
+
+ def finish(self, clientId, client, record, canceled=False):
+ with self.lock:
+ if client["active"] is not record:
+ return
+ client["active"] = None
+ record["finished"], record["canceled"] = True, canceled
+ if client["notifications"]:
+ code = 703 if canceled else 702
+ self.reply(client, f"{code}-1\r\n{code}-{clientId}\r\n{code} DONE\r\n")
+
+ def serve_client(self, clientId, client):
+ try:
+ with client["socket"].makefile("rb") as stream:
+ for raw in stream:
+ command = raw.decode().strip()
+ response = "200 OK\r\n"
+ if command == "HISTORY GET CLIENT_ID":
+ response = f"245-{clientId}\r\n245 OK\r\n"
+ elif command == "LIST OUTPUT_MODULES":
+ response = "200-echo-a\r\n200-echo-b\r\n200-main\r\n200 OK\r\n"
+ elif command == "LIST SYNTHESIS_VOICES":
+ voice = {"echo-a": "Echo A", "echo-b": "Echo B", "main": "Main"}[client["module"]]
+ response = f"200-{voice}\ten\tnone\r\n200 OK\r\n"
+ elif command.startswith("SET SELF OUTPUT_MODULE "):
+ client["module"] = command.removeprefix("SET SELF OUTPUT_MODULE ")
+ elif command.startswith("SET SELF SYNTHESIS_VOICE "):
+ client["voice"] = command.removeprefix("SET SELF SYNTHESIS_VOICE ")
+ elif command.startswith("SET SELF NOTIFICATION ALL "):
+ client["notifications"] = command.endswith(" on")
+ elif command.startswith("CANCEL "):
+ with self.lock:
+ targetId = int(command.split()[1])
+ target = self.clients[targetId]
+ if target["active"] is not None:
+ self.finish(targetId, target, target["active"], canceled=True)
+ elif command == "SPEAK":
+ parts = []
+ for line in stream:
+ if line == b".\r\n":
+ break
+ parts.append(line.decode().rstrip("\r\n"))
+ record = {"text": "\n".join(parts), "module": client["module"],
+ "voice": client["voice"], "finished": False, "canceled": False}
+ with self.lock:
+ self.spoken.append(record)
+ client["active"] = record
+ self.reply(client, "230 OK\r\n200 QUEUED\r\n")
+ if client["notifications"]:
+ self.reply(client, f"701-1\r\n701-{clientId}\r\n701 BEGIN\r\n")
+ timer = threading.Timer(0.25, self.finish, args=(clientId, client, record))
+ timer.daemon = True
+ timer.start()
+ continue
+ self.reply(client, response)
+ except (OSError, ValueError):
+ pass
+
+ def snapshot(self):
+ with self.lock:
+ return [dict(record) for record in self.spoken]
+
+ def close(self):
+ self.listener.close()
+ for client in self.clients.values():
+ client["socket"].close()
+
+
+def check_runtime():
+ import pyatspi
+ directory = Path(tempfile.mkdtemp(prefix="cthulhu-echo-runtime-"))
+ binary = os.environ["CTHULHU_BINARY"]
+ wmConfig = directory / "i3.config"
+ wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n")
+ server = PreviewSpeechServer(directory / "speech.sock")
+ wm = subprocess.Popen(["i3", "-c", str(wmConfig)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ reader = None
+ try:
+ wait_for(lambda: "0x" in subprocess.check_output(["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing")
+ configPath = directory / "settings.toml"
+ configPath.write_text('''schema-version = 2
+active-profile = "default"
+[profiles.default.speech]
+output-module = "main"
+[profiles.default.braille]
+enabled = false
+[profiles.default.sound]
+enabled = false
+[profiles.default.key-echo]
+enabled = false
+custom-voice = true
+custom-speech-server = true
+speech-server = "echo-a"
+[profiles.default.key-echo.voice]
+rate = 50
+average-pitch = 5
+gain = 5
+[profiles.default.key-echo.voice.family]
+name = "Echo A"
+lang = "en"
+''')
+ reader = subprocess.Popen([binary, "--config", str(configPath), "--settings", "--no-tentacles",
+ "--speech-socket", str(directory / "speech.sock"),
+ "--debug-file", str(directory / "reader.log")],
+ stdout=subprocess.DEVNULL, stderr=(directory / "stderr.log").open("w"))
+ def find_window():
+ for application in pyatspi.Registry.getDesktop(0):
+ for window in application:
+ if window and window.name == "Cthulhu Preferences":
+ return window
+ window = wait_for(find_window, "reader's preferences did not open")
+ def walk(node):
+ yield node
+ for child in node:
+ if child is not None:
+ yield from walk(child)
+ controls = {node.name: node for node in walk(window) if node.name}
+ xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Preferences$"], text=True).splitlines()[0]
+ subprocess.run(["xdotool", "windowactivate", "--sync", xid], check=True)
+ tabs = next(node for node in walk(window) if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
+ assert tabs.querySelection().selectChild(4)
+ assert controls["Use custom echo voice"].queryComponent().grabFocus()
+ wait_for(lambda: any(record["text"] == "checked" and record["finished"]
+ for record in server.snapshot()), "initial checkbox announcement did not finish")
+ time.sleep(0.3)
+ def key(*keys):
+ subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True)
+ def check_output(keys, expected):
+ offset = len(server.snapshot())
+ key(*keys)
+ def completed():
+ records = server.snapshot()[offset:]
+ matching = [record for record in records if record["text"] in {text for text, _, _ in expected}]
+ return len(matching) >= len(expected) and all(record["finished"] for record in matching)
+ wait_for(completed, f"preview did not complete: {keys}")
+ # The original delayed menu-item event followed its preview by up
+ # to 160 ms. Require quiescence beyond that delay, then inspect all
+ # speech requests, including duplicates in the main voice.
+ time.sleep(0.25)
+ records = server.snapshot()[offset:]
+ got = [(record["text"], record["module"], record["voice"]) for record in records]
+ assert got == expected, (keys, got, expected)
+ assert not any(record["canceled"] for record in records), (keys, records)
+ check_output(("Tab",), [("Key echo speech synthesizer", "main", ""), ("echo-a", "echo-a", "Echo A")])
+ check_output(("Down",), [("echo-b", "echo-b", "")])
+ check_output(("Tab",), [("Key echo person", "main", ""), ("Default", "echo-b", "")])
+ check_output(("Down",), [("Echo B (en)", "echo-b", "Echo B")])
+ for control in ("rate", "pitch", "volume"):
+ value = "50.0" if control == "rate" else "5.0"
+ check_output(("Tab",), [("Key echo " + control, "main", ""), (value, "echo-b", "Echo B")])
+ for control in ("pitch", "rate", "person", "synthesizer"):
+ value = {"pitch": "5.0", "rate": "50.0", "person": "Echo B (en)", "synthesizer": "echo-b"}[control]
+ label = "Key echo speech synthesizer" if control == "synthesizer" else "Key echo " + control
+ check_output(("shift+Tab",), [(label, "main", ""), (value, "echo-b", "Echo B")])
+ print(f"PASS: real reader + GTK + SSIP focus/selection previews, voice completion, no duplicate menu-item speech; evidence: {directory}")
+ finally:
+ if reader is not None:
+ reader.terminate()
+ try:
+ reader.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ reader.kill()
+ reader.wait(timeout=5)
+ wm.terminate()
+ wm.wait(timeout=5)
+ server.close()
+ (directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2))
diff --git a/tests/native_preferences.py b/tests/native_preferences.py
index f56be76..01386e7 100644
--- a/tests/native_preferences.py
+++ b/tests/native_preferences.py
@@ -13,7 +13,42 @@ sys.dont_write_bytecode = True
from native_x11 import wait_for
+class ResponseReader:
+ """Read complete JSON lines with one deadline, preserving read-ahead bytes."""
+
+ def __init__(self, stream):
+ self.stream = stream
+ self.pendingData = bytearray()
+
+ def request(self, timeout=5):
+ deadline = time.monotonic() + timeout
+ while b"\n" not in self.pendingData:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0 or not select.select([self.stream], [], [], remaining)[0]:
+ raise TimeoutError("no complete preferences request within response timeout")
+ # Never mix buffered reads with select: TextIO can retain another
+ # complete reply even when the underlying pipe is empty.
+ chunk = os.read(self.stream.fileno(), 65536)
+ if not chunk:
+ raise EOFError("preferences helper closed stdout before a complete request")
+ self.pendingData.extend(chunk)
+ end = self.pendingData.index(b"\n") + 1
+ line = self.pendingData[:end]
+ del self.pendingData[:end]
+ return json.loads(line)
+
+ def read_remaining(self):
+ # Used only after helper exit; include any bytes read ahead by request.
+ remaining = bytes(self.pendingData) + self.stream.buffer.read()
+ self.pendingData.clear()
+ return remaining.decode("utf-8")
+
+
def inside():
+ if os.environ.get("CTHULHU_PREFS_ECHO_RUNTIME_ONLY") == "1":
+ from native_echo_preview import check_runtime
+ check_runtime()
+ return
if os.environ.get("CTHULHU_PREFS_ADAPTER_ONLY") == "1":
environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1")
environment.setdefault("GOCACHE", "/tmp/cthulhu-cache")
@@ -40,6 +75,7 @@ def inside():
binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu"))
process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True)
+ responses = ResponseReader(process.stdout)
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
"SpeechHistoryLimit": 50,
"Reading": {"AutomaticallyReadTitleChanges": True},
@@ -71,6 +107,17 @@ def inside():
"test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"},
{"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]},
"CanSave": True, "Accepted": True, "Message": "Preferences test ready."}
+ echoFocusMode = os.environ.get("CTHULHU_PREFS_ECHO_PREVIEW_ONLY") == "1"
+ echo_mode = os.environ.get("CTHULHU_PREFS_KEY_ECHO_ONLY") or ("custom" if echoFocusMode else None)
+ if echo_mode:
+ initial["Catalog"]["echo-module"] = [
+ {"Name": "Echo test voice", "Language": "en", "Variant": "none"}]
+ initial["Values"].update(
+ SpeechOutputModule="test-module", EchoSpeechServer="echo-module",
+ UseCustomEchoVoice=echo_mode == "custom",
+ UseCustomEchoSpeechServer=echo_mode == "custom",
+ EnableEchoByCharacter=True, EnableEchoBySentence=True,
+ UseCustomEchoForCharacter=True)
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"])
if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1":
@@ -129,8 +176,12 @@ def inside():
assert node.queryComponent().grabFocus(), ("cannot focus", node.name)
wait_for(lambda: any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)), "focus missing")
def request():
- assert select.select([process.stdout], [], [], 5)[0], "no Apply request"
- return json.loads(process.stdout.readline())
+ while True:
+ value = responses.request()
+ if value.get("EchoPreviewFocus") and not echoFocusMode:
+ send({"Preview": True, "Accepted": True})
+ continue
+ return value
try:
send_initial(initial)
window = wait_for(find_window, "preferences accessible window missing")
@@ -141,6 +192,141 @@ def inside():
xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0]
subprocess.run(["xdotool", "windowfocus", xid], check=True)
assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences"
+ if echoFocusMode:
+ tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
+ assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
+ def check_preview(control, isFocus):
+ value = request()
+ assert value["Preview"] and value.get("EchoPreview") == control, value
+ assert bool(value.get("EchoPreviewFocus")) == isFocus, value
+ send({"Preview": True, "Accepted": True})
+ return value["Values"]
+ focus(named("Use custom echo voice"))
+ key("Tab")
+ check_preview("synthesizer", True)
+ key("Down")
+ value = check_preview("synthesizer", False)
+ assert value["EchoSpeechServer"] == "test-module", value
+ key("Up")
+ value = check_preview("synthesizer", False)
+ assert value["EchoSpeechServer"] == "echo-module", value
+ key("Tab")
+ check_preview("person", True)
+ key("Down")
+ value = check_preview("person", False)
+ assert value["EchoVoice"]["Family"]["Name"] == "Echo test voice", value
+ for control in ("rate", "pitch", "volume"):
+ key("Tab")
+ check_preview(control, True)
+ key("Right")
+ check_preview(control, False)
+ for control in ("pitch", "rate", "person", "synthesizer"):
+ key("shift+Tab")
+ check_preview(control, True)
+ # Popup Escape restores the same combo without trapping focus.
+ key("alt+Down", "Escape", "Tab")
+ check_preview("person", True)
+ focus(named("Cancel")); key("space")
+ assert process.wait(timeout=5) == 0
+ print("PASS: all five echo controls preview their current values on Tab/Shift+Tab and changed values on arrows; popup Escape and Cancel")
+ return
+ if echo_mode:
+ assert "Use custom speech synthesizer for key echo" not in controls
+ assert "Use custom voice for key echo" not in controls
+ assert "Use the custom voice for:" in controls
+ tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
+ assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
+ custom = named("Use custom echo voice")
+ synth = named("Key echo speech synthesizer")
+ family = named("Key echo person")
+ rate = named("Key echo rate")
+ expected_module = "echo-module" if echo_mode == "custom" else "test-module"
+ assert synth.querySelection().getSelectedChild(0).name == expected_module
+ if echo_mode != "custom":
+ # An unused saved echo module must not override the main one.
+ assert not synth.getState().contains(pyatspi.STATE_SENSITIVE)
+ assert not family.getState().contains(pyatspi.STATE_SENSITIVE)
+ focus(custom); key("space")
+ wait_for(lambda: synth.getState().contains(pyatspi.STATE_SENSITIVE),
+ "custom echo did not enable the synthesizer directly")
+ def focused(node):
+ return any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node))
+ # GTK's internal combo button does not consistently expose its
+ # focused state. Exercise the popup, value and following Tab stop.
+ focus(custom); key("Tab", "alt+Down", "Home", "Down")
+ if echo_mode != "custom":
+ key("Down")
+ key("Return")
+ wait_for(lambda: synth.description == expected_module, "Tab did not reach echo synthesizer")
+ key("Tab", "Tab")
+ wait_for(lambda: focused(rate), "voice selector trapped Tab")
+ key("shift+Tab", "shift+Tab")
+ key("shift+Tab")
+ wait_for(lambda: focused(custom), "synthesizer trapped Shift+Tab")
+ assert named("Key names").getState().contains(pyatspi.STATE_CHECKED)
+ assert named("Typed characters").getState().contains(pyatspi.STATE_CHECKED)
+ for name in ("Key names", "Typed characters", "Words", "Sentences"):
+ assert named(name).getRole() == pyatspi.ROLE_CHECK_BOX
+ assert named(name).getState().contains(pyatspi.STATE_SENSITIVE)
+ focus(named("Words")); key("space")
+ def apply_echo():
+ focus(named("Apply")); key("space")
+ while True:
+ applied = request()
+ if applied["Preview"]:
+ send({"Preview": True, "Accepted": True})
+ continue
+ assert applied["Save"] and not applied["Close"], applied
+ values = applied["Values"]
+ send(dict(initial, Values=values, Message="Echo settings saved."))
+ wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE),
+ "echo Apply stayed busy")
+ return values
+ saved = apply_echo()
+ assert saved["SpeechOutputModule"] == "test-module", saved
+ assert saved["UseCustomEchoVoice"] and saved["UseCustomEchoSpeechServer"], saved
+ assert saved["EchoSpeechServer"] == expected_module, saved
+ assert saved["UseCustomEchoForKey"] and saved["UseCustomEchoForCharacter"], saved
+ assert saved["UseCustomEchoForWord"] and not saved["UseCustomEchoForSentence"], saved
+ # Select Default first to guarantee a change in either startup
+ # mode. Only the selected combo value gets an echo preview.
+ def accept_echo_preview(control, module):
+ preview = request()
+ assert preview["Preview"] and preview.get("EchoPreview") == control, preview
+ assert preview["Values"]["EchoSpeechServer"] == module, preview
+ send({"Preview": True, "Accepted": True})
+ return preview["Values"]
+ focus(custom); key("Tab", "alt+Down", "Home", "Return")
+ preview = accept_echo_preview("synthesizer", "")
+ assert preview["EchoVoice"]["Family"] is None, preview
+ # Change the synthesizer using its keyboard popup, then select a
+ # person and verify each sends its newly selected draft voice.
+ focus(custom); key("Tab", "alt+Down", "Home", "Down", "Return")
+ accept_echo_preview("synthesizer", "echo-module")
+ wait_for(lambda: synth.description == "echo-module", "echo synthesizer did not change")
+ wait_for(lambda: any(child.name == "Echo test voice (en)" for child in walk(family)),
+ "echo voices did not follow the selected synthesizer")
+ key("Tab", "alt+Down", "Home", "Down", "Return")
+ preview = accept_echo_preview("person", "echo-module")
+ assert preview["EchoVoice"]["Family"]["Name"] == "Echo test voice", preview
+ saved = apply_echo()
+ assert saved["EchoSpeechServer"] == "echo-module", saved
+ assert saved["EchoVoice"]["Family"]["Name"] == "Echo test voice", saved
+ assert synth.querySelection().getSelectedChild(0).name == "echo-module"
+ assert family.querySelection().getSelectedChild(0).name == "Echo test voice (en)"
+ focus(custom); key("space")
+ wait_for(lambda: not synth.getState().contains(pyatspi.STATE_SENSITIVE),
+ "custom echo off left the synthesizer enabled")
+ saved = apply_echo()
+ assert not saved["UseCustomEchoVoice"] and saved["EchoSpeechServer"] == "echo-module", saved
+ focus(custom); key("space")
+ saved = apply_echo()
+ assert saved["UseCustomEchoVoice"] and saved["EchoSpeechServer"] == "echo-module", saved
+ assert saved["EchoVoice"]["Family"]["Name"] == "Echo test voice", saved
+ focus(named("Cancel")); key("space")
+ assert process.wait(timeout=5) == 0
+ print("PASS: echo synthesizer initialization, keyboard navigation, synthesizer/person previews, routing choices, Apply/reload, disable/re-enable")
+ return
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
from native_tentacle_settings import check_page
check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial)
@@ -190,7 +376,7 @@ def inside():
focus(automatic); key("space")
key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close OCR preferences"
- assert process.stdout.read() == "", "cancel submitted changed OCR settings"
+ assert responses.read_remaining() == "", "cancel submitted changed OCR settings"
print("PASS: OCR layout Tab/Shift+Tab, popup selection, automatic reading checkbox, Apply, rollback, Cancel")
return
if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1":
@@ -240,9 +426,10 @@ def inside():
key("ctrl+Page_Down", "ctrl+Page_Down")
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing")
values = initial["Values"]
- def accept_preview():
+ def accept_preview(echoPreview=""):
preview = request()
assert preview["Preview"], preview
+ assert preview.get("EchoPreview", "") == echoPreview, preview
send({"Preview": True, "Accepted": True})
return preview["Values"]
for name, field in (("Rate", "Rate"), ("Pitch", "Pitch"), ("Volume", "Volume")):
@@ -276,6 +463,7 @@ def inside():
for name, field in (("Key echo rate", "Rate"), ("Key echo pitch", "AveragePitch"), ("Key echo volume", "Gain")):
slider = named(name)
focus(slider)
+ echoPreview = {"Rate": "rate", "AveragePitch": "pitch", "Gain": "volume"}[field]
assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment")
for arrow, expected in (("Right", 5.1 if field != "Rate" else 50.1),
("Up", 6.1 if field != "Rate" else 51.1),
@@ -283,15 +471,19 @@ def inside():
("Down", 5 if field != "Rate" else 50),
("Left", 4.9 if field != "Rate" else 49.9)):
key(arrow)
+ values = accept_preview(echoPreview)
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
f"{name}: {arrow} did not produce {expected}")
+ assert abs(values["EchoVoice"][field] - expected) < 1e-8, values
finalValue = expected
for bound, arrows in ((0, ("Left", "Down")),
(slider.queryValue().maximumValue, ("Right", "Up"))):
slider.queryValue().currentValue = bound
+ accept_preview(echoPreview)
key(*arrows)
assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
slider.queryValue().currentValue = finalValue
+ accept_preview(echoPreview)
focus(named("Apply")); key("space")
applied = request()
assert applied["Save"], applied
@@ -494,7 +686,7 @@ def inside():
values = accept_preview()
assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8
focus(named("Cancel")); key("space")
- assert process.wait(timeout=2) == 0 and process.stdout.read() == ""
+ assert process.wait(timeout=2) == 0 and responses.read_remaining() == ""
print("PASS: independent voice editors, inheritance, previews, indentation modes, Apply/reload, Cancel")
return
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
@@ -592,7 +784,7 @@ def inside():
assert process.poll() is None, "dropdown Escape closed the whole preferences window"
key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
- assert process.stdout.read() == "", "Escape submitted changes"
+ assert responses.read_remaining() == "", "Escape submitted changes"
print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape")
return
if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1":
@@ -672,7 +864,7 @@ def inside():
if readingTable.getAccessibleAt(row, 1).name == "Speak object under mouse"), 0).name == "checked"
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
- assert process.stdout.read() == "", "Cancel submitted reading changes"
+ assert responses.read_remaining() == "", "Cancel submitted reading changes"
print("PASS: reading table, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel")
return
if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1":
@@ -727,7 +919,7 @@ def inside():
assert_modifiers({"Insert", "Numpad Insert"})
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
- assert process.stdout.read() == "", "Cancel submitted modifier changes"
+ assert responses.read_remaining() == "", "Cancel submitted modifier changes"
print("PASS: modifier checkbox table, accessible states, Space, Tab/Shift+Tab, desktop/laptop defaults, multiple/empty selections, Apply/reload, Cancel")
return
if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1":
@@ -804,7 +996,7 @@ def inside():
set_binding("F9")
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
- assert process.stdout.read() == "", "Cancel submitted bindings"
+ assert responses.read_remaining() == "", "Cancel submitted bindings"
print("PASS: accessible binding editor, Tab/Shift+Tab, conflict, save/reload, clear, reset, laptop browse, reset all, Cancel")
return
if os.environ.get("CTHULHU_PREFS_BRAILLE_ONLY") == "1":
@@ -1095,7 +1287,7 @@ def inside():
focus(named("Key echo volume")); named("Key echo volume").queryValue().currentValue = 10
family = named("Key echo person")
family.querySelection().selectChild(1)
- word_voice = named("Use custom voice for word echo")
+ word_voice = named("Words")
assert not word_voice.getState().contains(pyatspi.STATE_CHECKED)
focus(word_voice); key("space")
wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED),
@@ -1128,7 +1320,7 @@ def inside():
subprocess.run(["xdotool", "windowfocus", xid], check=True)
key("Escape")
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
- assert process.stdout.read() == "", "Escape submitted extra settings"
+ assert responses.read_remaining() == "", "Escape submitted extra settings"
print("PASS: GTK notebook, roles, label buddies, Tab/Shift+Tab, Apply, failed apply, OK, Escape")
finally:
if reader_process is not None:
diff --git a/tests/test_preferences_responses.py b/tests/test_preferences_responses.py
new file mode 100644
index 0000000..6f609a5
--- /dev/null
+++ b/tests/test_preferences_responses.py
@@ -0,0 +1,59 @@
+"""Pipe-level regressions for the native preferences harness response reader."""
+import os
+import select
+import unittest
+from unittest.mock import patch
+
+from native_preferences import ResponseReader
+
+
+class ResponseReaderTests(unittest.TestCase):
+ def setUp(self):
+ readFd, writeFd = os.pipe()
+ self.stream = os.fdopen(readFd, encoding="utf-8")
+ self.writer = os.fdopen(writeFd, "wb", buffering=0)
+ self.addCleanup(self.stream.close)
+ self.addCleanup(self.writer.close)
+ self.reader = ResponseReader(self.stream)
+
+ def test_batched_preview_and_save(self):
+ self.writer.write(b'{"Preview": true}\n{"Save": true}\n')
+ self.assertEqual(self.reader.request(), {"Preview": True})
+ self.assertFalse(select.select([self.stream], [], [], 0)[0])
+ self.assertEqual(self.reader.request(timeout=0), {"Save": True})
+
+ def test_missing_response_times_out(self):
+ with self.assertRaises(TimeoutError):
+ self.reader.request(timeout=0.01)
+
+ def test_partial_response_times_out_and_can_resume(self):
+ self.writer.write(b'{"Name": "\xc3')
+ with self.assertRaises(TimeoutError):
+ self.reader.request(timeout=0.01)
+ self.writer.write(b'\xa9"}\n')
+ self.assertEqual(self.reader.request(), {"Name": "\u00e9"})
+
+ def test_partial_reads_share_one_deadline(self):
+ self.writer.write(b'{"Save":')
+ with patch("native_preferences.time.monotonic", side_effect=[10, 10, 16]):
+ with self.assertRaises(TimeoutError):
+ self.reader.request(timeout=5)
+
+ def test_eof_is_reported_after_complete_responses(self):
+ self.writer.write(b'{"Save": true}\n{"Close":')
+ self.writer.close()
+ self.assertEqual(self.reader.request(), {"Save": True})
+ with self.assertRaises(EOFError):
+ self.reader.request()
+
+ def test_remaining_output_includes_read_ahead_and_pipe_bytes(self):
+ self.writer.write(b'{"Preview": true}\n{"Name": "\xc3')
+ self.assertEqual(self.reader.request(), {"Preview": True})
+ self.writer.write(b'\xa9"}\n')
+ self.writer.close()
+ self.assertEqual(self.reader.read_remaining(), '{"Name": "\u00e9"}\n')
+ self.assertEqual(self.reader.read_remaining(), "")
+
+
+if __name__ == "__main__":
+ unittest.main()