Options for controlling how and when speech is interrupted now wired up in preferences under the speech tab.

This commit is contained in:
Storm Dragon committed 2026-10-22 03:25:40 -04:00
1 parent e62f8c8e17
commit 3e9c4f83ce
32 files changed
+784 -47

No files matched your search

+21 -5
View File
@@ -55,9 +55,25 @@ Native X11 grab-window regression:
Incoming announcements queue in order. Focus, selection, state changes, Incoming announcements queue in order. Focus, selection, state changes,
notifications, tooltips and terminal output let earlier speech finish. notifications, tooltips and terminal output let earlier speech finish.
Passive announcements also queue behind Say All. A new key press or reader Passive announcements also queue behind Say All. By default, a new key press
command stops earlier speech and clears its pending announcements. Delayed or reader command stops earlier speech and clears its pending announcements.
copies of the key that started a command preserve that command's response. 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 Screen lock, shutdown, speech configuration changes and explicit stop requests
retain their lifecycle behavior. Existing Tentacle requests marked 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. Key names finish before the resulting navigation or focus announcement starts.
For example, Right speaks "right" in the selected key echo voice, followed by 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 the character at the caret in the normal reading voice. A configured interruption
interrupts the previous key's speech and discards its pending feedback. 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 Changing the custom echo synthesizer, person, rate, pitch or volume speaks the
selected value using the echo voice and its draft settings, before Apply. selected value using the echo voice and its draft settings, before Apply.
@@ -4,7 +4,7 @@
pkgbase=cthulhu-git pkgbase=cthulhu-git
pkgname=cthulhu-git pkgname=cthulhu-git
_pkgname=cthulhu _pkgname=cthulhu
pkgver=2026.10.22.r64.gb742617 pkgver=2026.10.22.r65.ge62f8c8
pkgrel=1 pkgrel=1
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
+48 -2
View File
@@ -1,6 +1,6 @@
# Speech queue verification # 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 reader command dispatch and explicit stop requests. An announcement arriving
later from the same action must let earlier speech finish. This includes focus, later from the same action must let earlier speech finish. This includes focus,
selection, state, notification and delayed review output. Accepted Tentacle 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. 1. In keyboard preferences, change Desktop to Laptop, release the key and wait.
Laptop should finish, followed by the role/state announcements. Repeat in Laptop should finish, followed by the role/state announcements. Repeat in
the other direction and test radio buttons, checkboxes and combo boxes. 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; Tab and a reader command. Old speech and its queued feedback should stop;
the new action should speak normally. Releasing keys should leave speech the new action should speak normally. Releasing keys should leave speech
alone. Test rapid presses and held arrows, including automatic titles. 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 configuration/backend and shutdown retain their existing behavior. Protected
Tentacle messages retain their explicit timeout and cancellation rules. The Tentacle messages retain their explicit timeout and cancellation rules. The
bounded queue can discard pending requests on overflow and logs that loss. 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.
+12 -10
View File
@@ -64,7 +64,18 @@ func processInputEvents(
if len(ocrSessions) > 0 { if len(ocrSessions) > 0 {
ocrSessions[0].Input(event) 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, ok := speech.(interface{ InterruptForInput(input.Event) bool }); ok {
if !interrupt.InterruptForInput(event) { if !interrupt.InterruptForInput(event) {
continue continue
@@ -77,15 +88,6 @@ func processInputEvents(
logger.Debug("queued speech interrupted by key press", "input_timestamp", event.Timestamp, logger.Debug("queued speech interrupted by key press", "input_timestamp", event.Timestamp,
"input_keycode", event.Keycode, "input_repeat", event.Repeat) "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 != "" { if text := echo.Text(event); text != "" {
speech.Enqueue(speechRequest{Text: text, Role: "key", Cause: "key-echo", CustomEcho: custom}) speech.Enqueue(speechRequest{Text: text, Role: "key", Cause: "key-echo", CustomEcho: custom})
logger.Debug("queued non-printable key echo", "kind", event.Kind) logger.Debug("queued non-printable key echo", "kind", event.Kind)
+89
View File
@@ -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")
}
}
+6 -3
View File
@@ -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 // Text and caret capture make synchronous AT-SPI calls. Focus events for
// ordinary controls do not use that text in presentation, so avoid stalling // 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 // the GLib event loop for each Tab stop. Focusable text controls include
// character text even when they originate in static web content. // 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 && 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); snapshot.text = cthulhu_capture_text(source, &snapshot.textBeforeCaret, &snapshot.textStartOffset);
} }
if (captureCaretCharacter && g_strcmp0(snapshot.role, "password text") != 0) { if (captureCaretCharacter && g_strcmp0(snapshot.role, "password text") != 0) {
+3
View File
@@ -17,6 +17,8 @@
package config package config
import "git.stormux.org/storm/cthulhu/internal/input"
func DefaultSettings() Settings { func DefaultSettings() Settings {
return Settings{ return Settings{
OCR: OCRSettings{Layout: 3, Scale: 1, Threshold: 200, RefreshInterval: 1500, ConfirmClick: true}, OCR: OCRSettings{Layout: 3, Scale: 1, Threshold: 200, RefreshInterval: 1500, ConfirmClick: true},
@@ -27,6 +29,7 @@ func DefaultSettings() Settings {
RoleSoundPresentation: "sound_and_speech", RoleSoundPresentation: "sound_and_speech",
EnableSpeech: true, EnableSpeech: true,
SpeechHistoryLimit: DefaultSpeechHistoryLimit, SpeechHistoryLimit: DefaultSpeechHistoryLimit,
SpeechInterruption: input.InterruptionSettings{Mode: input.InterruptAllKeys},
Braille: BrailleOptions{Verbosity: 1, SelectionIndicator: 0xc0, LinkIndicator: 0xc0, Braille: BrailleOptions{Verbosity: 1, SelectionIndicator: 0xc0, LinkIndicator: 0xc0,
FlashMessages: true, FlashTime: 5000, FlashDetailed: true}, FlashMessages: true, FlashTime: 5000, FlashDetailed: true},
SpeechServerFactory: "speechdispatcherfactory", SpeechServerFactory: "speechdispatcherfactory",
+46
View File
@@ -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")
}
}
+9
View File
@@ -108,6 +108,7 @@ func profileFromSettings(settings Settings) writableProfile {
}, },
}, },
Speech: writableSpeech{ Speech: writableSpeech{
Interruption: interruption_pointer(settings.SpeechInterruption),
HardwareDevice: stringPointer(settings.HardwareSpeechDevice), HardwareDevice: stringPointer(settings.HardwareSpeechDevice),
HardwareBaudRate: intPointer(settings.HardwareSpeechBaudRate), HardwareBaudRate: intPointer(settings.HardwareSpeechBaudRate),
Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory), 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) { 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 { if value := profile.Speech.Enabled; value != nil {
settings.EnableSpeech = *value 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) { func apply_profile_speech(settings *Settings, profile writableProfile) {
if value := profile.Speech.HardwareDevice; value != nil { if value := profile.Speech.HardwareDevice; value != nil {
settings.HardwareSpeechDevice = *value settings.HardwareSpeechDevice = *value
+16 -15
View File
@@ -118,21 +118,22 @@ type writableReading struct {
} }
type writableSpeech struct { type writableSpeech struct {
HardwareDevice *string `toml:"hardware-device,omitempty"` Interruption *input.InterruptionSettings `toml:"interruption,omitempty"`
HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"` HardwareDevice *string `toml:"hardware-device,omitempty"`
OutputModule *string `toml:"output-module,omitempty"` HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
Enabled *bool `toml:"enabled,omitempty"` OutputModule *string `toml:"output-module,omitempty"`
HistoryLimit *int `toml:"history-limit,omitempty"` Enabled *bool `toml:"enabled,omitempty"`
ServerFactory *string `toml:"server-factory,omitempty"` HistoryLimit *int `toml:"history-limit,omitempty"`
Punctuation *int `toml:"punctuation-style,omitempty"` ServerFactory *string `toml:"server-factory,omitempty"`
Capitalization *string `toml:"capitalization-style,omitempty"` Punctuation *int `toml:"punctuation-style,omitempty"`
RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"` Capitalization *string `toml:"capitalization-style,omitempty"`
BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"` RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"`
SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"` BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"`
DefaultVoice *Voice `toml:"default-voice,omitempty"` SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"`
UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"` DefaultVoice *Voice `toml:"default-voice,omitempty"`
HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"` UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
SystemVoice *Voice `toml:"system-voice,omitempty"` HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
SystemVoice *Voice `toml:"system-voice,omitempty"`
} }
type writableBraille struct { type writableBraille struct {
+1
View File
@@ -132,6 +132,7 @@ type Settings struct {
ActiveProfile string ActiveProfile string
EnableSpeech bool EnableSpeech bool
SpeechHistoryLimit int SpeechHistoryLimit int
SpeechInterruption input.InterruptionSettings
EnableBraille bool EnableBraille bool
EnableContractedBraille bool EnableContractedBraille bool
BrailleContractionTable string BrailleContractionTable string
+1
View File
@@ -22,6 +22,7 @@ import (
) )
func cloneSettings(settings Settings) Settings { func cloneSettings(settings Settings) Settings {
settings.SpeechInterruption.Keys = append([]string(nil), settings.SpeechInterruption.Keys...)
settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings) settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings)
settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...) settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...)
settings.Pronunciations = append([]PronunciationEntry(nil), settings.Pronunciations...) settings.Pronunciations = append([]PronunciationEntry(nil), settings.Pronunciations...)
+3
View File
@@ -33,6 +33,9 @@ import (
func Validate(settings Settings) error { return validate(settings) } func Validate(settings Settings) error { return validate(settings) }
func validate(settings Settings) error { 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 || 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.Scale < 1 || settings.OCR.Scale > 4 || settings.OCR.Threshold < 0 || settings.OCR.Threshold > 255 ||
settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 || settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 ||
-4
View File
@@ -120,10 +120,6 @@ func (modifiers ModifierKeycodes) Contains(keycode uint8) bool {
return modifiers[keycode] return modifiers[keycode]
} }
func ShouldInterruptSpeech(event Event) bool {
return event.Pressed
}
// Automatic titles survive held-key repeats, but fresh input and explicit // Automatic titles survive held-key repeats, but fresh input and explicit
// stop keys cancel both scheduled and already queued title speech. // stop keys cancel both scheduled and already queued title speech.
func ShouldInterruptTitle(event Event) bool { func ShouldInterruptTitle(event Event) bool {
+1 -1
View File
@@ -19,7 +19,7 @@ func TestShouldInterruptSpeechOnKeyPress(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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) t.Fatalf("ShouldInterruptSpeech() = %v, want %v", got, test.want)
} }
}) })
+72
View File
@@ -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
}
}
+47
View File
@@ -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)
}
}
}
+27
View File
@@ -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")
}
}
+1
View File
@@ -159,6 +159,7 @@ int prefs_init(const char *xml) {
prefs_init_reading_settings(); prefs_init_reading_settings();
prefs_init_key_bindings(); prefs_init_key_bindings();
prefs_init_modifiers(); prefs_init_modifiers();
prefs_init_interruption();
prefs_init_pronunciation(); prefs_init_pronunciation();
prefs_init_text_attributes(); prefs_init_text_attributes();
prefs_init_tentacles(); prefs_init_tentacles();
+4
View File
@@ -44,6 +44,10 @@ int prefs_binding_selected(void);
void prefs_binding_select(int row); void prefs_binding_select(int row);
void prefs_modifier_set(const char *id, int enabled); 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); int prefs_modifier_get(const char *id);
void prefs_init_pronunciation(void); void prefs_init_pronunciation(void);
void prefs_pronunciation_clear(void); void prefs_pronunciation_clear(void);
+1
View File
@@ -11,6 +11,7 @@ GtkWidget *prefs_widget(const char *id);
void prefs_set_action(int value); void prefs_set_action(int value);
void prefs_init_key_bindings(void); void prefs_init_key_bindings(void);
void prefs_init_modifiers(void); void prefs_init_modifiers(void);
void prefs_init_interruption(void);
void prefs_init_reading_settings(void); void prefs_init_reading_settings(void);
void prefs_init_pronunciation(void); void prefs_init_pronunciation(void);
void prefs_init_text_attributes(void); void prefs_init_text_attributes(void);
+188
View File
@@ -0,0 +1,188 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
#include "native_internal.h"
#include <gdk/gdkx.h>
#include <xkbcommon/xkbcommon.h>
// 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);
}
+8
View File
@@ -141,6 +141,14 @@
<child><object class="GtkRadioButton" id="speechBriefButton"><property name="label">Brie_f</property><property name="use-underline">True</property><property name="can-focus">True</property></object></child> <child><object class="GtkRadioButton" id="speechBriefButton"><property name="label">Brie_f</property><property name="use-underline">True</property><property name="can-focus">True</property></object></child>
<child><object class="GtkRadioButton" id="speechVerboseButton"><property name="label">Ver_bose</property><property name="use-underline">True</property><property name="can-focus">True</property><property name="group">speechBriefButton</property></object></child> <child><object class="GtkRadioButton" id="speechVerboseButton"><property name="label">Ver_bose</property><property name="use-underline">True</property><property name="can-focus">True</property><property name="group">speechBriefButton</property></object></child>
</object></child></object></child> </object></child></object></child>
<child><object class="GtkFrame"><property name="label">Speech interruption</property><child><object class="GtkBox"><property name="orientation">vertical</property><property name="border-width">8</property><property name="spacing">8</property>
<child><object class="GtkRadioButton" id="interruptAllKeysRadio"><property name="label">All keys interrupt</property><property name="can-focus">True</property></object></child>
<child><object class="GtkRadioButton" id="interruptCommonKeysRadio"><property name="label">Control, Enter, Space, and Escape</property><property name="can-focus">True</property><property name="group">interruptAllKeysRadio</property></object></child>
<child><object class="GtkRadioButton" id="interruptCustomKeysRadio"><property name="label">Define interruption keys</property><property name="can-focus">True</property><property name="group">interruptAllKeysRadio</property></object></child>
<child><object class="GtkButton" id="recordInterruptionKeysButton"><property name="label">Record interruption keys</property><property name="can-focus">True</property></object></child>
<child><object class="GtkLabel"><property name="label">Defined keys:</property><property name="mnemonic-widget">interruptionKeysEntry</property><property name="xalign">0</property></object></child>
<child><object class="GtkEntry" id="interruptionKeysEntry"><property name="can-focus">True</property><property name="editable">False</property></object></child>
</object></child></object></child>
<child><object class="GtkFrame"><property name="label">Speech history</property><child><object class="GtkBox"><property name="border-width">8</property><property name="spacing">12</property> <child><object class="GtkFrame"><property name="label">Speech history</property><child><object class="GtkBox"><property name="border-width">8</property><property name="spacing">12</property>
<child><object class="GtkLabel"><property name="label">History _entries:</property><property name="use-underline">True</property><property name="mnemonic-widget">speechHistoryLimitSpinButton</property></object></child> <child><object class="GtkLabel"><property name="label">History _entries:</property><property name="use-underline">True</property><property name="mnemonic-widget">speechHistoryLimitSpinButton</property></object></child>
<child><object class="GtkSpinButton" id="speechHistoryLimitSpinButton"><property name="can-focus">True</property><property name="adjustment">speech_history_limit_adjustment</property><property name="numeric">True</property><property name="tooltip-text">Number of spoken items kept in history</property></object></child> <child><object class="GtkSpinButton" id="speechHistoryLimitSpinButton"><property name="can-focus">True</property><property name="adjustment">speech_history_limit_adjustment</property><property name="numeric">True</property><property name="tooltip-text">Number of spoken items kept in history</property></object></child>
+1
View File
@@ -26,6 +26,7 @@ type Values struct {
EnableSound bool EnableSound bool
EnableSpeech bool EnableSpeech bool
SpeechHistoryLimit int SpeechHistoryLimit int
SpeechInterruption input.InterruptionSettings
SpeechVerbosityLevel int SpeechVerbosityLevel int
SayAllStyle string SayAllStyle string
EnableBraille bool EnableBraille bool
+19
View File
@@ -19,6 +19,7 @@ import (
"unsafe" "unsafe"
"git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
) )
func (session *uiSession) setSpeechVoice(values Values) { func (session *uiSession) setSpeechVoice(values Values) {
@@ -271,6 +272,15 @@ func (session *uiSession) reconcile_voice_families(module string) {
} }
func set_speech_support(values Values) { 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) setCheck("speechSupportCheckButton", values.EnableSpeech)
setSpin("speechHistoryLimitSpinButton", float64(values.SpeechHistoryLimit)) setSpin("speechHistoryLimitSpinButton", float64(values.SpeechHistoryLimit))
setCheck("speechBriefButton", values.SpeechVerbosityLevel == 0) setCheck("speechBriefButton", values.SpeechVerbosityLevel == 0)
@@ -313,6 +323,15 @@ func set_speech_presentation(values Values) {
} }
func get_speech_support(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.EnableSpeech = getCheck("speechSupportCheckButton")
values.SpeechHistoryLimit = int(getSpin("speechHistoryLimitSpinButton")) values.SpeechHistoryLimit = int(getSpin("speechHistoryLimitSpinButton"))
values.Reading.IndentationPresentationMode = "speech" values.Reading.IndentationPresentationMode = "speech"
+1 -1
View File
@@ -5,7 +5,7 @@
package preferences package preferences
/* /*
#cgo pkg-config: gtk+-3.0 #cgo pkg-config: gtk+-3.0 xkbcommon
#include <stdlib.h> #include <stdlib.h>
#include "native.h" #include "native.h"
*/ */
+6 -2
View File
@@ -17,8 +17,10 @@ func FromSettings(settings config.Settings) Values {
KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...), KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...),
KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat, KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat,
EnableSound: settings.EnableSound, EnableBraille: settings.EnableBraille, EnableSound: settings.EnableSound, EnableBraille: settings.EnableBraille,
EnableSpeech: settings.EnableSpeech, EnableSpeech: settings.EnableSpeech,
SpeechHistoryLimit: settings.SpeechHistoryLimit, SpeechHistoryLimit: settings.SpeechHistoryLimit,
SpeechInterruption: input.InterruptionSettings{Mode: settings.SpeechInterruption.Mode,
Keys: append([]string(nil), settings.SpeechInterruption.Keys...)},
SpeechVerbosityLevel: settings.SpeechVerbosityLevel, SpeechVerbosityLevel: settings.SpeechVerbosityLevel,
SayAllStyle: settings.SayAllStyle, SayAllStyle: settings.SayAllStyle,
EnableContractedBraille: settings.EnableContractedBraille, BrailleContractionTable: settings.BrailleContractionTable, 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.EnableSound = values.EnableSound
settings.EnableSpeech = values.EnableSpeech settings.EnableSpeech = values.EnableSpeech
settings.SpeechHistoryLimit = values.SpeechHistoryLimit settings.SpeechHistoryLimit = values.SpeechHistoryLimit
settings.SpeechInterruption = input.InterruptionSettings{Mode: values.SpeechInterruption.Mode,
Keys: append([]string(nil), values.SpeechInterruption.Keys...)}
settings.SpeechVerbosityLevel = values.SpeechVerbosityLevel settings.SpeechVerbosityLevel = values.SpeechVerbosityLevel
settings.SayAllStyle = values.SayAllStyle settings.SayAllStyle = values.SayAllStyle
settings.EnableBraille = values.EnableBraille settings.EnableBraille = values.EnableBraille
@@ -54,6 +54,7 @@ func TestNativeTextLabelRelations(t *testing.T) {
{"Secret:", ""}, {"Secret:", ""},
{"", "Unlabeled value"}, {"", "Unlabeled value"},
{"Account identifier", ""}, {"Account identifier", ""},
{"Read-only summary:", "Example summary"},
} { } {
if _, err := fmt.Fprintln(stdin, index); err != nil { if _, err := fmt.Fprintln(stdin, index); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -78,6 +79,9 @@ func TestNativeTextLabelRelations(t *testing.T) {
if snapshot.Name != probe.name { if snapshot.Name != probe.name {
t.Errorf("entry %d name = %q, want %q", index, 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) speech := FocusText(snapshot)
for _, part := range []string{probe.name, probe.value} { for _, part := range []string{probe.name, probe.value} {
if part != "" && !strings.Contains(speech, part) { if part != "" && !strings.Contains(speech, part) {
+3 -2
View File
@@ -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. 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. 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. 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. 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-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. 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. 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. 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. 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. 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-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. 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. 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. 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. 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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
30 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.
31 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.
32 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.
33 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. 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.
34 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.
35 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.
36 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 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.
45 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.
46 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.
47 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. 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.
48 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.
49 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.
50 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 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.
67 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.
68 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.
69 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.
+36 -1
View File
@@ -185,7 +185,35 @@ def check_flat_review(tabs, server, readerLog):
server.snapshot()[-1]["finished"], "return to tab header was suppressed") 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 import pyatspi
directory = Path(tempfile.mkdtemp(prefix="cthulhu-echo-runtime-")) directory = Path(tempfile.mkdtemp(prefix="cthulhu-echo-runtime-"))
binary = os.environ["CTHULHU_BINARY"] binary = os.environ["CTHULHU_BINARY"]
@@ -218,6 +246,9 @@ gain = 5
name = "Echo A" name = "Echo A"
lang = "en" 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: if radioOnly:
configPath.write_text(configPath.read_text().replace( configPath.write_text(configPath.read_text().replace(
"[profiles.default.sound]\nenabled = false", "[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] xid = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Preferences$"], text=True).splitlines()[0]
subprocess.run(["xdotool", "windowactivate", "--sync", xid], check=True) subprocess.run(["xdotool", "windowactivate", "--sync", xid], check=True)
tabs = next(node for node in walk(window) if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) 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: if flatReviewOnly:
check_flat_review(tabs, server, directory / "reader.log") 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}") print(f"PASS: notebook flat review has no repeated tab speech; tab changes and focus returns speak; evidence: {directory}")
+101
View File
@@ -46,6 +46,10 @@ class ResponseReader:
def inside(): 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": if os.environ.get("CTHULHU_PREFS_FLAT_REVIEW_ONLY") == "1":
from native_echo_preview import check_runtime from native_echo_preview import check_runtime
check_runtime(flatReviewOnly=True) check_runtime(flatReviewOnly=True)
@@ -202,6 +206,103 @@ 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 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: if echoFocusMode:
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) 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" assert tabs.querySelection().selectChild(4), "cannot select Key Echo tab"
+8
View File
@@ -45,6 +45,14 @@ def main():
combined.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, label.get_accessible()) combined.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, label.get_accessible())
box.pack_start(combined, False, False, 0) box.pack_start(combined, False, False, 0)
entries.append(combined) 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() window.show_all()
Gdk.Display.get_default().sync() Gdk.Display.get_default().sync()
subprocess.run(["xdotool", "windowfocus", str(GdkX11.X11Window.get_xid(window.get_window()))], check=True) subprocess.run(["xdotool", "windowfocus", str(GdkX11.X11Window.get_xid(window.get_window()))], check=True)