Options for controlling how and when speech is interrupted now wired up in preferences under the speech tab.
This commit is contained in:
1 parent
e62f8c8e17
commit
3e9c4f83ce
32 files changed
+784
-47
No files matched your search
@@ -64,7 +64,18 @@ func processInputEvents(
|
||||
if len(ocrSessions) > 0 {
|
||||
ocrSessions[0].Input(event)
|
||||
}
|
||||
if input.ShouldInterruptSpeech(event) {
|
||||
interruption := input.InterruptionSettings{Mode: input.InterruptAllKeys}
|
||||
custom := false
|
||||
if live != nil {
|
||||
settings := live.Load()
|
||||
interruption = settings.SpeechInterruption
|
||||
echo.settings = inputEchoSettingsFromConfig(settings)
|
||||
custom = customEcho(settings, "key")
|
||||
if settings.KeyEchoInEditFieldsOnly && (live.editFieldFocused == nil || !live.editFieldFocused()) {
|
||||
echo.settings.EnableKeyEcho = false
|
||||
}
|
||||
}
|
||||
if interruption.ShouldInterrupt(event) {
|
||||
if interrupt, ok := speech.(interface{ InterruptForInput(input.Event) bool }); ok {
|
||||
if !interrupt.InterruptForInput(event) {
|
||||
continue
|
||||
@@ -77,15 +88,6 @@ func processInputEvents(
|
||||
logger.Debug("queued speech interrupted by key press", "input_timestamp", event.Timestamp,
|
||||
"input_keycode", event.Keycode, "input_repeat", event.Repeat)
|
||||
}
|
||||
custom := false
|
||||
if live != nil {
|
||||
settings := live.Load()
|
||||
echo.settings = inputEchoSettingsFromConfig(settings)
|
||||
custom = customEcho(settings, "key")
|
||||
if settings.KeyEchoInEditFieldsOnly && (live.editFieldFocused == nil || !live.editFieldFocused()) {
|
||||
echo.settings.EnableKeyEcho = false
|
||||
}
|
||||
}
|
||||
if text := echo.Text(event); text != "" {
|
||||
speech.Enqueue(speechRequest{Text: text, Role: "key", Cause: "key-echo", CustomEcho: custom})
|
||||
logger.Debug("queued non-printable key echo", "kind", event.Kind)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -585,10 +585,13 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
|
||||
}
|
||||
// Text and caret capture make synchronous AT-SPI calls. Focus events for
|
||||
// ordinary controls do not use that text in presentation, so avoid stalling
|
||||
// the GLib event loop for each Tab stop. Caret events still need line and
|
||||
// character text even when they originate in static web content.
|
||||
// the GLib event loop for each Tab stop. Focusable text controls include
|
||||
// read-only entries: their current line is still part of focus speech.
|
||||
// Caret events also need text when they originate in static web content.
|
||||
gboolean focusableText = snapshot.focusable &&
|
||||
(g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0);
|
||||
if (captureText && g_strcmp0(snapshot.role, "password text") != 0 &&
|
||||
(snapshot.editable || g_strcmp0(snapshot.role, "terminal") == 0 || captureCaretCharacter)) {
|
||||
(snapshot.editable || focusableText || g_strcmp0(snapshot.role, "terminal") == 0 || captureCaretCharacter)) {
|
||||
snapshot.text = cthulhu_capture_text(source, &snapshot.textBeforeCaret, &snapshot.textStartOffset);
|
||||
}
|
||||
if (captureCaretCharacter && g_strcmp0(snapshot.role, "password text") != 0) {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
package config
|
||||
|
||||
import "git.stormux.org/storm/cthulhu/internal/input"
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{
|
||||
OCR: OCRSettings{Layout: 3, Scale: 1, Threshold: 200, RefreshInterval: 1500, ConfirmClick: true},
|
||||
@@ -27,6 +29,7 @@ func DefaultSettings() Settings {
|
||||
RoleSoundPresentation: "sound_and_speech",
|
||||
EnableSpeech: true,
|
||||
SpeechHistoryLimit: DefaultSpeechHistoryLimit,
|
||||
SpeechInterruption: input.InterruptionSettings{Mode: input.InterruptAllKeys},
|
||||
Braille: BrailleOptions{Verbosity: 1, SelectionIndicator: 0xc0, LinkIndicator: 0xc0,
|
||||
FlashMessages: true, FlashTime: 5000, FlashDetailed: true},
|
||||
SpeechServerFactory: "speechdispatcherfactory",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ func profileFromSettings(settings Settings) writableProfile {
|
||||
},
|
||||
},
|
||||
Speech: writableSpeech{
|
||||
Interruption: interruption_pointer(settings.SpeechInterruption),
|
||||
HardwareDevice: stringPointer(settings.HardwareSpeechDevice),
|
||||
HardwareBaudRate: intPointer(settings.HardwareSpeechBaudRate),
|
||||
Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory),
|
||||
@@ -386,6 +387,9 @@ func apply_profile_reading(settings *Settings, profile writableProfile) {
|
||||
}
|
||||
|
||||
func apply_profile_output(settings *Settings, profile writableProfile) {
|
||||
if value := profile.Speech.Interruption; value != nil {
|
||||
settings.SpeechInterruption = *interruption_pointer(*value)
|
||||
}
|
||||
if value := profile.Speech.Enabled; value != nil {
|
||||
settings.EnableSpeech = *value
|
||||
}
|
||||
@@ -433,6 +437,11 @@ func apply_profile_output(settings *Settings, profile writableProfile) {
|
||||
}
|
||||
}
|
||||
|
||||
func interruption_pointer(settings input.InterruptionSettings) *input.InterruptionSettings {
|
||||
settings.Keys = append([]string(nil), settings.Keys...)
|
||||
return &settings
|
||||
}
|
||||
|
||||
func apply_profile_speech(settings *Settings, profile writableProfile) {
|
||||
if value := profile.Speech.HardwareDevice; value != nil {
|
||||
settings.HardwareSpeechDevice = *value
|
||||
|
||||
+16
-15
@@ -118,21 +118,22 @@ type writableReading struct {
|
||||
}
|
||||
|
||||
type writableSpeech struct {
|
||||
HardwareDevice *string `toml:"hardware-device,omitempty"`
|
||||
HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
|
||||
OutputModule *string `toml:"output-module,omitempty"`
|
||||
Enabled *bool `toml:"enabled,omitempty"`
|
||||
HistoryLimit *int `toml:"history-limit,omitempty"`
|
||||
ServerFactory *string `toml:"server-factory,omitempty"`
|
||||
Punctuation *int `toml:"punctuation-style,omitempty"`
|
||||
Capitalization *string `toml:"capitalization-style,omitempty"`
|
||||
RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"`
|
||||
BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"`
|
||||
SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"`
|
||||
DefaultVoice *Voice `toml:"default-voice,omitempty"`
|
||||
UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
|
||||
HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
|
||||
SystemVoice *Voice `toml:"system-voice,omitempty"`
|
||||
Interruption *input.InterruptionSettings `toml:"interruption,omitempty"`
|
||||
HardwareDevice *string `toml:"hardware-device,omitempty"`
|
||||
HardwareBaudRate *int `toml:"hardware-baud-rate,omitempty"`
|
||||
OutputModule *string `toml:"output-module,omitempty"`
|
||||
Enabled *bool `toml:"enabled,omitempty"`
|
||||
HistoryLimit *int `toml:"history-limit,omitempty"`
|
||||
ServerFactory *string `toml:"server-factory,omitempty"`
|
||||
Punctuation *int `toml:"punctuation-style,omitempty"`
|
||||
Capitalization *string `toml:"capitalization-style,omitempty"`
|
||||
RaisePitchForCapitals *bool `toml:"raise-pitch-for-capitals,omitempty"`
|
||||
BeepForCapitals *bool `toml:"beep-for-capitals,omitempty"`
|
||||
SayCapForCapitals *bool `toml:"say-cap-for-capitals,omitempty"`
|
||||
DefaultVoice *Voice `toml:"default-voice,omitempty"`
|
||||
UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
|
||||
HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
|
||||
SystemVoice *Voice `toml:"system-voice,omitempty"`
|
||||
}
|
||||
|
||||
type writableBraille struct {
|
||||
|
||||
@@ -132,6 +132,7 @@ type Settings struct {
|
||||
ActiveProfile string
|
||||
EnableSpeech bool
|
||||
SpeechHistoryLimit int
|
||||
SpeechInterruption input.InterruptionSettings
|
||||
EnableBraille bool
|
||||
EnableContractedBraille bool
|
||||
BrailleContractionTable string
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
)
|
||||
|
||||
func cloneSettings(settings Settings) Settings {
|
||||
settings.SpeechInterruption.Keys = append([]string(nil), settings.SpeechInterruption.Keys...)
|
||||
settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings)
|
||||
settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...)
|
||||
settings.Pronunciations = append([]PronunciationEntry(nil), settings.Pronunciations...)
|
||||
|
||||
@@ -33,6 +33,9 @@ import (
|
||||
func Validate(settings Settings) error { return validate(settings) }
|
||||
|
||||
func validate(settings Settings) error {
|
||||
if err := settings.SpeechInterruption.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.OCR.Layout != 3 && settings.OCR.Layout != 6 && settings.OCR.Layout != 11 ||
|
||||
settings.OCR.Scale < 1 || settings.OCR.Scale > 4 || settings.OCR.Threshold < 0 || settings.OCR.Threshold > 255 ||
|
||||
settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 ||
|
||||
|
||||
@@ -120,10 +120,6 @@ func (modifiers ModifierKeycodes) Contains(keycode uint8) bool {
|
||||
return modifiers[keycode]
|
||||
}
|
||||
|
||||
func ShouldInterruptSpeech(event Event) bool {
|
||||
return event.Pressed
|
||||
}
|
||||
|
||||
// Automatic titles survive held-key repeats, but fresh input and explicit
|
||||
// stop keys cancel both scheduled and already queued title speech.
|
||||
func ShouldInterruptTitle(event Event) bool {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestShouldInterruptSpeechOnKeyPress(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ShouldInterruptSpeech(test.event); got != test.want {
|
||||
if got := (InterruptionSettings{Mode: InterruptAllKeys}).ShouldInterrupt(test.event); got != test.want {
|
||||
t.Fatalf("ShouldInterruptSpeech() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ int prefs_init(const char *xml) {
|
||||
prefs_init_reading_settings();
|
||||
prefs_init_key_bindings();
|
||||
prefs_init_modifiers();
|
||||
prefs_init_interruption();
|
||||
prefs_init_pronunciation();
|
||||
prefs_init_text_attributes();
|
||||
prefs_init_tentacles();
|
||||
|
||||
@@ -44,6 +44,10 @@ int prefs_binding_selected(void);
|
||||
void prefs_binding_select(int row);
|
||||
|
||||
void prefs_modifier_set(const char *id, int enabled);
|
||||
void prefs_interruption_clear(void);
|
||||
void prefs_interruption_append(const char *name);
|
||||
int prefs_interruption_count(void);
|
||||
const char *prefs_interruption_key(int index);
|
||||
int prefs_modifier_get(const char *id);
|
||||
void prefs_init_pronunciation(void);
|
||||
void prefs_pronunciation_clear(void);
|
||||
|
||||
@@ -11,6 +11,7 @@ GtkWidget *prefs_widget(const char *id);
|
||||
void prefs_set_action(int value);
|
||||
void prefs_init_key_bindings(void);
|
||||
void prefs_init_modifiers(void);
|
||||
void prefs_init_interruption(void);
|
||||
void prefs_init_reading_settings(void);
|
||||
void prefs_init_pronunciation(void);
|
||||
void prefs_init_text_attributes(void);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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="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>
|
||||
<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="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>
|
||||
|
||||
@@ -26,6 +26,7 @@ type Values struct {
|
||||
EnableSound bool
|
||||
EnableSpeech bool
|
||||
SpeechHistoryLimit int
|
||||
SpeechInterruption input.InterruptionSettings
|
||||
SpeechVerbosityLevel int
|
||||
SayAllStyle string
|
||||
EnableBraille bool
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func (session *uiSession) setSpeechVoice(values Values) {
|
||||
@@ -271,6 +272,15 @@ func (session *uiSession) reconcile_voice_families(module string) {
|
||||
}
|
||||
|
||||
func set_speech_support(values Values) {
|
||||
setCheck("interruptAllKeysRadio", values.SpeechInterruption.Mode != input.InterruptCommonKeys && values.SpeechInterruption.Mode != input.InterruptCustomKeys)
|
||||
setCheck("interruptCommonKeysRadio", values.SpeechInterruption.Mode == input.InterruptCommonKeys)
|
||||
setCheck("interruptCustomKeysRadio", values.SpeechInterruption.Mode == input.InterruptCustomKeys)
|
||||
C.prefs_interruption_clear()
|
||||
for _, name := range values.SpeechInterruption.Keys {
|
||||
key := C.CString(name)
|
||||
C.prefs_interruption_append(key)
|
||||
C.free(unsafe.Pointer(key))
|
||||
}
|
||||
setCheck("speechSupportCheckButton", values.EnableSpeech)
|
||||
setSpin("speechHistoryLimitSpinButton", float64(values.SpeechHistoryLimit))
|
||||
setCheck("speechBriefButton", values.SpeechVerbosityLevel == 0)
|
||||
@@ -313,6 +323,15 @@ func set_speech_presentation(values Values) {
|
||||
}
|
||||
|
||||
func get_speech_support(values *Values) {
|
||||
values.SpeechInterruption.Mode = input.InterruptAllKeys
|
||||
if getCheck("interruptCommonKeysRadio") {
|
||||
values.SpeechInterruption.Mode = input.InterruptCommonKeys
|
||||
} else if getCheck("interruptCustomKeysRadio") {
|
||||
values.SpeechInterruption.Mode = input.InterruptCustomKeys
|
||||
}
|
||||
for i := C.int(0); i < C.prefs_interruption_count(); i++ {
|
||||
values.SpeechInterruption.Keys = append(values.SpeechInterruption.Keys, C.GoString(C.prefs_interruption_key(i)))
|
||||
}
|
||||
values.EnableSpeech = getCheck("speechSupportCheckButton")
|
||||
values.SpeechHistoryLimit = int(getSpin("speechHistoryLimitSpinButton"))
|
||||
values.Reading.IndentationPresentationMode = "speech"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
package preferences
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0
|
||||
#cgo pkg-config: gtk+-3.0 xkbcommon
|
||||
#include <stdlib.h>
|
||||
#include "native.h"
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,10 @@ func FromSettings(settings config.Settings) Values {
|
||||
KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...),
|
||||
KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat,
|
||||
EnableSound: settings.EnableSound, EnableBraille: settings.EnableBraille,
|
||||
EnableSpeech: settings.EnableSpeech,
|
||||
SpeechHistoryLimit: settings.SpeechHistoryLimit,
|
||||
EnableSpeech: settings.EnableSpeech,
|
||||
SpeechHistoryLimit: settings.SpeechHistoryLimit,
|
||||
SpeechInterruption: input.InterruptionSettings{Mode: settings.SpeechInterruption.Mode,
|
||||
Keys: append([]string(nil), settings.SpeechInterruption.Keys...)},
|
||||
SpeechVerbosityLevel: settings.SpeechVerbosityLevel,
|
||||
SayAllStyle: settings.SayAllStyle,
|
||||
EnableContractedBraille: settings.EnableContractedBraille, BrailleContractionTable: settings.BrailleContractionTable,
|
||||
@@ -80,6 +82,8 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) {
|
||||
settings.EnableSound = values.EnableSound
|
||||
settings.EnableSpeech = values.EnableSpeech
|
||||
settings.SpeechHistoryLimit = values.SpeechHistoryLimit
|
||||
settings.SpeechInterruption = input.InterruptionSettings{Mode: values.SpeechInterruption.Mode,
|
||||
Keys: append([]string(nil), values.SpeechInterruption.Keys...)}
|
||||
settings.SpeechVerbosityLevel = values.SpeechVerbosityLevel
|
||||
settings.SayAllStyle = values.SayAllStyle
|
||||
settings.EnableBraille = values.EnableBraille
|
||||
|
||||
@@ -54,6 +54,7 @@ func TestNativeTextLabelRelations(t *testing.T) {
|
||||
{"Secret:", ""},
|
||||
{"", "Unlabeled value"},
|
||||
{"Account identifier", ""},
|
||||
{"Read-only summary:", "Example summary"},
|
||||
} {
|
||||
if _, err := fmt.Fprintln(stdin, index); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -78,6 +79,9 @@ func TestNativeTextLabelRelations(t *testing.T) {
|
||||
if snapshot.Name != probe.name {
|
||||
t.Errorf("entry %d name = %q, want %q", index, snapshot.Name, probe.name)
|
||||
}
|
||||
if probe.name == "Read-only summary:" && snapshot.Editable {
|
||||
t.Error("read-only fixture unexpectedly exposes editable state")
|
||||
}
|
||||
speech := FocusText(snapshot)
|
||||
for _, part := range []string{probe.name, probe.value} {
|
||||
if part != "" && !strings.Contains(speech, part) {
|
||||
|
||||
Reference in new issue
Block a user