From 0834b47b5556768a08e01bf45448909df6116307 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 15 Oct 2026 23:33:02 -0400 Subject: [PATCH] Echo voice now used for its adjusters so prieview can be done while setting the voice. --- README.md | 21 ++ internal/app/app.go | 3 + internal/app/configured_speaker.go | 3 + internal/app/echo_speaker_test.go | 67 ++++- internal/app/event_presentation.go | 6 + internal/app/event_state.go | 2 + internal/app/live_settings.go | 1 + internal/app/preferences.go | 21 +- internal/app/preferences_echo_preview_test.go | 279 ++++++++++++++++++ internal/app/preferences_preview.go | 80 +++++ internal/app/preferences_preview_events.go | 47 +++ internal/app/preferences_session.go | 4 + internal/app/speech_delivery.go | 4 +- .../atspi/adapter_event_callback_native.go | 4 + internal/atspi/model.go | 2 + internal/config/validation.go | 2 +- internal/preferences/echo.go | 23 +- internal/preferences/native.c | 45 ++- internal/preferences/native.h | 1 + internal/preferences/preferences.ui | 13 +- internal/preferences/protocol.go | 5 + internal/preferences/speech.go | 3 + internal/preferences/ui.go | 14 +- tests/native_echo_preview.py | 209 +++++++++++++ tests/native_preferences.py | 214 +++++++++++++- tests/test_preferences_responses.py | 59 ++++ 26 files changed, 1080 insertions(+), 52 deletions(-) create mode 100644 internal/app/preferences_echo_preview_test.go create mode 100644 internal/app/preferences_preview_events.go create mode 100644 tests/native_echo_preview.py create mode 100644 tests/test_preferences_responses.py 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 navigation keysTrue Enable diacritical keysTrue Use custom echo voiceTrue - Use custom speech synthesizer for key echoTrue + Speech synthesizerechoSpeechServers0 FalseDefault + VoiceechoSpeechFamilies0 FalseDefault 1echo_rate_adjustmentTrueTrueKey echo rate 1echo_pitch_adjustmentTrueTrueKey echo pitch 1echo_volume_adjustmentTrueTrueKey echo volume - Use custom voice for key echoTrue - Use custom voice for character echoTrue - Use custom voice for word echoTrue - Use custom voice for sentence echoTrue + Use the custom voice for:vertical78 + Key namesTrue + Typed charactersTrue + WordsTrue + SentencesTrue + 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()