Echo voice now used for its adjusters so prieview can be done while setting the voice.
This commit is contained in:
1 parent
70d447667e
commit
0834b47b55
26 files changed
+1080
-52
No files matched your search
@@ -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
|
configurations keep their previous enabled or disabled behavior. The new setting
|
||||||
is `edit-fields-only` in the profile's `key-echo` table.
|
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
|
Native controls must expose editable accessibility metadata. This includes
|
||||||
standard entries, text editors and editable browser controls. Read-only fields,
|
standard entries, text editors and editable browser controls. Read-only fields,
|
||||||
password contents, buttons, menus and terminals do not enable this mode. Controls
|
password contents, buttons, menus and terminals do not enable this mode. Controls
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ func Run(ctx context.Context, options Options) error {
|
|||||||
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true})
|
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true})
|
||||||
brailleOutput.FlashConfirmation(message, live.Load().Braille)
|
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})
|
services = append(services, lifecycle.Service{Name: "preferences", Start: preferences.Start, Stop: preferences.Stop})
|
||||||
|
|
||||||
notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList)
|
notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList)
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ func (speaker *hardwareSpeaker) speak_character(ctx context.Context, text string
|
|||||||
return speaker.speak(ctx, text, echo, kind)
|
return speaker.speak(ctx, text, echo, kind)
|
||||||
}
|
}
|
||||||
func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind string) error {
|
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)
|
return speaker.speak(ctx, text, false, kind)
|
||||||
}
|
}
|
||||||
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
|
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
"git.stormux.org/storm/cthulhu/internal/config"
|
"git.stormux.org/storm/cthulhu/internal/config"
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,8 +35,9 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
type utterance struct {
|
type utterance struct {
|
||||||
module, text string
|
module, text string
|
||||||
rate, pitch string
|
rate, pitch string
|
||||||
|
volume, voice string
|
||||||
}
|
}
|
||||||
spoken := make(chan utterance, 10)
|
spoken := make(chan utterance, 10)
|
||||||
var handlers sync.WaitGroup
|
var handlers sync.WaitGroup
|
||||||
@@ -53,6 +55,8 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
reader := bufio.NewReader(conn)
|
reader := bufio.NewReader(conn)
|
||||||
module, rate, pitch := "", "", ""
|
module, rate, pitch := "", "", ""
|
||||||
|
volume, voice := "", ""
|
||||||
|
notifications := false
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,6 +64,11 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
|
|||||||
}
|
}
|
||||||
command := strings.TrimSpace(line)
|
command := strings.TrimSpace(line)
|
||||||
response := "200 OK\r\n"
|
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 ") {
|
if strings.HasPrefix(command, "SET SELF OUTPUT_MODULE ") {
|
||||||
module = strings.TrimPrefix(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 ") {
|
if strings.HasPrefix(command, "SET SELF PITCH ") {
|
||||||
pitch = strings.TrimPrefix(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" {
|
if command == "SET SELF OUTPUT_MODULE rejected" {
|
||||||
response = "400 Module unavailable\r\n"
|
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)
|
response = fmt.Sprintf("245-%d\r\n245 OK\r\n", id)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(command, "CHAR ") {
|
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" {
|
if command == "SPEAK" {
|
||||||
var text strings.Builder
|
var text strings.Builder
|
||||||
@@ -90,8 +105,11 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
|
|||||||
}
|
}
|
||||||
text.WriteString(strings.TrimSuffix(part, "\r\n"))
|
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"
|
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 {
|
if _, err := conn.Write([]byte(response)); err != nil {
|
||||||
return
|
return
|
||||||
@@ -141,6 +159,47 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
|
|||||||
}
|
}
|
||||||
check(false, "", "")
|
check(false, "", "")
|
||||||
check(true, "echo-module", "62")
|
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) {
|
checkCharacters := func(wantNormal string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
|
|||||||
foreground := presenter.foreground
|
foreground := presenter.foreground
|
||||||
inputSource := presenter.inputSource
|
inputSource := presenter.inputSource
|
||||||
live := presenter.live
|
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
|
browser := presenter.browser
|
||||||
ownedAutocompletePopup := state.ownedAutocompletePopup
|
ownedAutocompletePopup := state.ownedAutocompletePopup
|
||||||
if foreground != nil && !eventBypassesXTermHandoff(event) &&
|
if foreground != nil && !eventBypassesXTermHandoff(event) &&
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ type eventPresentationState struct {
|
|||||||
lastLiveText string
|
lastLiveText string
|
||||||
lastLiveAt time.Time
|
lastLiveAt time.Time
|
||||||
comboHistory comboPresentation
|
comboHistory comboPresentation
|
||||||
|
echoPreview echoPreviewPresentation
|
||||||
// Retain this identity after the history window closes so delayed GTK
|
// Retain this identity after the history window closes so delayed GTK
|
||||||
// events cannot enter speech history.
|
// events cannot enter speech history.
|
||||||
historyWindow atspi.ObjectID
|
historyWindow atspi.ObjectID
|
||||||
@@ -167,6 +168,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer
|
|||||||
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
||||||
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
||||||
state.comboHistory = comboPresentation{}
|
state.comboHistory = comboPresentation{}
|
||||||
|
state.echoPreview = echoPreviewPresentation{}
|
||||||
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
|
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
|
||||||
state.lastAutocompletePopupItem = atspi.ObjectID{}
|
state.lastAutocompletePopupItem = atspi.ObjectID{}
|
||||||
state.autocompleteTyping = autocompleteEcho{}
|
state.autocompleteTyping = autocompleteEcho{}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
type liveSettings struct {
|
type liveSettings struct {
|
||||||
editFieldFocused func() bool // Installed before workers start; reads cached focus only.
|
editFieldFocused func() bool // Installed before workers start; reads cached focus only.
|
||||||
current atomic.Pointer[config.Settings]
|
current atomic.Pointer[config.Settings]
|
||||||
|
preferencesPID atomic.Int64 // Owned by the preferences window lifetime; zero when closed.
|
||||||
application atomic.Pointer[string]
|
application atomic.Pointer[string]
|
||||||
requestedApplication atomic.Pointer[string]
|
requestedApplication atomic.Pointer[string]
|
||||||
clipboardChanges chan struct{}
|
clipboardChanges chan struct{}
|
||||||
|
|||||||
+11
-10
@@ -55,16 +55,17 @@ type preferencesWorker struct {
|
|||||||
bindings interface {
|
bindings interface {
|
||||||
SetKeyBindings(context.Context, []input.BindingOverride) error
|
SetKeyBindings(context.Context, []input.BindingOverride) error
|
||||||
}
|
}
|
||||||
braille brailleSettingsUpdater
|
braille brailleSettingsUpdater
|
||||||
documentReview *review.Worker
|
documentReview *review.Worker
|
||||||
socket string
|
socket string
|
||||||
speechCommands chan input.Command
|
speechCommands chan input.Command
|
||||||
remoteChanges chan remoteSettingsChange
|
remoteChanges chan remoteSettingsChange
|
||||||
speechSetting int
|
speechSetting int
|
||||||
announce func(string)
|
announce func(string)
|
||||||
announceApply func(string)
|
announceApply func(string)
|
||||||
catalog speech.Catalog
|
announceEchoPreview func(string, string)
|
||||||
discover func(context.Context, string) (speech.Catalog, error)
|
catalog speech.Catalog
|
||||||
|
discover func(context.Context, string) (speech.Catalog, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker {
|
func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -7,12 +7,17 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/config"
|
"git.stormux.org/storm/cthulhu/internal/config"
|
||||||
"git.stormux.org/storm/cthulhu/internal/preferences"
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (worker *preferencesWorker) preview(ctx context.Context, request preferences.Request) preferences.Response {
|
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
|
next := worker.settings
|
||||||
if worker.editingActive {
|
if worker.editingActive {
|
||||||
next = worker.editing
|
next = worker.editing
|
||||||
@@ -48,6 +53,81 @@ func (worker *preferencesWorker) preview(ctx context.Context, request preference
|
|||||||
return response
|
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 {
|
func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response {
|
||||||
response := worker.response("Speech settings restored.", false)
|
response := worker.response("Speech settings restored.", false)
|
||||||
if err := worker.updateSpeech(ctx, worker.settings); err != nil {
|
if err := worker.updateSpeech(ctx, worker.settings); err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -65,6 +65,10 @@ func (worker *preferencesWorker) window(ctx context.Context) (result error) {
|
|||||||
out.Close()
|
out.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if worker.live != nil {
|
||||||
|
worker.live.preferencesPID.Store(int64(command.Process.Pid))
|
||||||
|
defer worker.live.preferencesPID.Store(0)
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
stop()
|
stop()
|
||||||
in.Close()
|
in.Close()
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
text := worker.transformText(worker.line_text(part.Text, part.Line))
|
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" {
|
if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" {
|
||||||
worker.history.add(text, request.Role)
|
worker.history.add(text, request.Role)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
|
||||||
snapshot.ApplicationProcess = adapter.applicationProcess(target)
|
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") {
|
if snapshot.Role == "combo box" && strings.EqualFold(snapshot.ApplicationProcess, "firefox") {
|
||||||
snapshot.Autocomplete = snapshot.Autocomplete ||
|
snapshot.Autocomplete = snapshot.Autocomplete ||
|
||||||
isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0)
|
isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0)
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ type Snapshot struct {
|
|||||||
ApplicationName string
|
ApplicationName string
|
||||||
// ApplicationProcess is resolved from the provider PID, not page content.
|
// ApplicationProcess is resolved from the provider PID, not page content.
|
||||||
ApplicationProcess string
|
ApplicationProcess string
|
||||||
|
// ApplicationPID identifies owned preferences controls during voice previews.
|
||||||
|
ApplicationPID int
|
||||||
// ToolkitName is captured for name-change events to select web policy
|
// ToolkitName is captured for name-change events to select web policy
|
||||||
// even before document focus or an address-bar event has arrived.
|
// even before document focus or an address-bar event has arrived.
|
||||||
ToolkitName string
|
ToolkitName string
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func validate(settings Settings) error {
|
|||||||
}
|
}
|
||||||
if settings.SpeechServerFactory == "hardwarefactory" {
|
if settings.SpeechServerFactory == "hardwarefactory" {
|
||||||
if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer {
|
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 {
|
switch settings.SpeechOutputModule {
|
||||||
case "litetalk", "doubletalk", "tripletalk", "dectalk":
|
case "litetalk", "doubletalk", "tripletalk", "dectalk":
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ func echoChecks(values *Values) []struct {
|
|||||||
{"enableEchoByWordCheckButton", &values.EnableEchoByWord},
|
{"enableEchoByWordCheckButton", &values.EnableEchoByWord},
|
||||||
{"enableEchoBySentenceCheckButton", &values.EnableEchoBySentence},
|
{"enableEchoBySentenceCheckButton", &values.EnableEchoBySentence},
|
||||||
{"useCustomEchoVoiceCheckButton", &values.UseCustomEchoVoice},
|
{"useCustomEchoVoiceCheckButton", &values.UseCustomEchoVoice},
|
||||||
{"useCustomEchoSpeechServerCheckButton", &values.UseCustomEchoSpeechServer},
|
|
||||||
{"useCustomEchoForKeyCheckButton", &values.UseCustomEchoForKey},
|
{"useCustomEchoForKeyCheckButton", &values.UseCustomEchoForKey},
|
||||||
{"useCustomEchoForCharacterCheckButton", &values.UseCustomEchoForCharacter},
|
{"useCustomEchoForCharacterCheckButton", &values.UseCustomEchoForCharacter},
|
||||||
{"useCustomEchoForWordCheckButton", &values.UseCustomEchoForWord},
|
{"useCustomEchoForWordCheckButton", &values.UseCustomEchoForWord},
|
||||||
@@ -48,6 +47,10 @@ func echoChecks(values *Values) []struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (session *uiSession) setEchoVoice(values Values) {
|
func (session *uiSession) setEchoVoice(values Values) {
|
||||||
|
module := values.SpeechOutputModule
|
||||||
|
if values.UseCustomEchoSpeechServer {
|
||||||
|
module = values.EchoSpeechServer
|
||||||
|
}
|
||||||
session.echoModules = []string{""}
|
session.echoModules = []string{""}
|
||||||
for module := range session.catalog {
|
for module := range session.catalog {
|
||||||
if module != "" {
|
if module != "" {
|
||||||
@@ -56,26 +59,21 @@ func (session *uiSession) setEchoVoice(values Values) {
|
|||||||
}
|
}
|
||||||
sort.Strings(session.echoModules[1:])
|
sort.Strings(session.echoModules[1:])
|
||||||
selected := 0
|
selected := 0
|
||||||
if values.EchoSpeechServer != "" {
|
if module != "" {
|
||||||
selected = -1
|
selected = -1
|
||||||
for i, module := range session.echoModules {
|
for i, candidate := range session.echoModules {
|
||||||
if module == values.EchoSpeechServer {
|
if candidate == module {
|
||||||
selected = i
|
selected = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected < 0 {
|
if selected < 0 {
|
||||||
selected = len(session.echoModules)
|
selected = len(session.echoModules)
|
||||||
session.echoModules = append(session.echoModules, values.EchoSpeechServer)
|
session.echoModules = append(session.echoModules, module)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
labels := append([]string(nil), session.echoModules...)
|
labels := append([]string(nil), session.echoModules...)
|
||||||
labels[0] = "Default"
|
labels[0] = "Default"
|
||||||
comboItems("echoSpeechServers", labels, selected)
|
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)
|
session.populateEchoFamilies(module, values.EchoVoice.Family)
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
id string
|
id string
|
||||||
@@ -129,10 +127,7 @@ func (session *uiSession) selectedEchoModule() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (session *uiSession) refreshEchoVoices() {
|
func (session *uiSession) refreshEchoVoices() {
|
||||||
module := ""
|
module := session.selectedEchoModule()
|
||||||
if getCheck("useCustomEchoSpeechServerCheckButton") {
|
|
||||||
module = session.selectedEchoModule()
|
|
||||||
}
|
|
||||||
if module != session.activeEchoModule {
|
if module != session.activeEchoModule {
|
||||||
session.populateEchoFamilies(module, nil)
|
session.populateEchoFamilies(module, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
static GtkBuilder *builder;
|
static GtkBuilder *builder;
|
||||||
static int action;
|
static int action;
|
||||||
static int voice_changed;
|
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.
|
// Borrowed from the builder for the duration of one Apply/OK request.
|
||||||
static GtkWidget *busy_focus;
|
static GtkWidget *busy_focus;
|
||||||
void prefs_set_action(int value) { action = value; }
|
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;
|
(void)control; (void)data;
|
||||||
voice_changed = 1;
|
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) {
|
static gboolean voice_scale_key_press(GtkWidget *control, GdkEventKey *event, gpointer data) {
|
||||||
(void)data;
|
(void)data;
|
||||||
if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE;
|
if (event->state & gtk_accelerator_get_default_mod_mask()) return FALSE;
|
||||||
@@ -99,9 +126,8 @@ static void echo_sensitivity(GtkToggleButton *button, gpointer data) {
|
|||||||
"enableDiacriticalKeysCheckButton"};
|
"enableDiacriticalKeysCheckButton"};
|
||||||
for (unsigned i = 0; i < G_N_ELEMENTS(categories); i++) gtk_widget_set_sensitive(prefs_widget(categories[i]), enabled);
|
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");
|
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);
|
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("useCustomEchoForKeyCheckButton"), custom && enabled);
|
||||||
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton"));
|
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForCharacterCheckButton"), custom && prefs_check_get("enableEchoByCharacterCheckButton"));
|
||||||
gtk_widget_set_sensitive(prefs_widget("useCustomEchoForWordCheckButton"), custom && prefs_check_get("enableEchoByWordCheckButton"));
|
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),
|
atk_object_set_description(gtk_widget_get_accessible(control),
|
||||||
"Up and Down change by 1; Left and Right change by 0.1.");
|
"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[] = {
|
const char *voiceCombos[] = {
|
||||||
"speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo",
|
"speechSystems", "speechServers", "speechLanguages", "speechFamilies", "voiceTypesCombo", "hardwareBaudCombo",
|
||||||
"echoSpeechServers", "echoSpeechFamilies",
|
|
||||||
};
|
};
|
||||||
for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) {
|
for (unsigned i = 0; i < G_N_ELEMENTS(voiceCombos); i++) {
|
||||||
g_signal_connect(prefs_widget(voiceCombos[i]), "changed",
|
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,
|
set_focus_chain("keyboard_layout_controls", keyboard_focus,
|
||||||
G_N_ELEMENTS(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);
|
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);
|
echo_sensitivity(NULL, NULL);
|
||||||
g_signal_connect(prefs_widget("enableSoundCheckButton"), "toggled", G_CALLBACK(sound_sensitivity), 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); }
|
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_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_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) {
|
void prefs_busy(int busy, int can_save) {
|
||||||
gtk_widget_set_sensitive(prefs_widget("notebook"), !busy);
|
gtk_widget_set_sensitive(prefs_widget("notebook"), !busy);
|
||||||
gtk_widget_set_sensitive(prefs_widget("apply"), !busy);
|
gtk_widget_set_sensitive(prefs_widget("apply"), !busy);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ void prefs_set_application(const char *name);
|
|||||||
void prefs_iterate(void);
|
void prefs_iterate(void);
|
||||||
int prefs_action(void);
|
int prefs_action(void);
|
||||||
int prefs_voice_changed(void);
|
int prefs_voice_changed(void);
|
||||||
|
int prefs_echo_preview(int *focused);
|
||||||
void prefs_busy(int busy, int can_save);
|
void prefs_busy(int busy, int can_save);
|
||||||
void prefs_present(void);
|
void prefs_present(void);
|
||||||
void prefs_close(void);
|
void prefs_close(void);
|
||||||
|
|||||||
@@ -209,16 +209,19 @@
|
|||||||
<child><object class="GtkCheckButton" id="enableNavigationKeysCheckButton"><property name="label">Enable navigation keys</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="enableNavigationKeysCheckButton"><property name="label">Enable navigation keys</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="enableDiacriticalKeysCheckButton"><property name="label">Enable diacritical keys</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="enableDiacriticalKeysCheckButton"><property name="label">Enable diacritical keys</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="useCustomEchoVoiceCheckButton"><property name="label">Use custom echo voice</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="useCustomEchoVoiceCheckButton"><property name="label">Use custom echo voice</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="useCustomEchoSpeechServerCheckButton"><property name="label">Use custom speech synthesizer for key echo</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkLabel"><property name="label">Speech synthesizer</property><property name="mnemonic-widget">echoSpeechServers</property><property name="xalign">0</property></object></child>
|
||||||
<child><object class="GtkComboBoxText" id="echoSpeechServers"><property name="can-focus">False</property><items><item>Default</item></items></object></child>
|
<child><object class="GtkComboBoxText" id="echoSpeechServers"><property name="can-focus">False</property><items><item>Default</item></items></object></child>
|
||||||
|
<child><object class="GtkLabel"><property name="label">Voice</property><property name="mnemonic-widget">echoSpeechFamilies</property><property name="xalign">0</property></object></child>
|
||||||
<child><object class="GtkComboBoxText" id="echoSpeechFamilies"><property name="can-focus">False</property><items><item>Default</item></items></object></child>
|
<child><object class="GtkComboBoxText" id="echoSpeechFamilies"><property name="can-focus">False</property><items><item>Default</item></items></object></child>
|
||||||
<child><object class="GtkScale" id="echoRateScale"><property name="digits">1</property><property name="adjustment">echo_rate_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo rate</property></object></child>
|
<child><object class="GtkScale" id="echoRateScale"><property name="digits">1</property><property name="adjustment">echo_rate_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo rate</property></object></child>
|
||||||
<child><object class="GtkScale" id="echoPitchScale"><property name="digits">1</property><property name="adjustment">echo_pitch_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo pitch</property></object></child>
|
<child><object class="GtkScale" id="echoPitchScale"><property name="digits">1</property><property name="adjustment">echo_pitch_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo pitch</property></object></child>
|
||||||
<child><object class="GtkScale" id="echoVolumeScale"><property name="digits">1</property><property name="adjustment">echo_volume_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo volume</property></object></child>
|
<child><object class="GtkScale" id="echoVolumeScale"><property name="digits">1</property><property name="adjustment">echo_volume_adjustment</property><property name="can-focus">True</property><property name="hexpand">True</property><property name="tooltip-text">Key echo volume</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="useCustomEchoForKeyCheckButton"><property name="label">Use custom voice for key echo</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkFrame"><property name="label">Use the custom voice for:</property><child><object class="GtkBox"><property name="orientation">vertical</property><property name="spacing">7</property><property name="border-width">8</property>
|
||||||
<child><object class="GtkCheckButton" id="useCustomEchoForCharacterCheckButton"><property name="label">Use custom voice for character echo</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="useCustomEchoForKeyCheckButton"><property name="label">Key names</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="useCustomEchoForWordCheckButton"><property name="label">Use custom voice for word echo</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="useCustomEchoForCharacterCheckButton"><property name="label">Typed characters</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="useCustomEchoForSentenceCheckButton"><property name="label">Use custom voice for sentence echo</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="useCustomEchoForWordCheckButton"><property name="label">Words</property><property name="can-focus">True</property></object></child>
|
||||||
|
<child><object class="GtkCheckButton" id="useCustomEchoForSentenceCheckButton"><property name="label">Sentences</property><property name="can-focus">True</property></object></child>
|
||||||
|
</object></child></object></child>
|
||||||
<child><object class="GtkCheckButton" id="enableEchoByCharacterCheckButton"><property name="label">Enable echo by character</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="enableEchoByCharacterCheckButton"><property name="label">Enable echo by character</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="enableEchoByWordCheckButton"><property name="label">Enable echo by word</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="enableEchoByWordCheckButton"><property name="label">Enable echo by word</property><property name="can-focus">True</property></object></child>
|
||||||
<child><object class="GtkCheckButton" id="enableEchoBySentenceCheckButton"><property name="label">Enable echo by sentence</property><property name="can-focus">True</property></object></child>
|
<child><object class="GtkCheckButton" id="enableEchoBySentenceCheckButton"><property name="label">Enable echo by sentence</property><property name="can-focus">True</property></object></child>
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ type Request struct {
|
|||||||
// Preview applies the default voice controls to the running speech
|
// Preview applies the default voice controls to the running speech
|
||||||
// backend without committing session or persistent settings.
|
// backend without committing session or persistent settings.
|
||||||
Preview bool
|
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 restores the last committed settings after a live preview.
|
||||||
Cancel bool
|
Cancel bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.UppercaseVoice, values.HyperlinkVoice, values.SystemVoice = session.voiceDrafts[1], session.voiceDrafts[2], session.voiceDrafts[3]
|
||||||
values.VoiceType = []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
|
values.VoiceType = []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
|
||||||
values.EchoVoice, values.EchoSpeechServer = session.getEchoVoice()
|
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"
|
values.SpeechServerFactory = "speechdispatcherfactory"
|
||||||
if session.activeSpeechSystem == 1 {
|
if session.activeSpeechSystem == 1 {
|
||||||
values.SpeechServerFactory = "hardwarefactory"
|
values.SpeechServerFactory = "hardwarefactory"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
|||||||
tentacles.populate(initial.Tentacles)
|
tentacles.populate(initial.Tentacles)
|
||||||
var tentaclePending *tentacle.ManagementRequest
|
var tentaclePending *tentacle.ManagementRequest
|
||||||
C.prefs_voice_changed()
|
C.prefs_voice_changed()
|
||||||
|
C.prefs_echo_preview(nil)
|
||||||
status(initial.Message, false)
|
status(initial.Message, false)
|
||||||
canSave, busy := initial.CanSave, false
|
canSave, busy := initial.CanSave, false
|
||||||
previewPending, previewDirty := false, false
|
previewPending, previewDirty := false, false
|
||||||
@@ -122,6 +123,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
|||||||
if response.Accepted {
|
if response.Accepted {
|
||||||
session.setValues(response.Values)
|
session.setValues(response.Values)
|
||||||
C.prefs_voice_changed()
|
C.prefs_voice_changed()
|
||||||
|
C.prefs_echo_preview(nil)
|
||||||
}
|
}
|
||||||
C.prefs_busy(0, boolean(canSave))
|
C.prefs_busy(0, boolean(canSave))
|
||||||
status(response.Message, !response.Accepted)
|
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))
|
C.prefs_busy(1, boolean(canSave))
|
||||||
continue
|
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
|
continue
|
||||||
}
|
}
|
||||||
values, err := session.getValues()
|
values, err := session.getValues()
|
||||||
@@ -191,7 +201,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
|||||||
previewDirty = false
|
previewDirty = false
|
||||||
continue
|
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
|
return err
|
||||||
}
|
}
|
||||||
previewPending = true
|
previewPending = true
|
||||||
|
|||||||
@@ -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))
|
||||||
+203
-11
@@ -13,7 +13,42 @@ sys.dont_write_bytecode = True
|
|||||||
from native_x11 import wait_for
|
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():
|
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":
|
if os.environ.get("CTHULHU_PREFS_ADAPTER_ONLY") == "1":
|
||||||
environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1")
|
environment = dict(os.environ, CTHULHU_NATIVE_PREFERENCES="1")
|
||||||
environment.setdefault("GOCACHE", "/tmp/cthulhu-cache")
|
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"))
|
binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu"))
|
||||||
process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE,
|
process = subprocess.Popen([binary, "--preferences-ui"], stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE, text=True)
|
stdout=subprocess.PIPE, text=True)
|
||||||
|
responses = ResponseReader(process.stdout)
|
||||||
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
|
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
|
||||||
"SpeechHistoryLimit": 50,
|
"SpeechHistoryLimit": 50,
|
||||||
"Reading": {"AutomaticallyReadTitleChanges": True},
|
"Reading": {"AutomaticallyReadTitleChanges": True},
|
||||||
@@ -71,6 +107,17 @@ def inside():
|
|||||||
"test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"},
|
"test-module": [{"Name": "English test voice", "Language": "en", "Variant": "none"},
|
||||||
{"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]},
|
{"Name": "Spanish test voice", "Language": "es", "Variant": "none"}]},
|
||||||
"CanSave": True, "Accepted": True, "Message": "Preferences test ready."}
|
"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":
|
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
|
||||||
initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"])
|
initial["Catalog"]["espeak-ng"] = list(initial["Catalog"]["test-module"])
|
||||||
if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1":
|
if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1":
|
||||||
@@ -129,8 +176,12 @@ def inside():
|
|||||||
assert node.queryComponent().grabFocus(), ("cannot focus", node.name)
|
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")
|
wait_for(lambda: any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)), "focus missing")
|
||||||
def request():
|
def request():
|
||||||
assert select.select([process.stdout], [], [], 5)[0], "no Apply request"
|
while True:
|
||||||
return json.loads(process.stdout.readline())
|
value = responses.request()
|
||||||
|
if value.get("EchoPreviewFocus") and not echoFocusMode:
|
||||||
|
send({"Preview": True, "Accepted": True})
|
||||||
|
continue
|
||||||
|
return value
|
||||||
try:
|
try:
|
||||||
send_initial(initial)
|
send_initial(initial)
|
||||||
window = wait_for(find_window, "preferences accessible window missing")
|
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]
|
xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0]
|
||||||
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
||||||
assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences"
|
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":
|
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
|
||||||
from native_tentacle_settings import check_page
|
from native_tentacle_settings import check_page
|
||||||
check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial)
|
check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial)
|
||||||
@@ -190,7 +376,7 @@ def inside():
|
|||||||
focus(automatic); key("space")
|
focus(automatic); key("space")
|
||||||
key("Escape")
|
key("Escape")
|
||||||
assert process.wait(timeout=2) == 0, "Escape did not close OCR preferences"
|
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")
|
print("PASS: OCR layout Tab/Shift+Tab, popup selection, automatic reading checkbox, Apply, rollback, Cancel")
|
||||||
return
|
return
|
||||||
if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1":
|
if os.environ.get("CTHULHU_PREFS_TEXT_ONLY") == "1":
|
||||||
@@ -240,9 +426,10 @@ def inside():
|
|||||||
key("ctrl+Page_Down", "ctrl+Page_Down")
|
key("ctrl+Page_Down", "ctrl+Page_Down")
|
||||||
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing")
|
wait_for(lambda: named("Speech").getState().contains(pyatspi.STATE_SELECTED), "Speech page missing")
|
||||||
values = initial["Values"]
|
values = initial["Values"]
|
||||||
def accept_preview():
|
def accept_preview(echoPreview=""):
|
||||||
preview = request()
|
preview = request()
|
||||||
assert preview["Preview"], preview
|
assert preview["Preview"], preview
|
||||||
|
assert preview.get("EchoPreview", "") == echoPreview, preview
|
||||||
send({"Preview": True, "Accepted": True})
|
send({"Preview": True, "Accepted": True})
|
||||||
return preview["Values"]
|
return preview["Values"]
|
||||||
for name, field in (("Rate", "Rate"), ("Pitch", "Pitch"), ("Volume", "Volume")):
|
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")):
|
for name, field in (("Key echo rate", "Rate"), ("Key echo pitch", "AveragePitch"), ("Key echo volume", "Gain")):
|
||||||
slider = named(name)
|
slider = named(name)
|
||||||
focus(slider)
|
focus(slider)
|
||||||
|
echoPreview = {"Rate": "rate", "AveragePitch": "pitch", "Gain": "volume"}[field]
|
||||||
assert abs(slider.queryValue().minimumIncrement - 0.1) < 1e-8, (name, "accessible increment")
|
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),
|
for arrow, expected in (("Right", 5.1 if field != "Rate" else 50.1),
|
||||||
("Up", 6.1 if field != "Rate" else 51.1),
|
("Up", 6.1 if field != "Rate" else 51.1),
|
||||||
@@ -283,15 +471,19 @@ def inside():
|
|||||||
("Down", 5 if field != "Rate" else 50),
|
("Down", 5 if field != "Rate" else 50),
|
||||||
("Left", 4.9 if field != "Rate" else 49.9)):
|
("Left", 4.9 if field != "Rate" else 49.9)):
|
||||||
key(arrow)
|
key(arrow)
|
||||||
|
values = accept_preview(echoPreview)
|
||||||
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
|
wait_for(lambda: abs(slider.queryValue().currentValue - expected) < 1e-8,
|
||||||
f"{name}: {arrow} did not produce {expected}")
|
f"{name}: {arrow} did not produce {expected}")
|
||||||
|
assert abs(values["EchoVoice"][field] - expected) < 1e-8, values
|
||||||
finalValue = expected
|
finalValue = expected
|
||||||
for bound, arrows in ((0, ("Left", "Down")),
|
for bound, arrows in ((0, ("Left", "Down")),
|
||||||
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
(slider.queryValue().maximumValue, ("Right", "Up"))):
|
||||||
slider.queryValue().currentValue = bound
|
slider.queryValue().currentValue = bound
|
||||||
|
accept_preview(echoPreview)
|
||||||
key(*arrows)
|
key(*arrows)
|
||||||
assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
|
assert slider.queryValue().currentValue == bound, (name, "boundary", bound)
|
||||||
slider.queryValue().currentValue = finalValue
|
slider.queryValue().currentValue = finalValue
|
||||||
|
accept_preview(echoPreview)
|
||||||
focus(named("Apply")); key("space")
|
focus(named("Apply")); key("space")
|
||||||
applied = request()
|
applied = request()
|
||||||
assert applied["Save"], applied
|
assert applied["Save"], applied
|
||||||
@@ -494,7 +686,7 @@ def inside():
|
|||||||
values = accept_preview()
|
values = accept_preview()
|
||||||
assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8
|
assert values["Pitch"] == 5 and pitchScale.queryValue().currentValue == 8
|
||||||
focus(named("Cancel")); key("space")
|
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")
|
print("PASS: independent voice editors, inheritance, previews, indentation modes, Apply/reload, Cancel")
|
||||||
return
|
return
|
||||||
if os.environ.get("CTHULHU_PREFS_SPEECH_ONLY") == "1":
|
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"
|
assert process.poll() is None, "dropdown Escape closed the whole preferences window"
|
||||||
key("Escape")
|
key("Escape")
|
||||||
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
|
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")
|
print("PASS: synthesizer and voice previews, locale filtering, language preserved across synthesizers, Apply/reload, dropdown and window Escape")
|
||||||
return
|
return
|
||||||
if os.environ.get("CTHULHU_PREFS_READING_ONLY") == "1":
|
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"
|
if readingTable.getAccessibleAt(row, 1).name == "Speak object under mouse"), 0).name == "checked"
|
||||||
focus(named("Cancel")); key("space")
|
focus(named("Cancel")); key("space")
|
||||||
assert process.wait(timeout=5) == 0
|
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")
|
print("PASS: reading table, Say All, tooltip and mouse choices, Space, arrows, Tab/Shift+Tab, Apply/reload, Cancel")
|
||||||
return
|
return
|
||||||
if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1":
|
if os.environ.get("CTHULHU_PREFS_MODIFIERS_ONLY") == "1":
|
||||||
@@ -727,7 +919,7 @@ def inside():
|
|||||||
assert_modifiers({"Insert", "Numpad Insert"})
|
assert_modifiers({"Insert", "Numpad Insert"})
|
||||||
focus(named("Cancel")); key("space")
|
focus(named("Cancel")); key("space")
|
||||||
assert process.wait(timeout=5) == 0
|
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")
|
print("PASS: modifier checkbox table, accessible states, Space, Tab/Shift+Tab, desktop/laptop defaults, multiple/empty selections, Apply/reload, Cancel")
|
||||||
return
|
return
|
||||||
if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1":
|
if os.environ.get("CTHULHU_PREFS_BINDINGS_ONLY") == "1":
|
||||||
@@ -804,7 +996,7 @@ def inside():
|
|||||||
set_binding("F9")
|
set_binding("F9")
|
||||||
focus(named("Cancel")); key("space")
|
focus(named("Cancel")); key("space")
|
||||||
assert process.wait(timeout=5) == 0
|
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")
|
print("PASS: accessible binding editor, Tab/Shift+Tab, conflict, save/reload, clear, reset, laptop browse, reset all, Cancel")
|
||||||
return
|
return
|
||||||
if os.environ.get("CTHULHU_PREFS_BRAILLE_ONLY") == "1":
|
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
|
focus(named("Key echo volume")); named("Key echo volume").queryValue().currentValue = 10
|
||||||
family = named("Key echo person")
|
family = named("Key echo person")
|
||||||
family.querySelection().selectChild(1)
|
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)
|
assert not word_voice.getState().contains(pyatspi.STATE_CHECKED)
|
||||||
focus(word_voice); key("space")
|
focus(word_voice); key("space")
|
||||||
wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED),
|
wait_for(lambda: word_voice.getState().contains(pyatspi.STATE_CHECKED),
|
||||||
@@ -1128,7 +1320,7 @@ def inside():
|
|||||||
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
subprocess.run(["xdotool", "windowfocus", xid], check=True)
|
||||||
key("Escape")
|
key("Escape")
|
||||||
assert process.wait(timeout=2) == 0, "Escape did not close preferences promptly"
|
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")
|
print("PASS: GTK notebook, roles, label buddies, Tab/Shift+Tab, Apply, failed apply, OK, Escape")
|
||||||
finally:
|
finally:
|
||||||
if reader_process is not None:
|
if reader_process is not None:
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user