From 3e9c4f83ceb1c70298933458c77ab0c76be5fc13 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 22 Oct 2026 03:25:40 -0400 Subject: [PATCH] Options for controlling how and when speech is interrupted now wired up in preferences under the speech tab. --- README.md | 26 ++- .../Arch-Linux/cthulhu-git/PKGBUILD | 2 +- docs/testing/speech-queue.md | 50 ++++- internal/app/input_events.go | 22 +- internal/app/interruption_test.go | 89 +++++++++ internal/atspi/adapter_capture.c | 9 +- internal/config/defaults.go | 3 + internal/config/interruption_test.go | 46 +++++ internal/config/profile_mapping.go | 9 + internal/config/schema.go | 31 +-- internal/config/settings.go | 1 + internal/config/settings_copy.go | 1 + internal/config/validation.go | 3 + internal/input/input.go | 4 - internal/input/input_test.go | 2 +- internal/input/interruption.go | 72 +++++++ internal/input/interruption_test.go | 47 +++++ internal/preferences/interruption_test.go | 27 +++ internal/preferences/native.c | 1 + internal/preferences/native.h | 4 + internal/preferences/native_internal.h | 1 + internal/preferences/native_interruption.c | 188 ++++++++++++++++++ internal/preferences/preferences.ui | 8 + internal/preferences/protocol.go | 1 + internal/preferences/speech.go | 19 ++ internal/preferences/ui.go | 2 +- internal/preferences/values.go | 8 +- .../presentation/text_label_native_test.go | 4 + testdata/parity-ledger.csv | 5 +- tests/native_echo_preview.py | 37 +++- tests/native_preferences.py | 101 ++++++++++ tests/text_label_fixture.py | 8 + 32 files changed, 784 insertions(+), 47 deletions(-) create mode 100644 internal/app/interruption_test.go create mode 100644 internal/config/interruption_test.go create mode 100644 internal/input/interruption.go create mode 100644 internal/input/interruption_test.go create mode 100644 internal/preferences/interruption_test.go create mode 100644 internal/preferences/native_interruption.c diff --git a/README.md b/README.md index e97cebe..3564a26 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,25 @@ Native X11 grab-window regression: Incoming announcements queue in order. Focus, selection, state changes, notifications, tooltips and terminal output let earlier speech finish. -Passive announcements also queue behind Say All. A new key press or reader -command stops earlier speech and clears its pending announcements. Delayed -copies of the key that started a command preserve that command's response. +Passive announcements also queue behind Say All. By default, a new key press +or reader command stops earlier speech and clears its pending announcements. +Delayed copies of the key that started a command preserve that command's response. + +The Speech tab in Preferences offers **All keys interrupt**, **Control, Enter, +Space, and Escape**, and **Define interruption keys**. The second choice includes +both Control keys and the keypad Enter key. These choices apply immediately +after Apply and are saved with the profile. Key echo remains independent; +reader commands and explicit stop requests still interrupt speech in every mode. + +For custom keys, select **Define interruption keys**, then activate **Record +interruption keys**. Press the individual keys to record, including Tab, Enter, +Space or Escape. Press and release a modifier separately to record it; left and +right modifiers can be selected independently. **Control+Enter** finishes and +**Control+Escape** cancels. Neither finishing nor cancelling adds its Control +key to the list. There is no timer. Recording replaces the previous list when +finished and requires at least one key. Duplicate keys are ignored. Focus +returns to the recording button, and the read-only **Defined interruption keys** +field displays the result. Tab and Shift+Tab work normally outside recording. Screen lock, shutdown, speech configuration changes and explicit stop requests retain their lifecycle behavior. Existing Tentacle requests marked @@ -85,8 +101,8 @@ what is spoken. Turn off custom echo voice to use the main speech settings. Key names finish before the resulting navigation or focus announcement starts. For example, Right speaks "right" in the selected key echo voice, followed by -the character at the caret in the normal reading voice. A new key press still -interrupts the previous key's speech and discards its pending feedback. +the character at the caret in the normal reading voice. A configured interruption +key still interrupts the previous key's speech and discards its pending feedback. Changing the custom echo synthesizer, person, rate, pitch or volume speaks the selected value using the echo voice and its draft settings, before Apply. diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 6715552..aa5c2ad 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,7 +4,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.10.22.r64.gb742617 +pkgver=2026.10.22.r65.ge62f8c8 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/docs/testing/speech-queue.md b/docs/testing/speech-queue.md index 1ef0585..47db8bc 100644 --- a/docs/testing/speech-queue.md +++ b/docs/testing/speech-queue.md @@ -1,6 +1,6 @@ # Speech queue verification -Ordinary output always appends. Cancellation belongs to physical key input, +Ordinary output always appends. Cancellation belongs to configured interruption keys, reader command dispatch and explicit stop requests. An announcement arriving later from the same action must let earlier speech finish. This includes focus, selection, state, notification and delayed review output. Accepted Tentacle @@ -46,7 +46,8 @@ and Pi: 1. In keyboard preferences, change Desktop to Laptop, release the key and wait. Laptop should finish, followed by the role/state announcements. Repeat in the other direction and test radio buttons, checkboxes and combo boxes. -2. During a long announcement, press Control. Repeat with typing, an arrow, +2. With All keys interrupt selected, during a long announcement, press Control. + Repeat with typing, an arrow, Tab and a reader command. Old speech and its queued feedback should stop; the new action should speak normally. Releasing keys should leave speech alone. Test rapid presses and held arrows, including automatic titles. @@ -64,3 +65,48 @@ Screen lock still clears speech for privacy. Disabling speech, replacing its configuration/backend and shutdown retain their existing behavior. Protected Tentacle messages retain their explicit timeout and cancellation rules. The bounded queue can discard pending requests on overflow and logs that loss. + +## Interruption preferences + +Build a separate binary and exercise the recorder on a private display: + +```sh +go build -o /tmp/cthulhu-interruption-test ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-interruption-test CTHULHU_PREFS_INTERRUPTION_ONLY=1 /usr/bin/python3 tests/native_preferences.py +CTHULHU_BINARY=/tmp/cthulhu-interruption-test CTHULHU_PREFS_INTERRUPTION_RUNTIME_ONLY=1 /usr/bin/python3 tests/native_preferences.py +``` + +This checks all three choices, Tab and Shift+Tab around the new controls, +recording Tab/Enter/Space/Escape and separate modifiers, duplicate suppression, +Control+Enter finish, Control+Escape cancellation, replacement, empty-recording +rejection, Apply and reloading the accepted draft. Direct XTest modifier input +avoids extra Control presses synthesized by xdotool. Config tests separately +check disk persistence and application profiles. Application tests apply the +preference to the live settings and check that unselected keys preserve both +queued speech and Say All, while selected keys and reader commands cancel them. +The runtime check starts a private reader and fake speech server. It verifies +that the read-only summary's contents are spoken on focus and characters are +spoken while moving with Left and Right. The generic native text-label test +also covers read-only entries and keeps password contents excluded. + +After reviewing the diff and authorizing a reader replacement, verify audibly: + +1. Select All keys interrupt and Apply. During long speech, press a letter, + Tab, an arrow, Shift and Control separately; each should stop old speech. +2. Select Control, Enter, Space, and Escape and Apply. Letters, arrows, Tab and + Shift should let speech continue. Both Control keys, both Enter keys, Space + and Escape should stop it. Repeat during Say All. +3. Select Define interruption keys. Record Tab, Enter, Escape and Right Control, + finishing with Control+Enter. Confirm Left Control was not added. Check the + result field, Apply, close, reopen and confirm the list persists. Test each + chosen key during long speech and confirm unchosen keys let it continue. +4. Record a different list and cancel with Control+Escape; the previous list + should remain. Cancel the whole Preferences window and confirm unapplied + changes do not alter runtime behavior. +5. Tab and Shift+Tab through the interruption group, recorder button, defined + keys and speech history. Check spoken instructions and Braille in recording, + normal typing/key echo, browser navigation, reader commands, automatic title + repeats, and selected/unselected held keys after applying each mode. + +The private checks do not establish live speech, physical keyboard, physical +Braille or Wayland compositor acceptance. diff --git a/internal/app/input_events.go b/internal/app/input_events.go index 6dfecf7..eb16a46 100644 --- a/internal/app/input_events.go +++ b/internal/app/input_events.go @@ -64,7 +64,18 @@ func processInputEvents( if len(ocrSessions) > 0 { ocrSessions[0].Input(event) } - if input.ShouldInterruptSpeech(event) { + interruption := input.InterruptionSettings{Mode: input.InterruptAllKeys} + custom := false + if live != nil { + settings := live.Load() + interruption = settings.SpeechInterruption + echo.settings = inputEchoSettingsFromConfig(settings) + custom = customEcho(settings, "key") + if settings.KeyEchoInEditFieldsOnly && (live.editFieldFocused == nil || !live.editFieldFocused()) { + echo.settings.EnableKeyEcho = false + } + } + if interruption.ShouldInterrupt(event) { if interrupt, ok := speech.(interface{ InterruptForInput(input.Event) bool }); ok { if !interrupt.InterruptForInput(event) { continue @@ -77,15 +88,6 @@ func processInputEvents( logger.Debug("queued speech interrupted by key press", "input_timestamp", event.Timestamp, "input_keycode", event.Keycode, "input_repeat", event.Repeat) } - custom := false - if live != nil { - settings := live.Load() - echo.settings = inputEchoSettingsFromConfig(settings) - custom = customEcho(settings, "key") - if settings.KeyEchoInEditFieldsOnly && (live.editFieldFocused == nil || !live.editFieldFocused()) { - echo.settings.EnableKeyEcho = false - } - } if text := echo.Text(event); text != "" { speech.Enqueue(speechRequest{Text: text, Role: "key", Cause: "key-echo", CustomEcho: custom}) logger.Debug("queued non-printable key echo", "kind", event.Kind) diff --git a/internal/app/interruption_test.go b/internal/app/interruption_test.go new file mode 100644 index 0000000..aade9fb --- /dev/null +++ b/internal/app/interruption_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "context" + "log/slog" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestProcessInputEventsUsesLiveInterruptionSettings(t *testing.T) { + live := &liveSettings{} + settings := config.DefaultSettings() + settings.EnableKeyEcho = true + settings.EnableActionKeys = true + for _, mode := range []string{input.InterruptAllKeys, input.InterruptCommonKeys, input.InterruptCustomKeys} { + settings.SpeechInterruption = input.InterruptionSettings{Mode: mode, Keys: []string{"Tab"}} + live.Store(settings) + speech := &fakeSpeechQueue{spoken: make(chan string, 10), interrupt: make(chan struct{}, 10)} + listener := &fakeInputListener{events: make(chan input.Event, 8)} + for _, name := range []string{"a", "Tab", "Control_L", "Return"} { + listener.events <- input.NewKeyEvent(42, true, name) + listener.events <- input.NewKeyEvent(42, false, name) + } + close(listener.events) + processInputEvents(context.Background(), slog.New(slog.DiscardHandler), speech, listener, input.EchoSettings{}, live, nil) + want := map[string]int{input.InterruptAllKeys: 4, input.InterruptCommonKeys: 2, input.InterruptCustomKeys: 1}[mode] + if len(speech.interrupt) != want { + t.Fatalf("mode %s interrupted %d times, want %d", mode, len(speech.interrupt), want) + } + // Selecting interruption keys must not suppress ordinary key echo. + if len(speech.spoken) < 2 { + t.Fatalf("mode %s suppressed Tab/Enter echo", mode) + } + } +} + +func TestAppliedInterruptionKeysPreserveQueuedSpeechAndSayAll(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + speech := newSpeechWorker(logger, nil) + speech.running = true + live := &liveSettings{} + settings := config.DefaultSettings() + settings.EnableKeyEcho = false + live.Store(settings) + preferences := &preferencesWorker{settings: settings, live: live, speechWorker: speech} + settings.SpeechInterruption.Mode = input.InterruptCommonKeys + if err := preferences.applyRuntime(context.Background(), settings); err != nil { + t.Fatal(err) + } + deliver := func(name string) { + listener := &fakeInputListener{events: make(chan input.Event, 1)} + listener.events <- input.NewKeyEvent(42, true, name) + close(listener.events) + processInputEvents(context.Background(), logger, speech, listener, input.EchoSettings{}, live, nil) + } + readContext := speech.BeginReadAll(context.Background(), 0) + speech.Enqueue(speechRequest{Text: "First announcement"}) + speech.Enqueue(speechRequest{Text: "Second announcement"}) + generation := speech.generation + deliver("Up") + deliver("a") + if readContext.Err() != nil || len(speech.pending) != 2 || speech.generation != generation { + t.Fatal("unselected keys cancelled Say All or dropped queued speech") + } + deliver("Control_R") + if readContext.Err() != context.Canceled || len(speech.pending) != 0 { + t.Fatal("common interruption key did not cancel Say All and pending output") + } + settings.SpeechInterruption = input.InterruptionSettings{Mode: input.InterruptCustomKeys, Keys: []string{"F8"}} + if err := preferences.applyRuntime(context.Background(), settings); err != nil { + t.Fatal(err) + } + speech.Enqueue(speechRequest{Text: "Custom mode announcement"}) + deliver("Control_R") + if len(speech.pending) != 1 { + t.Fatal("previous interruption mode remained active after Apply") + } + deliver("F8") + if len(speech.pending) != 0 { + t.Fatal("custom interruption key did not cancel queued output") + } + speech.Enqueue(speechRequest{Text: "Old output"}) + speech.ProtectCommand(100) + if len(speech.pending) != 0 { + t.Fatal("reader command failed to cancel old output in custom mode") + } +} diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 1dafe1b..1188024 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -585,10 +585,13 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt } // Text and caret capture make synchronous AT-SPI calls. Focus events for // ordinary controls do not use that text in presentation, so avoid stalling - // the GLib event loop for each Tab stop. Caret events still need line and - // character text even when they originate in static web content. + // the GLib event loop for each Tab stop. Focusable text controls include + // read-only entries: their current line is still part of focus speech. + // Caret events also need text when they originate in static web content. + gboolean focusableText = snapshot.focusable && + (g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0); if (captureText && g_strcmp0(snapshot.role, "password text") != 0 && - (snapshot.editable || g_strcmp0(snapshot.role, "terminal") == 0 || captureCaretCharacter)) { + (snapshot.editable || focusableText || g_strcmp0(snapshot.role, "terminal") == 0 || captureCaretCharacter)) { snapshot.text = cthulhu_capture_text(source, &snapshot.textBeforeCaret, &snapshot.textStartOffset); } if (captureCaretCharacter && g_strcmp0(snapshot.role, "password text") != 0) { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 8f15288..75ef09a 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -17,6 +17,8 @@ package config +import "git.stormux.org/storm/cthulhu/internal/input" + func DefaultSettings() Settings { return Settings{ OCR: OCRSettings{Layout: 3, Scale: 1, Threshold: 200, RefreshInterval: 1500, ConfirmClick: true}, @@ -27,6 +29,7 @@ func DefaultSettings() Settings { RoleSoundPresentation: "sound_and_speech", EnableSpeech: true, SpeechHistoryLimit: DefaultSpeechHistoryLimit, + SpeechInterruption: input.InterruptionSettings{Mode: input.InterruptAllKeys}, Braille: BrailleOptions{Verbosity: 1, SelectionIndicator: 0xc0, LinkIndicator: 0xc0, FlashMessages: true, FlashTime: 5000, FlashDetailed: true}, SpeechServerFactory: "speechdispatcherfactory", diff --git a/internal/config/interruption_test.go b/internal/config/interruption_test.go new file mode 100644 index 0000000..4fe9060 --- /dev/null +++ b/internal/config/interruption_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "path/filepath" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestSpeechInterruptionPersistenceAndApplicationProfiles(t *testing.T) { + settings, err := Load(writeConfig(t, "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\n")) + if err != nil || settings.SpeechInterruption.Mode != input.InterruptAllKeys { + t.Fatalf("omitted preference changed default: %+v, %v", settings.SpeechInterruption, err) + } + store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml")) + if err != nil { + t.Fatal(err) + } + settings.SpeechInterruption = input.InterruptionSettings{Mode: input.InterruptCustomKeys, Keys: []string{"Tab", "Return", "Control_R"}} + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil || !reflect.DeepEqual(settings.SpeechInterruption, loaded.SpeechInterruption) { + t.Fatalf("saved keys = %+v, %v", loaded.SpeechInterruption, err) + } + application := cloneSettings(loaded) + application.SpeechInterruption.Mode = input.InterruptCommonKeys + if err := store.SaveForApplication("Editor", application); err != nil { + t.Fatal(err) + } + app, found, err := store.LoadForApplication("Editor") + if err != nil || !found || app.SpeechInterruption.Mode != input.InterruptCommonKeys { + t.Fatalf("application settings = %+v, %t, %v", app.SpeechInterruption, found, err) + } + global, err := store.Load() + if err != nil || global.SpeechInterruption.Mode != input.InterruptCustomKeys { + t.Fatalf("application altered global = %+v, %v", global.SpeechInterruption, err) + } + copy := cloneSettings(global) + copy.SpeechInterruption.Keys[0] = "Escape" + if global.SpeechInterruption.Keys[0] != "Tab" { + t.Fatal("settings snapshots share mutable keys") + } +} diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go index 4b61ddf..f78f79d 100644 --- a/internal/config/profile_mapping.go +++ b/internal/config/profile_mapping.go @@ -108,6 +108,7 @@ func profileFromSettings(settings Settings) writableProfile { }, }, Speech: writableSpeech{ + Interruption: interruption_pointer(settings.SpeechInterruption), HardwareDevice: stringPointer(settings.HardwareSpeechDevice), HardwareBaudRate: intPointer(settings.HardwareSpeechBaudRate), Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory), @@ -386,6 +387,9 @@ func apply_profile_reading(settings *Settings, profile writableProfile) { } func apply_profile_output(settings *Settings, profile writableProfile) { + if value := profile.Speech.Interruption; value != nil { + settings.SpeechInterruption = *interruption_pointer(*value) + } if value := profile.Speech.Enabled; value != nil { settings.EnableSpeech = *value } @@ -433,6 +437,11 @@ func apply_profile_output(settings *Settings, profile writableProfile) { } } +func interruption_pointer(settings input.InterruptionSettings) *input.InterruptionSettings { + settings.Keys = append([]string(nil), settings.Keys...) + return &settings +} + func apply_profile_speech(settings *Settings, profile writableProfile) { if value := profile.Speech.HardwareDevice; value != nil { settings.HardwareSpeechDevice = *value diff --git a/internal/config/schema.go b/internal/config/schema.go index 0653d64..c16cca6 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -118,21 +118,22 @@ type writableReading struct { } type writableSpeech struct { - HardwareDevice *string `toml:"hardware-device,omitempty"` - HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"` - OutputModule *string `toml:"output-module,omitempty"` - Enabled *bool `toml:"enabled,omitempty"` - HistoryLimit *int `toml:"history-limit,omitempty"` - ServerFactory *string `toml:"server-factory,omitempty"` - Punctuation *int `toml:"punctuation-style,omitempty"` - Capitalization *string `toml:"capitalization-style,omitempty"` - RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"` - BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"` - SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"` - DefaultVoice *Voice `toml:"default-voice,omitempty"` - UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"` - HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"` - SystemVoice *Voice `toml:"system-voice,omitempty"` + Interruption *input.InterruptionSettings `toml:"interruption,omitempty"` + HardwareDevice *string `toml:"hardware-device,omitempty"` + HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"` + OutputModule *string `toml:"output-module,omitempty"` + Enabled *bool `toml:"enabled,omitempty"` + HistoryLimit *int `toml:"history-limit,omitempty"` + ServerFactory *string `toml:"server-factory,omitempty"` + Punctuation *int `toml:"punctuation-style,omitempty"` + Capitalization *string `toml:"capitalization-style,omitempty"` + RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"` + BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"` + SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"` + DefaultVoice *Voice `toml:"default-voice,omitempty"` + UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"` + HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"` + SystemVoice *Voice `toml:"system-voice,omitempty"` } type writableBraille struct { diff --git a/internal/config/settings.go b/internal/config/settings.go index 125f10b..ec9b322 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -132,6 +132,7 @@ type Settings struct { ActiveProfile string EnableSpeech bool SpeechHistoryLimit int + SpeechInterruption input.InterruptionSettings EnableBraille bool EnableContractedBraille bool BrailleContractionTable string diff --git a/internal/config/settings_copy.go b/internal/config/settings_copy.go index 86383d0..16c9863 100644 --- a/internal/config/settings_copy.go +++ b/internal/config/settings_copy.go @@ -22,6 +22,7 @@ import ( ) func cloneSettings(settings Settings) Settings { + settings.SpeechInterruption.Keys = append([]string(nil), settings.SpeechInterruption.Keys...) settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings) settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...) settings.Pronunciations = append([]PronunciationEntry(nil), settings.Pronunciations...) diff --git a/internal/config/validation.go b/internal/config/validation.go index fcb1492..9737d41 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -33,6 +33,9 @@ import ( func Validate(settings Settings) error { return validate(settings) } func validate(settings Settings) error { + if err := settings.SpeechInterruption.Validate(); err != nil { + return err + } if settings.OCR.Layout != 3 && settings.OCR.Layout != 6 && settings.OCR.Layout != 11 || settings.OCR.Scale < 1 || settings.OCR.Scale > 4 || settings.OCR.Threshold < 0 || settings.OCR.Threshold > 255 || settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 || diff --git a/internal/input/input.go b/internal/input/input.go index f8740c2..e6339ab 100644 --- a/internal/input/input.go +++ b/internal/input/input.go @@ -120,10 +120,6 @@ func (modifiers ModifierKeycodes) Contains(keycode uint8) bool { return modifiers[keycode] } -func ShouldInterruptSpeech(event Event) bool { - return event.Pressed -} - // Automatic titles survive held-key repeats, but fresh input and explicit // stop keys cancel both scheduled and already queued title speech. func ShouldInterruptTitle(event Event) bool { diff --git a/internal/input/input_test.go b/internal/input/input_test.go index 326ea05..465e36e 100644 --- a/internal/input/input_test.go +++ b/internal/input/input_test.go @@ -19,7 +19,7 @@ func TestShouldInterruptSpeechOnKeyPress(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - if got := ShouldInterruptSpeech(test.event); got != test.want { + if got := (InterruptionSettings{Mode: InterruptAllKeys}).ShouldInterrupt(test.event); got != test.want { t.Fatalf("ShouldInterruptSpeech() = %v, want %v", got, test.want) } }) diff --git a/internal/input/interruption.go b/internal/input/interruption.go new file mode 100644 index 0000000..3277891 --- /dev/null +++ b/internal/input/interruption.go @@ -0,0 +1,72 @@ +package input + +import ( + "errors" + "strings" +) + +const ( + InterruptAllKeys = "all" + InterruptCommonKeys = "common" + InterruptCustomKeys = "custom" + MaxInterruptionKeys = 64 +) + +// InterruptionSettings selects individual keys, independently of key echo and +// command bindings. Reader commands retain their own explicit cancellation. +type InterruptionSettings struct { + Mode string `toml:"mode"` + Keys []string `toml:"keys"` +} + +func (settings InterruptionSettings) Validate() error { + switch settings.Mode { + case InterruptAllKeys, InterruptCommonKeys, InterruptCustomKeys: + default: + return errors.New("choose a valid speech interruption mode") + } + if len(settings.Keys) > MaxInterruptionKeys { + return errors.New("too many speech interruption keys") + } + if settings.Mode == InterruptCustomKeys && len(settings.Keys) == 0 { + return errors.New("record at least one speech interruption key") + } + seen := make(map[string]bool, len(settings.Keys)) + for _, name := range settings.Keys { + key := strings.ToLower(name) + if name == "" || len(name) > 128 || seen[key] { + return errors.New("speech interruption keys must have unique, valid key names") + } + for _, character := range name { + if character != '_' && !(character >= 'a' && character <= 'z') && + !(character >= 'A' && character <= 'Z') && !(character >= '0' && character <= '9') { + return errors.New("invalid speech interruption key name") + } + } + seen[key] = true + } + return nil +} + +func (settings InterruptionSettings) ShouldInterrupt(event Event) bool { + if !event.Pressed { + return false + } + switch settings.Mode { + case InterruptCommonKeys: + switch event.Name { + case "Control_L", "Control_R", "Return", "KP_Enter", "space", "KP_Space", "Escape": + return true + } + return false + case InterruptCustomKeys: + for _, name := range settings.Keys { + if strings.EqualFold(name, event.Name) || name == "Tab" && event.Name == "ISO_Left_Tab" { + return true + } + } + return false + default: + return true + } +} diff --git a/internal/input/interruption_test.go b/internal/input/interruption_test.go new file mode 100644 index 0000000..23e5b5e --- /dev/null +++ b/internal/input/interruption_test.go @@ -0,0 +1,47 @@ +package input + +import "testing" + +func TestSpeechInterruptionModes(t *testing.T) { + for _, name := range []string{"Control_L", "Control_R", "Return", "KP_Enter", "space", "KP_Space", "Escape", "Shift_L", "Tab", "Up", "a", "F8"} { + for _, pressed := range []bool{false, true} { + for _, repeat := range []bool{false, true} { + event := NewKeyEvent(42, pressed, name) + event.Repeat = repeat + common := name == "Control_L" || name == "Control_R" || name == "Return" || name == "KP_Enter" || name == "space" || name == "KP_Space" || name == "Escape" + for mode, want := range map[string]bool{InterruptAllKeys: pressed, InterruptCommonKeys: pressed && common, + InterruptCustomKeys: pressed && (name == "Tab" || name == "F8")} { + settings := InterruptionSettings{Mode: mode, Keys: []string{"Tab", "F8"}} + if got := settings.ShouldInterrupt(event); got != want { + t.Fatalf("mode=%s event=%+v: interrupt=%t, want %t", mode, event, got, want) + } + } + } + } + } + settings := InterruptionSettings{Mode: InterruptCustomKeys, Keys: []string{"a", "Control_R"}} + if !settings.ShouldInterrupt(Event{Pressed: true, Name: "A"}) || settings.ShouldInterrupt(Event{Pressed: true, Name: "Control_L"}) { + t.Fatal("custom keys must ignore letter case and preserve left/right modifiers") + } + settings.Keys = []string{"Tab", "Prior", "XF86AudioMute"} + for _, name := range []string{"ISO_Left_Tab", "Prior", "XF86AudioMute"} { + if !settings.ShouldInterrupt(Event{Pressed: true, Name: name}) { + t.Fatalf("recorded key %s did not match its input name", name) + } + } +} + +func TestSpeechInterruptionValidation(t *testing.T) { + for _, settings := range []InterruptionSettings{ + {Mode: "unknown"}, {Mode: InterruptCustomKeys}, + {Mode: InterruptCustomKeys, Keys: []string{"Tab", "Tab"}}, + {Mode: InterruptCustomKeys, Keys: []string{"a", "A"}}, + {Mode: InterruptCustomKeys, Keys: []string{"Ctrl+Enter"}}, + {Mode: InterruptCustomKeys, Keys: []string{""}}, + {Mode: InterruptCustomKeys, Keys: make([]string, MaxInterruptionKeys+1)}, + } { + if err := settings.Validate(); err == nil { + t.Fatalf("accepted invalid settings %+v", settings) + } + } +} diff --git a/internal/preferences/interruption_test.go b/internal/preferences/interruption_test.go new file mode 100644 index 0000000..8e32460 --- /dev/null +++ b/internal/preferences/interruption_test.go @@ -0,0 +1,27 @@ +package preferences + +import ( + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" +) + +func TestSpeechInterruptionValuesRoundTrip(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + values.SpeechInterruption = input.InterruptionSettings{Mode: input.InterruptCustomKeys, Keys: []string{"Tab", "Return"}} + merged, err := values.Merge(settings) + if err != nil || !reflect.DeepEqual(FromSettings(merged), values) { + t.Fatalf("interruption values did not round trip: %+v, %v", merged.SpeechInterruption, err) + } + values.SpeechInterruption.Keys[0] = "Escape" + if merged.SpeechInterruption.Keys[0] != "Tab" { + t.Fatal("merged settings share the UI's mutable keys") + } + values.SpeechInterruption.Keys = nil + if _, err := values.Merge(settings); err == nil { + t.Fatal("accepted custom mode without keys") + } +} diff --git a/internal/preferences/native.c b/internal/preferences/native.c index ea84518..505a063 100644 --- a/internal/preferences/native.c +++ b/internal/preferences/native.c @@ -159,6 +159,7 @@ int prefs_init(const char *xml) { prefs_init_reading_settings(); prefs_init_key_bindings(); prefs_init_modifiers(); + prefs_init_interruption(); prefs_init_pronunciation(); prefs_init_text_attributes(); prefs_init_tentacles(); diff --git a/internal/preferences/native.h b/internal/preferences/native.h index aa03d3f..c5bec2d 100644 --- a/internal/preferences/native.h +++ b/internal/preferences/native.h @@ -44,6 +44,10 @@ int prefs_binding_selected(void); void prefs_binding_select(int row); void prefs_modifier_set(const char *id, int enabled); +void prefs_interruption_clear(void); +void prefs_interruption_append(const char *name); +int prefs_interruption_count(void); +const char *prefs_interruption_key(int index); int prefs_modifier_get(const char *id); void prefs_init_pronunciation(void); void prefs_pronunciation_clear(void); diff --git a/internal/preferences/native_internal.h b/internal/preferences/native_internal.h index db43e07..9d3babd 100644 --- a/internal/preferences/native_internal.h +++ b/internal/preferences/native_internal.h @@ -11,6 +11,7 @@ GtkWidget *prefs_widget(const char *id); void prefs_set_action(int value); void prefs_init_key_bindings(void); void prefs_init_modifiers(void); +void prefs_init_interruption(void); void prefs_init_reading_settings(void); void prefs_init_pronunciation(void); void prefs_init_text_attributes(void); diff --git a/internal/preferences/native_interruption.c b/internal/preferences/native_interruption.c new file mode 100644 index 0000000..06afc29 --- /dev/null +++ b/internal/preferences/native_interruption.c @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" +#include +#include + +// The preferences window owns the accepted draft. Each recorder owns a fresh +// candidate and held modifier set; cancelling or destroying it discards both. +typedef struct { + GtkWidget *dialog; + GtkWidget *entry; + GPtrArray *keys; + GHashTable *modifiers; +} KeyRecorder; + +static GPtrArray *interruption_keys(void) { + return g_object_get_data(G_OBJECT(prefs_widget("window")), "interruption-keys"); +} + +static char *key_label(const char *name) { + const struct { const char *name; const char *label; } labels[] = { + {"Control_L", "Left Control"}, {"Control_R", "Right Control"}, + {"Shift_L", "Left Shift"}, {"Shift_R", "Right Shift"}, + {"Alt_L", "Left Alt"}, {"Alt_R", "Right Alt"}, + {"Super_L", "Left Super"}, {"Super_R", "Right Super"}, + {"Return", "Enter"}, {"KP_Enter", "Numpad Enter"}, {"space", "Space"}, + }; + for (unsigned i = 0; i < G_N_ELEMENTS(labels); i++) + if (strcmp(name, labels[i].name) == 0) return g_strdup(labels[i].label); + guint keyval = xkb_keysym_from_name(name, XKB_KEYSYM_NO_FLAGS); + return keyval == XKB_KEY_NoSymbol ? g_strdup(name) : gtk_accelerator_get_label(keyval, 0); +} + +static void show_keys(GtkWidget *entry, GPtrArray *keys) { + GString *text = g_string_new(NULL); + for (unsigned i = 0; i < keys->len; i++) { + char *label = key_label(g_ptr_array_index(keys, i)); + if (i) g_string_append(text, ", "); + g_string_append(text, label); + g_free(label); + } + gtk_entry_set_text(GTK_ENTRY(entry), text->len ? text->str : "No keys defined"); + g_string_free(text, TRUE); +} + +void prefs_interruption_clear(void) { + g_ptr_array_set_size(interruption_keys(), 0); + show_keys(prefs_widget("interruptionKeysEntry"), interruption_keys()); +} + +void prefs_interruption_append(const char *name) { + g_ptr_array_add(interruption_keys(), g_strdup(name)); + show_keys(prefs_widget("interruptionKeysEntry"), interruption_keys()); +} + +int prefs_interruption_count(void) { return interruption_keys()->len; } +const char *prefs_interruption_key(int index) { return g_ptr_array_index(interruption_keys(), index); } + +static void interruption_sensitivity(GtkToggleButton *button, gpointer data) { + (void)button; (void)data; + gboolean custom = prefs_check_get("interruptCustomKeysRadio"); + gtk_widget_set_sensitive(prefs_widget("recordInterruptionKeysButton"), custom); + gtk_widget_set_sensitive(prefs_widget("interruptionKeysEntry"), custom); +} + +static void record_key(KeyRecorder *recorder, guint keyval) { + // GTK and X11 use different aliases for Page Up and multimedia keys. + // Persist XKB names, matching both input observers rather than GTK names. + char name[128]; + int length = xkb_keysym_get_name(gdk_keyval_to_lower(keyval), name, sizeof(name)); + if (length <= 0 || (unsigned)length >= sizeof(name)) return; + for (unsigned i = 0; i < recorder->keys->len; i++) + if (strcmp(name, g_ptr_array_index(recorder->keys, i)) == 0) return; + if (recorder->keys->len == 64) return; + g_ptr_array_add(recorder->keys, g_strdup(name)); + show_keys(recorder->entry, recorder->keys); + gtk_dialog_set_response_sensitive(GTK_DIALOG(recorder->dialog), GTK_RESPONSE_OK, TRUE); +} + +static guint recording_keyval(GdkEventKey *event) { + if (!GDK_IS_X11_DISPLAY(gdk_display_get_default())) return event->keyval; + // Match the base names published by the X11 observer, including keypad + // keys with Num Lock enabled. This records individual keys, not chords. + GdkKeymapKey key = {event->hardware_keycode, 0, 0}; + guint keyval = gdk_keymap_lookup_key(gdk_keymap_get_for_display(gdk_display_get_default()), &key); + return keyval ? keyval : event->keyval; +} + +static gboolean recording_key(GtkWidget *dialog, GdkEventKey *event, gpointer data) { + KeyRecorder *recorder = data; + guint state = event->state & gtk_accelerator_get_default_mod_mask(); + gboolean finish = state == GDK_CONTROL_MASK && + (event->keyval == GDK_KEY_Return || event->keyval == GDK_KEY_KP_Enter); + gboolean cancel = state == GDK_CONTROL_MASK && event->keyval == GDK_KEY_Escape; + if (event->type == GDK_KEY_PRESS && (finish || cancel)) { + g_hash_table_remove_all(recorder->modifiers); + if (cancel || recorder->keys->len) + gtk_dialog_response(GTK_DIALOG(dialog), cancel ? GTK_RESPONSE_CANCEL : GTK_RESPONSE_OK); + else + gtk_entry_set_text(GTK_ENTRY(recorder->entry), "Record at least one key, then press Control+Enter."); + return TRUE; + } + guint keyval = recording_keyval(event); + if (event->is_modifier) { + // Record modifiers on release so the Control used to finish or cancel + // never becomes an interruption key just because it ended recording. + if (event->type == GDK_KEY_PRESS) + g_hash_table_add(recorder->modifiers, GUINT_TO_POINTER(keyval)); + else if (g_hash_table_remove(recorder->modifiers, GUINT_TO_POINTER(keyval))) + record_key(recorder, keyval); + } else if (event->type == GDK_KEY_PRESS) { + record_key(recorder, keyval); + } + // Consume Tab, Enter, Escape and Space before GTK focus/default actions. + return TRUE; +} + +static gboolean recording_focus_out(GtkWidget *dialog, GdkEventFocus *event, gpointer data) { + (void)dialog; (void)event; + KeyRecorder *recorder = data; + g_hash_table_remove_all(recorder->modifiers); + return FALSE; +} + +static void free_recorder(gpointer data) { + KeyRecorder *recorder = data; + g_ptr_array_unref(recorder->keys); + g_hash_table_unref(recorder->modifiers); + g_free(recorder); +} + +static void recording_response(GtkDialog *dialog, int response, gpointer data) { + KeyRecorder *recorder = data; + if (response == GTK_RESPONSE_OK && recorder->keys->len) { + prefs_interruption_clear(); + for (unsigned i = 0; i < recorder->keys->len; i++) + prefs_interruption_append(g_ptr_array_index(recorder->keys, i)); + } + gtk_widget_destroy(GTK_WIDGET(dialog)); + gtk_window_present(GTK_WINDOW(prefs_widget("window"))); + gtk_widget_grab_focus(prefs_widget("recordInterruptionKeysButton")); +} + +static void start_recording(GtkButton *button, gpointer data) { + (void)button; (void)data; + const char *instructions = "Press each key that should interrupt speech. " + "Tab, Enter, Space, and Escape can be recorded. " + "Press and release a modifier by itself to record it. " + "Control+Enter finishes; Control+Escape cancels. " + "Recording replaces the previous keys. Up to 64 keys can be recorded."; + KeyRecorder *recorder = g_new0(KeyRecorder, 1); + recorder->keys = g_ptr_array_new_with_free_func(g_free); + recorder->modifiers = g_hash_table_new(g_direct_hash, g_direct_equal); + recorder->dialog = gtk_dialog_new_with_buttons("Record interruption keys", + GTK_WINDOW(prefs_widget("window")), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "Cancel recording", GTK_RESPONSE_CANCEL, "Finish recording", GTK_RESPONSE_OK, NULL); + g_object_set_data_full(G_OBJECT(recorder->dialog), "key-recorder", recorder, free_recorder); + GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(recorder->dialog)); + gtk_container_set_border_width(GTK_CONTAINER(content), 12); + GtkWidget *label = gtk_label_new(instructions); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(label), 65); + gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 8); + recorder->entry = gtk_entry_new(); + gtk_editable_set_editable(GTK_EDITABLE(recorder->entry), FALSE); + atk_object_set_name(gtk_widget_get_accessible(recorder->entry), instructions); + atk_object_set_description(gtk_widget_get_accessible(recorder->dialog), instructions); + gtk_box_pack_start(GTK_BOX(content), recorder->entry, FALSE, FALSE, 8); + show_keys(recorder->entry, recorder->keys); + gtk_dialog_set_response_sensitive(GTK_DIALOG(recorder->dialog), GTK_RESPONSE_OK, FALSE); + g_signal_connect(recorder->dialog, "key-press-event", G_CALLBACK(recording_key), recorder); + g_signal_connect(recorder->dialog, "key-release-event", G_CALLBACK(recording_key), recorder); + g_signal_connect(recorder->dialog, "focus-out-event", G_CALLBACK(recording_focus_out), recorder); + g_signal_connect(recorder->dialog, "response", G_CALLBACK(recording_response), recorder); + gtk_widget_show_all(recorder->dialog); + gtk_widget_grab_focus(recorder->entry); +} + +void prefs_init_interruption(void) { + g_object_set_data_full(G_OBJECT(prefs_widget("window")), "interruption-keys", + g_ptr_array_new_with_free_func(g_free), (GDestroyNotify)g_ptr_array_unref); + g_signal_connect(prefs_widget("interruptCustomKeysRadio"), "toggled", G_CALLBACK(interruption_sensitivity), NULL); + g_signal_connect(prefs_widget("recordInterruptionKeysButton"), "clicked", G_CALLBACK(start_recording), NULL); + atk_object_set_description(gtk_widget_get_accessible(prefs_widget("recordInterruptionKeysButton")), + "Record individual keys. Control+Enter finishes; Control+Escape cancels."); + atk_object_set_name(gtk_widget_get_accessible(prefs_widget("interruptionKeysEntry")), "Defined interruption keys"); + interruption_sensitivity(NULL, NULL); +} diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui index 83b6b03..7b05bba 100644 --- a/internal/preferences/preferences.ui +++ b/internal/preferences/preferences.ui @@ -141,6 +141,14 @@ Brie_fTrueTrue Ver_boseTrueTruespeechBriefButton + Speech interruptionvertical88 + All keys interruptTrue + Control, Enter, Space, and EscapeTrueinterruptAllKeysRadio + Define interruption keysTrueinterruptAllKeysRadio + Record interruption keysTrue + Defined keys:interruptionKeysEntry0 + TrueFalse + Speech history812 History _entries:TruespeechHistoryLimitSpinButton Truespeech_history_limit_adjustmentTrueNumber of spoken items kept in history diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go index 6bbfab3..2cd7cff 100644 --- a/internal/preferences/protocol.go +++ b/internal/preferences/protocol.go @@ -26,6 +26,7 @@ type Values struct { EnableSound bool EnableSpeech bool SpeechHistoryLimit int + SpeechInterruption input.InterruptionSettings SpeechVerbosityLevel int SayAllStyle string EnableBraille bool diff --git a/internal/preferences/speech.go b/internal/preferences/speech.go index a099d9a..13e8b24 100644 --- a/internal/preferences/speech.go +++ b/internal/preferences/speech.go @@ -19,6 +19,7 @@ import ( "unsafe" "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" ) func (session *uiSession) setSpeechVoice(values Values) { @@ -271,6 +272,15 @@ func (session *uiSession) reconcile_voice_families(module string) { } func set_speech_support(values Values) { + setCheck("interruptAllKeysRadio", values.SpeechInterruption.Mode != input.InterruptCommonKeys && values.SpeechInterruption.Mode != input.InterruptCustomKeys) + setCheck("interruptCommonKeysRadio", values.SpeechInterruption.Mode == input.InterruptCommonKeys) + setCheck("interruptCustomKeysRadio", values.SpeechInterruption.Mode == input.InterruptCustomKeys) + C.prefs_interruption_clear() + for _, name := range values.SpeechInterruption.Keys { + key := C.CString(name) + C.prefs_interruption_append(key) + C.free(unsafe.Pointer(key)) + } setCheck("speechSupportCheckButton", values.EnableSpeech) setSpin("speechHistoryLimitSpinButton", float64(values.SpeechHistoryLimit)) setCheck("speechBriefButton", values.SpeechVerbosityLevel == 0) @@ -313,6 +323,15 @@ func set_speech_presentation(values Values) { } func get_speech_support(values *Values) { + values.SpeechInterruption.Mode = input.InterruptAllKeys + if getCheck("interruptCommonKeysRadio") { + values.SpeechInterruption.Mode = input.InterruptCommonKeys + } else if getCheck("interruptCustomKeysRadio") { + values.SpeechInterruption.Mode = input.InterruptCustomKeys + } + for i := C.int(0); i < C.prefs_interruption_count(); i++ { + values.SpeechInterruption.Keys = append(values.SpeechInterruption.Keys, C.GoString(C.prefs_interruption_key(i))) + } values.EnableSpeech = getCheck("speechSupportCheckButton") values.SpeechHistoryLimit = int(getSpin("speechHistoryLimitSpinButton")) values.Reading.IndentationPresentationMode = "speech" diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go index 2d79d56..4c0a19b 100644 --- a/internal/preferences/ui.go +++ b/internal/preferences/ui.go @@ -5,7 +5,7 @@ package preferences /* -#cgo pkg-config: gtk+-3.0 +#cgo pkg-config: gtk+-3.0 xkbcommon #include #include "native.h" */ diff --git a/internal/preferences/values.go b/internal/preferences/values.go index d7d6fcf..3f1f8b8 100644 --- a/internal/preferences/values.go +++ b/internal/preferences/values.go @@ -17,8 +17,10 @@ func FromSettings(settings config.Settings) Values { KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...), KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat, EnableSound: settings.EnableSound, EnableBraille: settings.EnableBraille, - EnableSpeech: settings.EnableSpeech, - SpeechHistoryLimit: settings.SpeechHistoryLimit, + EnableSpeech: settings.EnableSpeech, + SpeechHistoryLimit: settings.SpeechHistoryLimit, + SpeechInterruption: input.InterruptionSettings{Mode: settings.SpeechInterruption.Mode, + Keys: append([]string(nil), settings.SpeechInterruption.Keys...)}, SpeechVerbosityLevel: settings.SpeechVerbosityLevel, SayAllStyle: settings.SayAllStyle, EnableContractedBraille: settings.EnableContractedBraille, BrailleContractionTable: settings.BrailleContractionTable, @@ -80,6 +82,8 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) { settings.EnableSound = values.EnableSound settings.EnableSpeech = values.EnableSpeech settings.SpeechHistoryLimit = values.SpeechHistoryLimit + settings.SpeechInterruption = input.InterruptionSettings{Mode: values.SpeechInterruption.Mode, + Keys: append([]string(nil), values.SpeechInterruption.Keys...)} settings.SpeechVerbosityLevel = values.SpeechVerbosityLevel settings.SayAllStyle = values.SayAllStyle settings.EnableBraille = values.EnableBraille diff --git a/internal/presentation/text_label_native_test.go b/internal/presentation/text_label_native_test.go index c70f53f..7142959 100644 --- a/internal/presentation/text_label_native_test.go +++ b/internal/presentation/text_label_native_test.go @@ -54,6 +54,7 @@ func TestNativeTextLabelRelations(t *testing.T) { {"Secret:", ""}, {"", "Unlabeled value"}, {"Account identifier", ""}, + {"Read-only summary:", "Example summary"}, } { if _, err := fmt.Fprintln(stdin, index); err != nil { t.Fatal(err) @@ -78,6 +79,9 @@ func TestNativeTextLabelRelations(t *testing.T) { if snapshot.Name != probe.name { t.Errorf("entry %d name = %q, want %q", index, snapshot.Name, probe.name) } + if probe.name == "Read-only summary:" && snapshot.Editable { + t.Error("read-only fixture unexpectedly exposes editable state") + } speech := FocusText(snapshot) for _, part := range []string{probe.name, probe.value} { if part != "" && !strings.Contains(speech, part) { diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 31e8677..20c0302 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -30,7 +30,7 @@ owned-object-tree,src/cthulhu/ax_object.py,review,Own stable accessible identiti object-navigation,src/cthulhu/object_navigator.py,review,Navigate the owned accessible hierarchy and present each object or boundary,Storm Dragon,internal/review internal/app internal/input internal/platform/xorg,in-progress,go test ./internal/review ./internal/app ./internal/input ./internal/platform/xorg -count=1; python3 tests/native_x11.py,Private X11 proof covers all four Cthulhu Control Arrow bindings with balanced consumption and no application leakage plus Insert-first and Control-first ordering for Up. Maintainer accepted physical Xorg press release behavior with no leaked or stuck keys on 2026-09-14.,,Maintainer heard all expected object and boundary announcements on physical Xorg on 2026-09-14.,,Runtime commands and asynchronous capture presentation are bound to Python's parent first-child previous-sibling and next-sibling gestures. Python simplified navigation filtering action invocation and toggle remain pending. Desktop numpad belongs to flat review and is intentionally not aliased to hierarchical object navigation. clipboard-present-command,src/cthulhu/plugins/Clipboard/plugin.py,command,Present clipboard text URI objects image presence empty state or access errors with Cthulhu+Shift+C,Storm Dragon,internal/input internal/clipboard internal/app internal/platform/xorg,in-progress,go test ./internal/clipboard ./internal/input ./internal/app ./internal/platform/xorg -count=1; isolated Xvfb clipboard helper and native command test,Private X11 proof covers Cthulhu+Shift+C press dispatch and both release orders; isolated GTK clipboard reads confirmed text URI and image formats,,Pending human speech acceptance for text URI image empty and error cases,,The Go command reads GTK 3 clipboard data in a short lived helper with a three second timeout so a stalled clipboard owner cannot block input or shutdown. It announces text URI object names and types image presence empty state and access errors. URI-only clipboard data is presented even when no text target exists. Physical Xorg listening remains pending. display-version-command,src/cthulhu/plugins/DisplayVersion/plugin.py,command,Speak the Cthulhu version plus revision AT-SPI version and session details with Cthulhu+Shift+V,Storm Dragon,internal/input internal/presentation internal/version,pending,,Pending Cthulhu+Shift+V X11 key-consumption and release gate,,Pending human listening gate,,Python registers the gesture once and presents only through an active script; the announcement conditionally includes the code name build revision and XDG session type and desktop. The Phase 0 audit also confirmed that plugin.info marks this as a visible non-builtin plugin. Go currently implements command-line version output only; that is not evidence for this spoken command. -game-mode-command,src/cthulhu/plugins/GameMode/plugin.py,input,Toggle reduced speech interruption for ordinary unbound key presses with Cthulhu+Control+G,Storm Dragon,internal/input internal/config internal/presentation,pending,,Pending global Cthulhu+Control+G and unbound-key X11 non-consumption gate,,Pending human listening gate for toggle messages and held-key speech continuity,,Python keeps interruption for handled or consumed commands and Cthulhu modifiers while game mode is enabled and resets the setting on plugin deactivation. The Phase 0 audit also confirmed that plugin.info marks this as a visible non-builtin plugin. The Go preview now interrupts on every observed key press; reduced-interruption game mode remains pending. +game-mode-command,src/cthulhu/plugins/GameMode/plugin.py,input,Toggle reduced speech interruption for ordinary unbound key presses with Cthulhu+Control+G,Storm Dragon,internal/input internal/config internal/presentation,pending,,Pending global Cthulhu+Control+G and unbound-key X11 non-consumption gate,,Pending human listening gate for toggle messages and held-key speech continuity,,Python keeps interruption for handled or consumed commands and Cthulhu modifiers while game mode is enabled and resets the setting on plugin deactivation. The Phase 0 audit also confirmed that plugin.info marks this as a visible non-builtin plugin. The Go preview defaults to interrupting on every observed key press and now offers persistent interruption choices in Speech preferences; the separate Game Mode toggle remains pending. hello-world-plugin-example,src/cthulhu/plugins/hello_world/plugin.py,extension,Register a learn-mode-aware Cthulhu+Z example command that presents hello world,Storm Dragon,api/lua/v1 docs/contributor,pending,,Pending extension-command X11 key-consumption and release gate,,Pending human listening and learn-mode acceptance,,Python is a visible non-builtin test plugin and directly accesses the active script without checking that it exists. Preserve the user-facing tutorial behavior in the process-isolated Lua extension examples rather than embedding this Python plugin. self-voice-socket,src/cthulhu/plugins/self_voice/plugin.py,remote,Accept external UTF-8 text over a local Unix socket for speech and braille presentation with append and persistent markers,Storm Dragon,,accepted-omission,,,,,,Decision 0005 omits XDG_RUNTIME_DIR/cthulhu.sock and its <#APPEND#> and <#PERSISTENT#> marker protocol in favor of the established Cthulhu D-Bus self-voice API. No socket bridge or compatibility parser will be implemented. self-voice-dbus-present,src/cthulhu/dbus_service.py,remote,Present an external message through normal speech and braille output over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test ./internal/remote ./internal/app -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus and handler tests pass same-user dispatch introspection and output queueing.,,Pending PresentMessage speech listening acceptance,Pending PresentMessage braille acceptance,PresentMessage queues speech without interruption and braille when connected; it returns false only if neither output is available. Physical display delivery remains unverified. @@ -44,7 +44,7 @@ review-component-geometry,src/cthulhu/flat_review.py,review,Capture component sc review-visible-text,src/cthulhu/flat_review.py,review,Capture bounded visible text with character offsets and screen clipping,Storm Dragon,internal/atspi,in-progress,DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/parity -count=1; DISPLAY=:0 python3 tests/native_atspi.py,Private ListBox and GtkBox capture edit clipping hidden-text and password-exclusion checks pass. Physical acceptance deferred.,,Pending navigation and listening gate,,Exact Unicode offsets and owned ranges include captured caret offsets. Text caret and geometry events invalidate cached text without retiring identities. Capture budgets are 65536 characters and 256 visible spans per object and 1 MiB per tree. Bounded line-rectangle discovery supports large objects without character rectangles; oversized lines use viewport glyph locations and bounded text spans. Zero-area glyphs no longer discard other visible text. Known unavailable objects and child subtrees leave healthy content reviewable with an explicit speech and Braille warning. Root failures and aggregate layout budgets remain fatal. Private GTK fixtures and worker tests pass; physical speech and Braille acceptance remain pending. See docs/testing/flat-review-robustness.md. date-time-commands,src/cthulhu/date_and_time_presenter.py,command,Speak local time with Cthulhu+T and date with a double tap using saved formats,Storm Dragon,internal/input internal/app internal/preferences internal/config,in-progress,go test ./... -count=1; focused race checks; native keyboard double-tap checks,Private Xvfb press-time and both-release-order checks passed; physical Xorg pending,,Pending human listening,Pending braille,All 7 Python time and 16 Python date format choices are wired including locale seconds word numeric full and abbreviated variants; see docs/testing/time-date.md. notifications,src/cthulhu/notification_presenter.py;src/cthulhu/scripts/apps/notification-daemon/script.py;src/cthulhu/scripts/apps/notify-osd/script.py;src/cthulhu/mako_notification_monitor.py,notification,Present provider notifications save history expose mako live actions speak the last notification and open the notification-list GUI,Storm Dragon,internal/notification internal/app internal/notificationui internal/atspi internal/input,in-progress,NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./... -count=1; NO_COLOR=1 python3 tests/native_notifications.py,,,Automated speech-queue history list-state and list-action assertions passed; headless Xvfb/AT-SPI GUI proof passed; human listening and physical Xorg use not verified,Braille not implemented,Go registers window-create and accessible-name events maps notification-daemon mate and xfce4 providers handles notify-osd gauge and name events keeps a 55-entry history and adds Cthulhu+N plus Cthulhu+Ctrl+N. The modal accessible list opens in a GTK helper process with sortable rows copy dismiss clear close dynamic actions and mako live refresh. Live provider Xorg acceptance remains pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. -ordinary-key-speech-interrupt,src/cthulhu/input_event.py,input,Interrupt speech on an ordinary key press even if focus does not move,,internal/input internal/app,implemented,go test ./internal/input ./internal/app -run TestProcessInputEventsInterruptsSpeechOnKeyPress -count=1,,,,,Python KeyboardEvent._present interrupts on press outside game mode. Go now interrupts on every observed press; physical FastSMRW table-boundary listening remains pending. +ordinary-key-speech-interrupt,src/cthulhu/input_event.py,input,Interrupt speech on an ordinary key press even if focus does not move,,internal/input internal/app,implemented,go test ./internal/input ./internal/app -run TestProcessInputEventsInterruptsSpeechOnKeyPress -count=1,,,,,Python KeyboardEvent._present interrupts on press outside game mode. Go interrupts on every observed press by default; Speech preferences can restrict interruption to common or recorded keys. Physical FastSMRW table-boundary listening remains pending. table-cell-selection-speech,src/cthulhu/speech_generator.py; src/cthulhu/scripts/default.py,speech,Omit selected from table-cell focus speech and avoid deselection chatter during movement,,internal/presentation internal/app,implemented,go test ./internal/presentation ./internal/app -count=1,,,,,Selected table-cell focus omits the state; a focused selected-state event can still announce selected. Physical FastSMRW listening remains pending. braille-focused-text-routing,src/cthulhu/braille.py,braille,Route display cells to the native focused text caret,,internal/braille internal/app internal/atspi,in-progress,go test -race -tags brlapi ./internal/braille ./internal/app ./internal/atspi -count=1; python3 tests/native_atspi.py,Private GTK/Xvfb routes Unicode and EOL carets and rejects stale content and old focus. Physical Xorg pending.,,,Physical display routing and BRLTTY ownership recovery pending.,Frame IDs and panned Unicode offsets return through the helper; the GLib owner revalidates focus and source text. Component actions and mouse fallback; virtual browser carets; contracted and review routing remain pending. See docs/testing/braille.md. braille-focused-object-formatting,src/cthulhu/formatting.py;src/cthulhu/braille_generator.py;src/cthulhu/object_properties.py,braille,Format focused text and common controls for braille,,internal/app internal/atspi,in-progress,go test ./internal/app ./internal/atspi ./internal/braille -count=1,Private GTK/Xvfb verifies native Unicode capture and caret offsets.,,,Physical text and control reading pending.,Default verbose long-role subset: control indicators; expansion; values; EOL and text state suffixes. Full ancestor/group/table context; labels distinct from names; masks; translation; preferences and application overrides remain pending. Reference inspected at local Python b9dd2eb2bf96143309ba0c724c47f86d978826ef; the original inventory oracle commit is unavailable in that checkout. See docs/testing/braille.md. @@ -66,3 +66,4 @@ flatpak-foreground-identity,src/cthulhu/script_utilities.py:canBeActiveWindow,fo present-window-title,Orca src/orca/where_am_i_presenter.py;src/orca/where_am_i_presenter_command_definitions.py,command,Read the foreground window title with Cthulhu+Numpad Enter on desktop or Cthulhu+Slash on laptop,,internal/input internal/app internal/platform/xorg,in-progress,go test ./internal/input ./internal/app ./internal/platform/xorg -count=1; focused title command race checks; python3 tests/check_speech.py,Private Xvfb command dispatch verifies both layout bindings and both release orders. Private X11 and AT-SPI event loop repeats the current title with automatic changes disabled.,,Speech queue assertions passed; physical keyboard and audible acceptance pending.,Braille flash queue assertion passed; physical display acceptance pending.,Reuses copied foreground title metadata and the owned accessible title. Explicit requests bypass automatic-title policy and duplicate suppression without moving focus. Stale native epochs and unrelated native windows cannot supply a title. Lock and terminal handoff remain enforced. Double-press status-bar reading is outside this change. See tests/window_titles.md. edit-field-relation-labels,src/cthulhu/generator.py:_generateLabel;src/cthulhu/script_utilities.py:displayedLabel,speech,Announce relation-only labels for unnamed edit fields,,internal/atspi internal/presentation,implemented,CTHULHU_NATIVE_EDIT_LABELS=1 python3 tests/native_atspi.py; go test ./... -count=1; go vet ./...,Private GTK/Xvfb verifies single and multiple labels plus existing names and password exclusion. Read-only live irctest capture verifies all three settings labels. Maintainer confirmed installed-reader label speech on physical Xorg on 2026-10-20.,,Formatted speech includes each label. Maintainer confirmed the installed reader speaks irctest settings field labels on 2026-10-20.,Physical Braille check pending.,Direct LABELLED_BY targets supply an empty text or entry or password-field name. Queries and output are bounded; explicit names are preserved. Human diff review remains required before submission. mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py,web,Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels,,internal/atspi internal/review internal/app,in-progress,"go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py",Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass.,,Recorded control-event assertions preserve full link focus output and subsequent arrow feedback. Original Thunderbird message and audible acceptance remain pending.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required. +speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences",,internal/input internal/config internal/preferences internal/app,in-progress,go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1,"Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload.",Physical compositor acceptance pending.,Physical listening pending.,Physical display acceptance pending.,All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile. diff --git a/tests/native_echo_preview.py b/tests/native_echo_preview.py index df207db..c2625cb 100644 --- a/tests/native_echo_preview.py +++ b/tests/native_echo_preview.py @@ -185,7 +185,35 @@ def check_flat_review(tabs, server, readerLog): server.snapshot()[-1]["finished"], "return to tab header was suppressed") -def check_runtime(radioOnly=False, flatReviewOnly=False): +def check_interruption_summary(controls, tabs, server): + import pyatspi + + def key(*keys): + subprocess.run(["xdotool", "key", "--clearmodifiers", *keys], check=True) + + assert tabs.querySelection().selectChild(2) + summary = controls["Defined interruption keys"] + assert summary.queryText().getText(0, -1) == "Tab, Enter" + assert not summary.getState().contains(pyatspi.STATE_EDITABLE) + assert controls["Record interruption keys"].queryComponent().grabFocus() + offset = len(server.snapshot()) + key("Tab") + wait_for(lambda: any(record["text"] == "Tab, Enter" and record["finished"] + for record in server.snapshot()[offset:]), "read-only summary contents were not spoken on focus") + assert any(record["text"] == "Defined interruption keys" for record in server.snapshot()[offset:]) + key("Home") + for keyName, character, caret in (("Right", "a", 1), ("Left", "T", 0)): + offset = len(server.snapshot()) + key(keyName) + wait_for(lambda: summary.queryText().caretOffset == caret, "summary caret did not move for " + keyName) + wait_for(lambda: any(record["text"] == character and record["finished"] + for record in server.snapshot()[offset:]), "summary character was not spoken for " + keyName) + key("shift+Tab") + wait_for(lambda: controls["Record interruption keys"].getState().contains(pyatspi.STATE_FOCUSED), + "read-only summary trapped Shift+Tab") + + +def check_runtime(radioOnly=False, flatReviewOnly=False, interruptionOnly=False): import pyatspi directory = Path(tempfile.mkdtemp(prefix="cthulhu-echo-runtime-")) binary = os.environ["CTHULHU_BINARY"] @@ -218,6 +246,9 @@ gain = 5 name = "Echo A" lang = "en" ''') + if interruptionOnly: + with configPath.open("a") as settingsFile: + settingsFile.write('\n[profiles.default.speech.interruption]\nmode = "custom"\nkeys = ["Tab", "Return"]\n') if radioOnly: configPath.write_text(configPath.read_text().replace( "[profiles.default.sound]\nenabled = false", @@ -257,6 +288,10 @@ lang = "en" 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) + if interruptionOnly: + check_interruption_summary(controls, tabs, server) + print(f"PASS: real reader speaks read-only interruption summary on focus and character arrows; Tab traversal preserved; evidence: {directory}") + return if flatReviewOnly: check_flat_review(tabs, server, directory / "reader.log") print(f"PASS: notebook flat review has no repeated tab speech; tab changes and focus returns speak; evidence: {directory}") diff --git a/tests/native_preferences.py b/tests/native_preferences.py index c8ffa41..5c490bf 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -46,6 +46,10 @@ class ResponseReader: def inside(): + if os.environ.get("CTHULHU_PREFS_INTERRUPTION_RUNTIME_ONLY") == "1": + from native_echo_preview import check_runtime + check_runtime(interruptionOnly=True) + return if os.environ.get("CTHULHU_PREFS_FLAT_REVIEW_ONLY") == "1": from native_echo_preview import check_runtime check_runtime(flatReviewOnly=True) @@ -202,6 +206,103 @@ def inside(): xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^" + window_name + "$"], text=True).splitlines()[0] subprocess.run(["xdotool", "windowfocus", xid], check=True) assert not any(node.name == "Profiles" for node in nodes), "removed profile controls remain in preferences" + if os.environ.get("CTHULHU_PREFS_INTERRUPTION_ONLY") == "1": + from native_x11 import press_physical_key + tabs = next(node for node in nodes if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + assert tabs.querySelection().selectChild(2), "cannot select Speech tab" + allKeys = named("All keys interrupt") + commonKeys = named("Control, Enter, Space, and Escape") + customKeys = named("Define interruption keys") + recordButton = named("Record interruption keys") + summary = named("Defined interruption keys") + history = named("Speech history entries") + statusNode = named(initial["Message"]) + applyCount = 0 + + def is_focused(node): + return any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)) + + def apply_interruption(mode, expectedKeys): + nonlocal applyCount + focus(named("Apply")); key("space") + applied = request() + assert applied["Save"] and not applied["Close"], applied + values = applied["Values"] + assert values["SpeechInterruption"] == {"Mode": mode, "Keys": expectedKeys}, values["SpeechInterruption"] + applyCount += 1 + message = f"Interruption settings saved {applyCount}." + send(dict(initial, Values=values, Message=message)) + wait_for(lambda: statusNode.name == message and named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), + "Apply response did not finish reloading the draft") + return values + + def recorder(): + for app in pyatspi.Registry.getDesktop(0): + for child in app: + if child and child.name == "Record interruption keys" and child.getRole() == pyatspi.ROLE_DIALOG: + return child + return None + + def begin_recording(): + focus(recordButton); key("space") + dialog = wait_for(recorder, "key recorder did not open") + dialogID = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^Record interruption keys$"], text=True).splitlines()[0] + subprocess.run(["xdotool", "windowfocus", dialogID], check=True) + assert "Control+Enter" in dialog.description and "Control+Escape" in dialog.description + return dialog + + assert allKeys.getState().contains(pyatspi.STATE_CHECKED) + assert not recordButton.getState().contains(pyatspi.STATE_SENSITIVE) + apply_interruption("all", None) + focus(named("Verbose")); key("Tab") + wait_for(lambda: is_focused(allKeys), "Tab did not reach interruption group; focused: " + + repr([(node.name, node.getRoleName()) for node in walk(window) if node.getState().contains(pyatspi.STATE_FOCUSED)])) + key("Down") + wait_for(lambda: commonKeys.getState().contains(pyatspi.STATE_CHECKED), "arrow did not select common keys") + apply_interruption("common", None) + focus(commonKeys); key("Down") + wait_for(lambda: customKeys.getState().contains(pyatspi.STATE_CHECKED), "arrow did not select custom keys") + key("Tab") + wait_for(lambda: is_focused(recordButton), "Tab did not reach recording button") + key("Tab") + wait_for(lambda: is_focused(summary), "Tab did not reach defined keys") + key("Home", "Right") + wait_for(lambda: summary.queryText().caretOffset == 1, "read-only summary did not move its caret") + key("Tab") + wait_for(lambda: is_focused(history), "defined keys trapped Tab") + key("shift+Tab", "shift+Tab", "shift+Tab") + wait_for(lambda: is_focused(customKeys), "Shift+Tab did not return through new controls") + dialog = begin_recording() + key("Tab", "Return", "space", "Escape", "Tab") + press_physical_key(0xFFE1) # Left Shift, without synthesized modifiers. + press_physical_key(0xFFE4) # Right Control. + press_physical_key(0xFF55) # Page Up: GTK calls this Page_Up, X11 calls it Prior. + press_physical_key(0x1008FF12) # Audio mute: GTK omits the XF86 prefix. + assert recorder() is not None, "a recordable key closed the recorder" + key("ctrl+Return") + wait_for(lambda: recorder() is None, "Control+Enter did not finish") + wait_for(lambda: is_focused(recordButton), "recording did not return focus to button") + expectedKeys = ["Tab", "Return", "space", "Escape", "Shift_L", "Control_R", "Prior", "XF86AudioMute"] + saved = apply_interruption("custom", expectedKeys) + assert customKeys.getState().contains(pyatspi.STATE_CHECKED), "Apply lost custom mode" + assert "Tab" in summary.queryText().getText(0, -1), "Apply lost displayed keys" + begin_recording(); key("F8", "ctrl+Escape") + wait_for(lambda: recorder() is None, "Control+Escape did not cancel") + wait_for(lambda: is_focused(recordButton), "cancellation did not return focus") + apply_interruption("custom", expectedKeys) + begin_recording(); key("ctrl+Return") + assert recorder() is not None, "empty recording was accepted" + key("F8", "ctrl+Return") + wait_for(lambda: recorder() is None, "replacement recording did not finish") + apply_interruption("custom", ["F8"]) + # Non-custom modes preserve the defined list for later reuse. + focus(customKeys); key("Up", "Up") + wait_for(lambda: allKeys.getState().contains(pyatspi.STATE_CHECKED), "arrows did not return to All keys interrupt") + apply_interruption("all", ["F8"]) + focus(named("Cancel")); key("space") + assert process.wait(timeout=5) == 0 + print("PASS: interruption modes, keyboard traversal, Tab/Enter/Space/Escape and modifier recording, finish/cancel, duplicates, replacement, Apply and draft reload") + return 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" diff --git a/tests/text_label_fixture.py b/tests/text_label_fixture.py index e0a4de9..c978a19 100644 --- a/tests/text_label_fixture.py +++ b/tests/text_label_fixture.py @@ -45,6 +45,14 @@ def main(): combined.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, label.get_accessible()) box.pack_start(combined, False, False, 0) entries.append(combined) + summary = Gtk.Entry() + summary.set_text("Example summary") + summary.set_editable(False) + label = Gtk.Label(label="Read-only summary:") + label.set_mnemonic_widget(summary) + box.pack_start(label, False, False, 0) + box.pack_start(summary, False, False, 0) + entries.append(summary) window.show_all() Gdk.Display.get_default().sync() subprocess.run(["xdotool", "windowfocus", str(GdkX11.X11Window.get_xid(window.get_window()))], check=True)