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

+12 -10
View File
@@ -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)
+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
// 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) {
+3
View File
@@ -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",
+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{
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
View File
@@ -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 {
+1
View File
@@ -132,6 +132,7 @@ type Settings struct {
ActiveProfile string
EnableSpeech bool
SpeechHistoryLimit int
SpeechInterruption input.InterruptionSettings
EnableBraille bool
EnableContractedBraille bool
BrailleContractionTable string
+1
View File
@@ -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...)
+3
View File
@@ -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 ||
-4
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
})
+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_key_bindings();
prefs_init_modifiers();
prefs_init_interruption();
prefs_init_pronunciation();
prefs_init_text_attributes();
prefs_init_tentacles();
+4
View File
@@ -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);
+1
View File
@@ -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);
+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="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>
+1
View File
@@ -26,6 +26,7 @@ type Values struct {
EnableSound bool
EnableSpeech bool
SpeechHistoryLimit int
SpeechInterruption input.InterruptionSettings
SpeechVerbosityLevel int
SayAllStyle string
EnableBraille bool
+19
View File
@@ -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"
+1 -1
View File
@@ -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"
*/
+6 -2
View File
@@ -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) {