Update caps notification preferences.

This commit is contained in:
ak47 committed 2026-10-17 20:16:43 -04:00
1 parent 3df96145f2
commit 1c4e0a99bc
34 files changed
+577 -56

No files matched your search

+17
View File
@@ -59,6 +59,23 @@ matching current X11 foreground metadata; without that evidence it stays quiet.
Wine insertion feedback uses the custom key voice when character echo is off,
and the custom character voice when character echo is on.
## Capital letters
Speech preferences group three independent checkboxes under Capital letters:
Raise pitch for capital letters (on by default), Beep for capital letters,
and Say “cap” before capital letters (both off by default). Any combination,
including all off, is supported. These settings apply to character navigation,
spelling, and typing feedback, including a custom key echo voice. Word and line
reading keep their normal presentation.
The beep matches NVDA's generated 2000 Hz, 50 ms capital-letter tone. It uses
the Sounds volume and audio backend, independently of role sounds and themes.
The pitch checkbox enables the existing Uppercase voice pitch; that voice's
pitch editor still allows an explicit pitch or automatic adjustment. Apply
changes the running reader, and saved choices survive restart and can be set
per application. Older capitalization styles are imported into the corresponding
beep or “cap” checkbox.
## Browser button updates
Web buttons announce their current label when focused. Automatic label changes
+4
View File
@@ -243,6 +243,10 @@ func Run(ctx context.Context, options Options) error {
gatedSpeech{speech: speechOutput, gate: focusGate}, brailleOutput, live, focusGate))
}
if speechMessages != nil {
speechMessages.capitalization = func() (bool, bool) {
settings := live.Load()
return settings.BeepForCapitals, settings.SayCapForCapitals
}
speechMessages.reading = func() config.ReadingSettings { return live.Load().Reading }
speechMessages.verbosity = func() int { return live.Load().SpeechVerbosityLevel }
speechMessages.indentationCue = func(cue sound.Cue) {
+52
View File
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"unicode"
"unicode/utf8"
)
func capitalCharacter(text string) bool {
letter, size := utf8.DecodeRuneInString(text)
return size == len(text) && unicode.IsUpper(letter)
}
func capitalVoice(customEcho bool) string {
if customEcho {
return "uppercase-echo"
}
return "uppercase"
}
// Called only by serialized speech delivery, after mode and context handling.
// No cue outlives its owning request's cancellation context.
func (worker *speechWorker) present_capital(ctx context.Context, request speechRequest) error {
if worker.capitalization == nil {
return nil
}
beep, sayCap := worker.capitalization()
if beep && worker.capitalBeep != nil {
if err := worker.capitalBeep(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
worker.logger.Warn("capital letter beep", "error", err)
}
}
if err := ctx.Err(); err != nil {
return err
}
if sayCap {
if voice, ok := worker.speaker.(interface {
SpeakVoiceAndWait(context.Context, string, string, func()) error
}); ok {
return voice.SpeakVoiceAndWait(ctx, "cap", capitalVoice(request.CustomEcho), nil)
}
return worker.speaker.Speak(ctx, "cap")
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"testing"
)
func TestCapitalBeepCancellationAndFailure(t *testing.T) {
for _, interrupt := range []bool{false, true} {
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.capitalization = func() (bool, bool) { return true, true }
ctx, cancel := context.WithCancel(context.Background())
worker.capitalBeep = func(beepContext context.Context) error {
if interrupt {
cancel()
return beepContext.Err()
}
return errors.New("audio unavailable")
}
err := worker.deliver(ctx, speechRequest{Text: "A", Character: true})
cancel()
if interrupt {
if !errors.Is(err, context.Canceled) || len(speaker.parts) != 0 {
t.Fatalf("canceled capital: %v %v", err, speaker.parts)
}
} else if err != nil || len(speaker.parts) != 2 || speaker.parts[0] != "cap" || speaker.parts[1] != "A" {
t.Fatalf("failed beep suppressed speech: %v %v", err, speaker.parts)
}
}
}
+3
View File
@@ -244,6 +244,9 @@ func (speaker *hardwareSpeaker) SpeakVoice(ctx context.Context, text, kind strin
if kind == "echo" {
return speaker.SpeakEcho(ctx, text)
}
if kind == "uppercase-echo" {
return speaker.speak(ctx, text, true, "uppercase")
}
return speaker.speak(ctx, text, false, kind)
}
func (speaker *hardwareSpeaker) speak(ctx context.Context, text string, echo bool, kind string) error {
+103
View File
@@ -330,6 +330,109 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatalf("custom character echo = %+v; want pitch %s", got, wantPitch)
}
}
capitalPreferences := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
capitalLive := &liveSettings{}
capitalLive.Store(settings)
capitalPreferences.live = capitalLive
for mask := 0; mask < 8; mask++ {
choices := settings
echoPitch := 6.0
choices.EchoVoice.AveragePitch = &echoPitch
choices.RaisePitchForCapitals = mask&1 != 0
choices.BeepForCapitals = mask&2 != 0
choices.SayCapForCapitals = mask&4 != 0
if response := capitalPreferences.apply(ctx, preferences.Request{Values: preferences.FromSettings(choices)}); !response.Accepted {
t.Fatalf("capital choices Apply failed: %+v", response)
}
capitals := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
capitals.capitalization = func() (bool, bool) {
current := capitalLive.Load()
return current.BeepForCapitals, current.SayCapForCapitals
}
beeps := 0
capitals.capitalBeep = func(context.Context) error { beeps++; return nil }
for _, custom := range []bool{false, true} {
for _, tc := range []struct {
text, cause, voice string
character, capital bool
}{
{"A", "character-echo", "", true, true},
{"É", "document-caret-character", "", true, true},
{"𐐀", "flat-review", "", true, true},
{"A", "key-echo", "", true, true},
{"a", "character-echo", "", true, false},
{"ABC", "word-echo", "", false, false},
{"A", "focus", "", false, false},
} {
before := beeps
request := speechRequest{Text: tc.text, Cause: tc.cause, VoiceType: tc.voice, Character: tc.character, CustomEcho: custom}
if err := capitals.deliver(ctx, request); err != nil {
t.Fatal(err)
}
wantPitch, wantModule := "", ""
if custom {
wantPitch, wantModule = "30", "echo-module"
}
if tc.capital && choices.RaisePitchForCapitals {
wantPitch = "50"
if custom {
wantPitch = "70"
}
}
if tc.capital && choices.SayCapForCapitals {
got := <-spoken
if got.text != "cap" || got.pitch != wantPitch || got.module != wantModule {
t.Fatalf("mask %d cap prefix: %+v", mask, got)
}
}
got := <-spoken
if got.text != tc.text || got.pitch != wantPitch || got.module != wantModule {
t.Fatalf("mask %d custom=%v request=%+v: %+v", mask, custom, request, got)
}
wantBeeps := 0
if tc.capital && choices.BeepForCapitals {
wantBeeps = 1
}
if beeps-before != wantBeeps {
t.Fatalf("mask %d beeps=%d", mask, beeps-before)
}
}
}
// Phonetic spelling carries uppercase metadata despite lowercase words.
for _, character := range []bool{false, true} {
text := "alpha"
if character {
text = "A"
}
request := speechRequest{Spelling: []string{text, "b"}, SpellingVoices: []string{"uppercase", ""}, Character: character}
before := beeps
if err := capitals.deliver(ctx, request); err != nil {
t.Fatal(err)
}
if choices.SayCapForCapitals {
if got := <-spoken; got.text != "cap" {
t.Fatalf("spelled prefix: %+v", got)
}
}
if got := <-spoken; got.text != text {
t.Fatalf("spelled capital: %+v", got)
}
if got := <-spoken; got.text != "b" || got.pitch != "" {
t.Fatalf("spelled lowercase: %+v", got)
}
wantBeeps := 0
if choices.BeepForCapitals {
wantBeeps = 1
}
if beeps-before != wantBeeps {
t.Fatal("spelling beep count")
}
}
}
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
}
upperPitch, linkPitch, systemPitch := 8.0, 1.0, 2.0
settings.UppercaseVoice.AveragePitch = &upperPitch
settings.HyperlinkVoice.AveragePitch = &linkPitch
+2 -1
View File
@@ -115,7 +115,8 @@ func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.S
}
}()
}
speechChanged := next.SpeechServerFactory != worker.settings.SpeechServerFactory ||
speechChanged := next.RaisePitchForCapitals != worker.settings.RaisePitchForCapitals ||
next.SpeechServerFactory != worker.settings.SpeechServerFactory ||
!reflect.DeepEqual(next.UppercaseVoice, worker.settings.UppercaseVoice) ||
!reflect.DeepEqual(next.HyperlinkVoice, worker.settings.HyperlinkVoice) ||
!reflect.DeepEqual(next.SystemVoice, worker.settings.SystemVoice) ||
+17
View File
@@ -85,6 +85,16 @@ func remoteValue(settings config.Settings, module, property string) (dbus.Varian
return dbus.MakeVariant(float64(10)), true
case "SpeechIsEnabled":
return dbus.MakeVariant(settings.EnableSpeech), true
case "CapitalizationStyle":
// The old API cannot represent both cues; prefer the spoken cue.
style := "none"
if settings.BeepForCapitals {
style = "icon"
}
if settings.SayCapForCapitals {
style = "spell"
}
return dbus.MakeVariant(style), true
case "PunctuationLevel":
return dbus.MakeVariant([]string{"all", "most", "some", "none"}[settings.VerbalizePunctuationStyle]), true
}
@@ -240,6 +250,13 @@ func setRemoteValue(settings *config.Settings, module, property string, value db
}
settings.DefaultVoice.Family.Name = v
return true
case "CapitalizationStyle":
v, ok := value.Value().(string)
if !ok || (v != "none" && v != "spell" && v != "icon") {
return false
}
settings.SetCapitalizationStyle(v)
return true
case "PunctuationLevel":
v, ok := value.Value().(string)
if !ok {
+24
View File
@@ -68,3 +68,27 @@ func TestRemoteSettingsUseWorkerWithoutSaving(t *testing.T) {
t.Fatalf("remote setter wrote settings file: %v", err)
}
}
func TestLegacyRemoteCapitalStyleControlsIndependentCues(t *testing.T) {
settings := config.DefaultSettings()
for _, style := range []string{"icon", "spell", "none"} {
if !setRemoteValue(&settings, "SpeechManager", "CapitalizationStyle", dbus.MakeVariant(style)) {
t.Fatal("style rejected")
}
if !settings.RaisePitchForCapitals || settings.BeepForCapitals != (style == "icon") || settings.SayCapForCapitals != (style == "spell") {
t.Fatalf("style %s cue mapping", style)
}
value, ok := remoteValue(settings, "SpeechManager", "CapitalizationStyle")
if !ok || value.Value() != style {
t.Fatalf("style %s getter: %v", style, value)
}
}
settings.BeepForCapitals, settings.SayCapForCapitals = true, true
value, ok := remoteValue(settings, "SpeechManager", "CapitalizationStyle")
if !ok || value.Value() != "spell" {
t.Fatal("combined style compatibility")
}
if setRemoteValue(&settings, "SpeechManager", "CapitalizationStyle", dbus.MakeVariant("invalid")) {
t.Fatal("invalid style accepted")
}
}
+3
View File
@@ -89,6 +89,9 @@ func newRuntimeBackends(options *Options, settings config.Settings, logger *slog
if beeper, ok := backends.sounds.(interface{ SpeechBeep(context.Context) error }); ok {
backends.speechMessages.beep = beeper.SpeechBeep
}
if beeper, ok := backends.sounds.(interface{ CapitalBeep(context.Context) error }); ok {
backends.speechMessages.capitalBeep = beeper.CapitalBeep
}
backends.history = newSpeechHistory(settings.SpeechHistoryLimit)
backends.speechMessages.history = backends.history
backends.speechOutput = backends.speechMessages
+1 -1
View File
@@ -29,7 +29,7 @@ func speechSettingsFromConfig(settings config.Settings) speech.Settings {
speechSettings := speech.Settings{
OutputModule: settings.SpeechOutputModule,
Punctuation: map[int]string{0: "all", 1: "most", 2: "some", 3: "none"}[settings.VerbalizePunctuationStyle],
Capitalization: settings.CapitalizationStyle,
Capitalization: "none",
}
if settings.DefaultVoice.Rate != nil {
value := int(2*(*settings.DefaultVoice.Rate) - 98)
+1 -1
View File
@@ -40,7 +40,7 @@ func TestSpeechSettingsFromConfig(t *testing.T) {
got.Volume == nil || *got.Volume != 40 {
t.Fatalf("converted voice values = rate %v pitch %v volume %v", got.Rate, got.Pitch, got.Volume)
}
if got.Punctuation != "some" || got.Capitalization != "icon" ||
if got.Punctuation != "some" || got.Capitalization != "none" ||
got.Language != "en-US" || got.SynthesisVoice != "Generic Voice" {
t.Fatalf("converted speech settings = %#v", got)
}
+8
View File
@@ -185,6 +185,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if request.CustomEcho {
kind = "echo"
}
if request.Character && capitalCharacter(request.Text) {
kind = capitalVoice(request.CustomEcho)
}
speak = func(ctx context.Context, text string) error {
return voiced.SpeakVoiceAndWait(ctx, text, kind, nil)
}
@@ -215,6 +218,11 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if !historyRow && !(request.Character && utf8.RuneCountInString(chunk) == 1) {
text = worker.transformText(chunk)
}
if (request.Character && capitalCharacter(chunk)) || request.VoiceType == "uppercase" {
if err := worker.present_capital(ctx, request); err != nil {
return err
}
}
part := request
part.Append = request.Append || index > 0
worker.record_delivery(ctx, part, text)
+2
View File
@@ -87,6 +87,8 @@ type speechWorker struct {
speaker Speaker
history *speechHistory
beep func(context.Context) error
capitalBeep func(context.Context) error
capitalization func() (beep, sayCap bool)
textTransform func(string) string
reading func() config.ReadingSettings
verbosity func() int
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package config
// SetCapitalizationStyle adapts the old exclusive choice to independent cues.
// Pitch has always been configured separately and is left untouched.
func (settings *Settings) SetCapitalizationStyle(style string) {
settings.CapitalizationStyle = style
settings.BeepForCapitals = style == "icon"
settings.SayCapForCapitals = style == "spell"
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"fmt"
"path/filepath"
"testing"
)
func TestCapitalChoicesPersistIndependently(t *testing.T) {
defaults := DefaultSettings()
if !defaults.RaisePitchForCapitals || defaults.BeepForCapitals || defaults.SayCapForCapitals {
t.Fatal("only capital pitch should default on")
}
store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
if err != nil {
t.Fatal(err)
}
for mask := 0; mask < 8; mask++ {
settings := defaults
settings.RaisePitchForCapitals = mask&1 != 0
settings.BeepForCapitals = mask&2 != 0
settings.SayCapForCapitals = mask&4 != 0
if err := store.Save(settings); err != nil {
t.Fatal(err)
}
loaded, err := Load(store.path)
if err != nil {
t.Fatal(err)
}
checkCapitalChoices(t, loaded, settings)
app := defaults
if err := store.SaveForApplication("editor", app); err != nil {
t.Fatal(err)
}
loaded, found, err := store.LoadForApplication("editor")
if err != nil || !found {
t.Fatalf("application load: %v, %v", found, err)
}
checkCapitalChoices(t, loaded, app)
loaded, err = store.Load()
if err != nil {
t.Fatal(err)
}
checkCapitalChoices(t, loaded, settings)
}
}
func checkCapitalChoices(t *testing.T, got, want Settings) {
t.Helper()
if got.RaisePitchForCapitals != want.RaisePitchForCapitals ||
got.BeepForCapitals != want.BeepForCapitals || got.SayCapForCapitals != want.SayCapForCapitals {
t.Fatalf("capital choices: pitch=%v beep=%v cap=%v; want %v %v %v",
got.RaisePitchForCapitals, got.BeepForCapitals, got.SayCapForCapitals,
want.RaisePitchForCapitals, want.BeepForCapitals, want.SayCapForCapitals)
}
}
func TestOldCapitalStylesMigrate(t *testing.T) {
for _, style := range []string{"none", "spell", "icon"} {
for _, versioned := range []bool{false, true} {
contents := fmt.Sprintf("[general]\ncapitalizationStyle = %q\n", style)
if versioned {
contents = fmt.Sprintf("schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\ncapitalization-style = %q\n", style)
}
settings, err := Load(writeConfig(t, contents))
if err != nil {
t.Fatal(err)
}
if !settings.RaisePitchForCapitals || settings.BeepForCapitals != (style == "icon") || settings.SayCapForCapitals != (style == "spell") {
t.Fatalf("old %s, versioned=%v: %+v", style, versioned, settings)
}
}
}
settings, err := Load(writeConfig(t, "schema-version = 2\nactive-profile = 'default'\n[profiles.default.speech]\ncapitalization-style = 'spell'\nsay-cap-for-capitals = false\nbeep-for-capitals = true\nraise-pitch-for-capitals = false\n"))
if err != nil {
t.Fatal(err)
}
if settings.RaisePitchForCapitals || settings.SayCapForCapitals || !settings.BeepForCapitals {
t.Fatal("explicit independent choices did not override old style")
}
}
func TestCapitalPitchCanBeDisabledWithExplicitUppercaseVoice(t *testing.T) {
settings := DefaultSettings()
normal, upper := 4.0, 8.0
settings.DefaultVoice.AveragePitch = &normal
settings.UppercaseVoice.AveragePitch = &upper
settings.RaisePitchForCapitals = false
if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != normal {
t.Fatalf("pitch=%v", got)
}
settings.DefaultVoice.AveragePitch = nil
if EffectiveVoice(settings, "uppercase").AveragePitch != nil {
t.Fatal("server default pitch was lost")
}
settings.RaisePitchForCapitals = true
if got := *EffectiveVoice(settings, "uppercase").AveragePitch; got != upper {
t.Fatalf("pitch=%v", got)
}
}
+1
View File
@@ -68,6 +68,7 @@ func DefaultSettings() Settings {
},
VerbalizePunctuationStyle: 1,
CapitalizationStyle: "none",
RaisePitchForCapitals: true,
KeyboardLayout: 1,
CthulhuModifierKeys: []string{"Insert", "KP_Insert"},
UseCustomEchoForKey: true,
+1 -1
View File
@@ -227,7 +227,7 @@ func apply_legacy_speech_formats(settings *Settings, values rawSettings) {
settings.VerbalizePunctuationStyle = *values.VerbalizePunctuationStyle
}
if values.CapitalizationStyle != nil {
settings.CapitalizationStyle = *values.CapitalizationStyle
settings.SetCapitalizationStyle(*values.CapitalizationStyle)
}
if values.TimeFormat != nil {
settings.TimeFormat = *values.TimeFormat
+18 -6
View File
@@ -112,11 +112,14 @@ func profileFromSettings(settings Settings) writableProfile {
Enabled: boolPointer(settings.EnableSpeech), ServerFactory: stringPointer(settings.SpeechServerFactory),
HistoryLimit: intPointer(settings.SpeechHistoryLimit),
Punctuation: intPointer(settings.VerbalizePunctuationStyle), Capitalization: stringPointer(settings.CapitalizationStyle),
DefaultVoice: voicePointer(settings.DefaultVoice),
UppercaseVoice: voicePointer(settings.UppercaseVoice),
HyperlinkVoice: voicePointer(settings.HyperlinkVoice),
SystemVoice: voicePointer(settings.SystemVoice),
OutputModule: stringPointer(settings.SpeechOutputModule),
RaisePitchForCapitals: boolPointer(settings.RaisePitchForCapitals),
BeepForCapitals: boolPointer(settings.BeepForCapitals),
SayCapForCapitals: boolPointer(settings.SayCapForCapitals),
DefaultVoice: voicePointer(settings.DefaultVoice),
UppercaseVoice: voicePointer(settings.UppercaseVoice),
HyperlinkVoice: voicePointer(settings.HyperlinkVoice),
SystemVoice: voicePointer(settings.SystemVoice),
OutputModule: stringPointer(settings.SpeechOutputModule),
},
Braille: writableBraille{Enabled: boolPointer(settings.EnableBraille),
Contracted: boolPointer(settings.EnableContractedBraille),
@@ -440,7 +443,16 @@ func apply_profile_speech(settings *Settings, profile writableProfile) {
settings.VerbalizePunctuationStyle = *value
}
if value := profile.Speech.Capitalization; value != nil {
settings.CapitalizationStyle = *value
settings.SetCapitalizationStyle(*value)
}
if value := profile.Speech.RaisePitchForCapitals; value != nil {
settings.RaisePitchForCapitals = *value
}
if value := profile.Speech.BeepForCapitals; value != nil {
settings.BeepForCapitals = *value
}
if value := profile.Speech.SayCapForCapitals; value != nil {
settings.SayCapForCapitals = *value
}
if value := profile.Speech.OutputModule; value != nil {
settings.SpeechOutputModule = *value
+15 -12
View File
@@ -117,18 +117,21 @@ 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"`
DefaultVoice *Voice `toml:"default-voice,omitempty"`
UppercaseVoice *Voice `toml:"uppercase-voice,omitempty"`
HyperlinkVoice *Voice `toml:"hyperlink-voice,omitempty"`
SystemVoice *Voice `toml:"system-voice,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 {
+4 -1
View File
@@ -145,7 +145,10 @@ type Settings struct {
Pronunciations []PronunciationEntry
TextAttributes TextAttributeSettings
VerbalizePunctuationStyle int
CapitalizationStyle string
CapitalizationStyle string // Compatibility with older settings and the remote API.
RaisePitchForCapitals bool
BeepForCapitals bool
SayCapForCapitals bool
KeyBindings []input.BindingOverride
KeyboardLayout int
CthulhuModifierKeys []string
+7 -2
View File
@@ -5,7 +5,7 @@
package config
// EffectiveVoice resolves a sparse voice over Default. Uppercase retains the
// established pitch of 7 unless the user explicitly chooses another pitch.
// established automatic pitch unless overridden or capital pitch is disabled.
func EffectiveVoice(settings Settings, kind string) Voice {
voice := cloneVoice(settings.DefaultVoice)
var override Voice
@@ -15,8 +15,13 @@ func EffectiveVoice(settings Settings, kind string) Voice {
if voice.AveragePitch != nil {
pitch = min(9, max(pitch, *voice.AveragePitch+2))
}
voice.AveragePitch = &pitch
if settings.RaisePitchForCapitals {
voice.AveragePitch = &pitch
}
override = settings.UppercaseVoice
if !settings.RaisePitchForCapitals {
override.AveragePitch = nil
}
case "hyperlink":
override = settings.HyperlinkVoice
case "system":
+2 -3
View File
@@ -205,8 +205,8 @@ int prefs_init(const char *xml) {
gtk_entry_set_completion(GTK_ENTRY(prefs_widget("hardwareDeviceEntry")), completion);
g_object_unref(completion);
g_object_unref(devices);
const char *labelled[] = {"rate", "pitch", "volume", "capitalization"};
for (int i = 0; i < 4; i++) {
const char *labelled[] = {"rate", "pitch", "volume"};
for (unsigned i = 0; i < G_N_ELEMENTS(labelled); i++) {
gchar *id = g_strconcat(labelled[i], "_label", NULL);
name_widget(labelled[i], gtk_label_get_text(GTK_LABEL(prefs_widget(id))));
g_free(id);
@@ -226,7 +226,6 @@ int prefs_init(const char *xml) {
{"speechFamilies", "Person"},
{"speechHistoryLimitSpinButton", "Speech history entries"},
{"voiceTypesCombo", "Voice type"},
{"capitalization", "Capitalization style"},
{"hardwareDeviceEntry", "Serial device"},
{"hardwareBaudCombo", "Serial baud rate"},
{"contractionTableCombo", "Contraction table"},
+5 -2
View File
@@ -123,8 +123,11 @@
<child><object class="GtkCheckButton" id="rate_default"><property name="label">Use server default rate</property><property name="can-focus">True</property></object><packing><property name="left-attach">0</property><property name="top-attach">9</property><property name="width">2</property></packing></child>
<child><object class="GtkLabel" id="rate_label"><property name="label">_Rate:</property><property name="use-underline">True</property><property name="mnemonic-widget">rate</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">10</property></packing></child>
<child><object class="GtkScale" id="rate"><property name="adjustment">rate_adjustment</property><property name="can-focus">True</property><property name="digits">1</property><property name="hexpand">True</property><property name="value-pos">right</property></object><packing><property name="left-attach">1</property><property name="top-attach">10</property></packing></child>
<child><object class="GtkLabel" id="capitalization_label"><property name="label">_Capitalization style:</property><property name="use-underline">True</property><property name="mnemonic-widget">capitalization</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">11</property></packing></child>
<child><object class="GtkComboBoxText" id="capitalization"><property name="can-focus">False</property><items><item>None</item><item>Spell</item><item>Sound</item></items></object><packing><property name="left-attach">1</property><property name="top-attach">11</property></packing></child>
<child><object class="GtkFrame" id="capitalLettersFrame"><property name="label">Capital letters</property><child><object class="GtkBox"><property name="orientation">vertical</property><property name="spacing">6</property><property name="border-width">8</property>
<child><object class="GtkCheckButton" id="raisePitchForCapitals"><property name="label">Raise pitch for capital letters</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="beepForCapitals"><property name="label">Beep for capital letters</property><property name="can-focus">True</property></object></child>
<child><object class="GtkCheckButton" id="sayCapForCapitals"><property name="label">Say “cap” before capital letters</property><property name="can-focus">True</property></object></child>
</object></child></object><packing><property name="left-attach">0</property><property name="top-attach">11</property><property name="width">2</property></packing></child>
<child><object class="GtkEntry" id="hardwareDeviceEntry"><property name="can-focus">True</property><property name="tooltip-text">Serial device path; completion offers detected devices</property></object><packing><property name="left-attach">1</property><property name="top-attach">15</property></packing></child>
<child><object class="GtkLabel"><property name="label">Serial _device:</property><property name="use-underline">True</property><property name="mnemonic-widget">hardwareDeviceEntry</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">15</property></packing></child>
<child><object class="GtkLabel"><property name="label">Serial baud rate:</property><property name="mnemonic-widget">hardwareBaudCombo</property><property name="xalign">1</property></object><packing><property name="left-attach">0</property><property name="top-attach">17</property></packing></child>
+3 -1
View File
@@ -76,7 +76,9 @@ type Values struct {
SystemVoice config.Voice
VoiceType string // Preview selection; never persisted as reader state.
Punctuation int
Capitalization string
RaisePitchForCapitals bool
BeepForCapitals bool
SayCapForCapitals bool
Reading config.ReadingSettings
SpeakDescription bool
}
+17
View File
@@ -140,3 +140,20 @@ func TestKeyboardValuesMergeAndOwnership(t *testing.T) {
t.Fatalf("cannot disable all modifiers: %v", err)
}
}
func TestCapitalCheckboxesRoundTrip(t *testing.T) {
for mask := 0; mask < 8; mask++ {
settings := config.DefaultSettings()
values := FromSettings(settings)
values.RaisePitchForCapitals = mask&1 != 0
values.BeepForCapitals = mask&2 != 0
values.SayCapForCapitals = mask&4 != 0
merged, err := values.Merge(settings)
if err != nil {
t.Fatal(err)
}
if got := FromSettings(merged); !reflect.DeepEqual(got, values) {
t.Fatalf("mask %d did not round trip", mask)
}
}
}
+7 -7
View File
@@ -306,7 +306,9 @@ func set_speech_numbers(values Values, inherited config.Voice) {
func set_speech_presentation(values Values) {
setChoice("punctuation", values.Punctuation)
setCombo("capitalization", map[string]int{"none": 0, "spell": 1, "icon": 2}[values.Capitalization])
setCheck("raisePitchForCapitals", values.RaisePitchForCapitals)
setCheck("beepForCapitals", values.BeepForCapitals)
setCheck("sayCapForCapitals", values.SayCapForCapitals)
setReading(values)
}
@@ -357,17 +359,15 @@ func (session *uiSession) get_speech_values(values *Values) error {
values.HardwareSpeechBaudRate = hardwareBaudRates[indexBaud]
values.SpeechOutputModule = session.selectedSpeechModule()
values.Punctuation = getChoice("punctuation")
index := getCombo("capitalization")
if index < 0 || index > 2 {
return errors.New("Choose a capitalization style.")
}
values.Capitalization = []string{"none", "spell", "icon"}[index]
values.RaisePitchForCapitals = getCheck("raisePitchForCapitals")
values.BeepForCapitals = getCheck("beepForCapitals")
values.SayCapForCapitals = getCheck("sayCapForCapitals")
return nil
}
func (session *uiSession) load_voice_editor() {
voice := session.voiceDrafts[session.activeVoiceType]
settings := config.Settings{DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]}
settings := config.Settings{RaisePitchForCapitals: true, DefaultVoice: session.voiceDrafts[0], UppercaseVoice: session.voiceDrafts[1], HyperlinkVoice: session.voiceDrafts[2], SystemVoice: session.voiceDrafts[3]}
kind := []string{"default", "uppercase", "hyperlink", "system"}[session.activeVoiceType]
set_speech_numbers(Values{Rate: voice.Rate, Pitch: voice.AveragePitch, Volume: voice.Gain}, config.EffectiveVoice(settings, kind))
C.prefs_voice_defaults(C.int(session.activeVoiceType))
+8 -2
View File
@@ -30,7 +30,10 @@ func FromSettings(settings config.Settings) Values {
ProgressBar: settings.ProgressBar,
RoleSoundPresentation: settings.RoleSoundPresentation,
Rate: settings.DefaultVoice.Rate, Pitch: settings.DefaultVoice.AveragePitch,
Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle, Capitalization: settings.CapitalizationStyle,
Volume: settings.DefaultVoice.Gain, Punctuation: settings.VerbalizePunctuationStyle,
RaisePitchForCapitals: settings.RaisePitchForCapitals,
BeepForCapitals: settings.BeepForCapitals,
SayCapForCapitals: settings.SayCapForCapitals,
SpeechOutputModule: settings.SpeechOutputModule,
SpeechServerFactory: settings.SpeechServerFactory,
HardwareSpeechDevice: settings.HardwareSpeechDevice,
@@ -126,7 +129,10 @@ func (values Values) Merge(settings config.Settings) (config.Settings, error) {
settings.HyperlinkVoice = values.HyperlinkVoice
settings.SystemVoice = values.SystemVoice
settings.VerbalizePunctuationStyle = values.Punctuation
settings.CapitalizationStyle = values.Capitalization
settings.CapitalizationStyle = "none"
settings.RaisePitchForCapitals = values.RaisePitchForCapitals
settings.BeepForCapitals = values.BeepForCapitals
settings.SayCapForCapitals = values.SayCapForCapitals
settings.Reading = values.Reading
settings.SpeakDescription = values.SpeakDescription
return settings, config.Validate(settings)
+7 -2
View File
@@ -33,12 +33,17 @@ func progress_percentage(cue string) (float64, bool) {
// Reference: nvaccess/nvda a4a89720ccbf3973a0e1e50a76dd8bff38db3d15,
// source/NVDAObjects/behaviors.py and nvdaHelper/local/beeps.cpp.
func progress_tone_wav(percentage float64) []byte {
return nvda_tone_wav(110*math.Pow(2, percentage/25), 40)
}
// nvda_tone_wav also supplies the capital-letter beep (2000 Hz, 50 ms).
func nvda_tone_wav(hz float64, milliseconds int) []byte {
const sampleRate = 44100
frequency := float64(float32(110 * math.Pow(2, percentage/25)))
frequency := float64(float32(hz))
// sampleRate/hz is evaluated as a float in NVDA's C++ generator.
cycleLength := float64(float32(sampleRate) / float32(frequency))
samplesPerCycle := int(cycleLength)
sampleCount := int(0.040 / (1.0 / sampleRate))
sampleCount := int((float64(milliseconds) / 1000) / (1.0 / sampleRate))
sampleCount += samplesPerCycle - sampleCount%samplesPerCycle
dataSize := sampleCount * 4
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
+12 -1
View File
@@ -10,15 +10,26 @@ import (
)
var speechBeepWAV = tone_wav(440, 40*time.Millisecond, 0.35, 5*time.Millisecond)
var capitalBeepWAV = nvda_tone_wav(2000, 50)
// SpeechBeep replaces an utterance, independently of role-sound enablement or
// theme availability. The speech worker owns its ordering and cancellation;
// sound preferences still supply the output sink and volume.
func (service *Service) SpeechBeep(ctx context.Context) error {
return service.play_speech_beep(ctx, speechBeepWAV)
}
// CapitalBeep is ordered and canceled by the speech worker, just like speech.
// It uses sound volume and sink, independently of the role sound theme.
func (service *Service) CapitalBeep(ctx context.Context) error {
return service.play_speech_beep(ctx, capitalBeepWAV)
}
func (service *Service) play_speech_beep(ctx context.Context, data []byte) error {
service.mutex.Lock()
player, volume := service.player, service.volume
service.mutex.Unlock()
beepContext, cancelBeep := context.WithTimeout(ctx, 3*time.Second)
defer cancelBeep()
return player.Play(beepContext, speechBeepWAV, volume, 0)
return player.Play(beepContext, data, volume, 0)
}
+23
View File
@@ -6,7 +6,10 @@ package sound
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"testing"
"time"
@@ -26,3 +29,23 @@ func TestSpeechBeepDecodesWithRoleSoundsDisabled(t *testing.T) {
t.Fatalf("canceled beep = %v", err)
}
}
func TestCapitalBeepMatchesNVDAAndDecodes(t *testing.T) {
// PCM from NVDA's C++ generateBeep(2000, 50, 50, 50), compiled separately.
if got := fmt.Sprintf("%x", sha256.Sum256(capitalBeepWAV[44:])); got != "3385e0c7e50f2b4abd8da47606999f27b651e999bcc04ba853e3a7338f469367" {
t.Fatalf("capital beep differs from NVDA: %s", got)
}
if binary.LittleEndian.Uint32(capitalBeepWAV[24:]) != 44100 || binary.LittleEndian.Uint16(capitalBeepWAV[22:]) != 2 {
t.Fatal("capital beep must be 44100 Hz stereo")
}
service := New(slog.New(slog.DiscardHandler), NativePlayer{Sink: "test"}, "none", 0.5)
service.Configure(Configuration{Enabled: false, Theme: "none", Sink: "test", Volume: 0.4})
if err := service.CapitalBeep(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := service.CapitalBeep(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled capital beep: %v", err)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ self-voice-dbus-braille,src/cthulhu/dbus_service.py,remote,Display an external p
self-voice-dbus-cancel,src/cthulhu/dbus_service.py,remote,Cancel current speech at an external application's request 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 proof passes same-user dispatch introspection conflict shutdown release and reacquisition.,,Pending CancelSpeech interruption and subsequent-delivery listening acceptance,,CancelSpeech uses the asynchronous owned speech-worker interruption path and returns false when speech is unavailable. Like Python it does not cancel sound output.
nvda-controller-ssml-rejection,src/cthulhu/dbus_service.py,remote,Reject invalid or unsupported NVDA Controller 2 SSML without claiming mark or completion support,Storm Dragon,internal/remote,implemented,go test ./internal/remote -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1,Private session-bus proof passes the real SpeakSsml signature and unsupported result.,,,,Malformed XML or a non-speak root returns Windows ERROR_INVALID_PARAMETER 87. Valid speak-root SSML returns ERROR_NOT_SUPPORTED 50 matching Python because mark callbacks and completion semantics are unavailable.
flat-review-keys,src/cthulhu/flat_review_presenter.py,review,Navigate and present content with desktop and laptop review keys,Storm Dragon,internal/input internal/review internal/app internal/platform/xorg,in-progress,DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/input ./internal/platform/xorg ./internal/presentation -count=1; DISPLAY=:0 python3 tests/native_atspi.py; DISPLAY=:0 python3 tests/native_x11.py,Private Xvfb tests pass all nine desktop and laptop sequential review commands; both laptop release orders; NumLock numeric keypad typing; laptop keypad pass-through; existing command and shutdown checks.,,Maintainer accepted all currently implemented terminal flat-review behavior on physical Xorg on 2026-09-23; broader application listening remains pending,Pending braille reading gate,Basic previous/current/next line word and code-point character commands are wired to owned window layout and the existing review worker. Window boundaries stop silently as in Python. Capture limits are reported explicitly. Native GTK text navigation and edit recovery pass. Viewport seeking mode toggling same-object caret following after leaving review state/value zones advanced bindings mouse review and full application parity remain pending. See docs/testing/flat-review.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
preferences-basic-speech,src/cthulhu/cthulhu_gui_prefs.py,settings,Open accessible basic speech preferences and apply or save settings,Storm Dragon,internal/preferences internal/app internal/config,in-progress,go test -race ./... -count=1; python3 tests/native_preferences.py; python3 tests/native_x11.py,Private Xvfb GTK and command checks pass; maintainer accepted save shutdown restart and restored settings on physical Xorg on 2026-09-13; broader desktop traversal pending,,Pending human listening for immediate rate pitch and volume previews plus punctuation and capitalization,,Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Default-voice rate pitch volume and server-default toggles preview immediately without committing settings; Cancel restores the last applied voice configuration. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending.
preferences-basic-speech,src/cthulhu/cthulhu_gui_prefs.py,settings,Open accessible basic speech preferences and apply or save settings,Storm Dragon,internal/preferences internal/app internal/config,in-progress,go test -race ./... -count=1; python3 tests/native_preferences.py; python3 tests/native_x11.py,Private Xvfb GTK and command checks pass; maintainer accepted save shutdown restart and restored settings on physical Xorg on 2026-09-13; broader desktop traversal pending,,Pending human listening for immediate rate pitch and volume previews plus punctuation and independent capital cues,,Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Default-voice rate pitch volume and server-default toggles preview immediately without committing settings; Cancel restores the last applied voice configuration. Independent capital pitch beep and cap checkboxes replace the capitalization style dropdown; default pitch is enabled and all combinations apply to custom echo and spelling. Private GTK traversal and Apply/reload plus SSIP and persistence checks pass; live listening and Braille remain pending. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending.
review-component-geometry,src/cthulhu/flat_review.py,review,Capture component screen bounds and visibility for flat-review clipping,Storm Dragon,internal/atspi,implemented,DISPLAY=:0 go test ./internal/atspi -count=1; DISPLAY=:0 python3 tests/native_atspi.py,Private Xvfb D-Bus GtkBox tests verify screen bounds resize invalidation changed dimensions hide invalidation and recapture. Physical acceptance deferred by maintainer on 2026-09-14.,,,,Geometry queries run only during owned-tree capture. Bounds and visibility events invalidate geometry without removing identities. Negative coordinates overflow-safe clipping malformed geometry and concurrent invalidation have unit tests. Component bounds do not establish occlusion text ranges zones reading order or flat-review key support.
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 now include captured caret offsets. Text caret and geometry events make cached text stale without removing identities. Maximum 4096 characters and 256 lines or ranges per object and 1 MiB per tree. Oversized objects remain limited with no partial text. Basic zones and review commands now consume this data; large-document viewport seeking and physical listening remain pending.
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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
38 self-voice-dbus-cancel src/cthulhu/dbus_service.py remote Cancel current speech at an external application's request 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 proof passes same-user dispatch introspection conflict shutdown release and reacquisition. Pending CancelSpeech interruption and subsequent-delivery listening acceptance CancelSpeech uses the asynchronous owned speech-worker interruption path and returns false when speech is unavailable. Like Python it does not cancel sound output.
39 nvda-controller-ssml-rejection src/cthulhu/dbus_service.py remote Reject invalid or unsupported NVDA Controller 2 SSML without claiming mark or completion support Storm Dragon internal/remote implemented go test ./internal/remote -count=1; CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/remote -run '^TestNativeSessionBusSelfVoice$' -count=1 Private session-bus proof passes the real SpeakSsml signature and unsupported result. Malformed XML or a non-speak root returns Windows ERROR_INVALID_PARAMETER 87. Valid speak-root SSML returns ERROR_NOT_SUPPORTED 50 matching Python because mark callbacks and completion semantics are unavailable.
40 flat-review-keys src/cthulhu/flat_review_presenter.py review Navigate and present content with desktop and laptop review keys Storm Dragon internal/input internal/review internal/app internal/platform/xorg in-progress DISPLAY=:0 go test -race ./internal/atspi ./internal/review ./internal/app ./internal/input ./internal/platform/xorg ./internal/presentation -count=1; DISPLAY=:0 python3 tests/native_atspi.py; DISPLAY=:0 python3 tests/native_x11.py Private Xvfb tests pass all nine desktop and laptop sequential review commands; both laptop release orders; NumLock numeric keypad typing; laptop keypad pass-through; existing command and shutdown checks. Maintainer accepted all currently implemented terminal flat-review behavior on physical Xorg on 2026-09-23; broader application listening remains pending Pending braille reading gate Basic previous/current/next line word and code-point character commands are wired to owned window layout and the existing review worker. Window boundaries stop silently as in Python. Capture limits are reported explicitly. Native GTK text navigation and edit recovery pass. Viewport seeking mode toggling same-object caret following after leaving review state/value zones advanced bindings mouse review and full application parity remain pending. See docs/testing/flat-review.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
41 preferences-basic-speech src/cthulhu/cthulhu_gui_prefs.py settings Open accessible basic speech preferences and apply or save settings Storm Dragon internal/preferences internal/app internal/config in-progress go test -race ./... -count=1; python3 tests/native_preferences.py; python3 tests/native_x11.py Private Xvfb GTK and command checks pass; maintainer accepted save shutdown restart and restored settings on physical Xorg on 2026-09-13; broader desktop traversal pending Pending human listening for immediate rate pitch and volume previews plus punctuation and capitalization Pending human listening for immediate rate pitch and volume previews plus punctuation and independent capital cues Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Default-voice rate pitch volume and server-default toggles preview immediately without committing settings; Cancel restores the last applied voice configuration. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending. Apply and OK update the active schema-v2 profile while preserving other profiles and application mappings; Apply stays open and OK closes. Default-voice rate pitch volume and server-default toggles preview immediately without committing settings; Cancel restores the last applied voice configuration. Independent capital pitch beep and cap checkboxes replace the capitalization style dropdown; default pitch is enabled and all combinations apply to custom echo and spelling. Private GTK traversal and Apply/reload plus SSIP and persistence checks pass; live listening and Braille remain pending. Explicitly selected legacy files remain session-only and cannot be overwritten. Advanced preferences and importer remain pending.
42 review-component-geometry src/cthulhu/flat_review.py review Capture component screen bounds and visibility for flat-review clipping Storm Dragon internal/atspi implemented DISPLAY=:0 go test ./internal/atspi -count=1; DISPLAY=:0 python3 tests/native_atspi.py Private Xvfb D-Bus GtkBox tests verify screen bounds resize invalidation changed dimensions hide invalidation and recapture. Physical acceptance deferred by maintainer on 2026-09-14. Geometry queries run only during owned-tree capture. Bounds and visibility events invalidate geometry without removing identities. Negative coordinates overflow-safe clipping malformed geometry and concurrent invalidation have unit tests. Component bounds do not establish occlusion text ranges zones reading order or flat-review key support.
43 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 now include captured caret offsets. Text caret and geometry events make cached text stale without removing identities. Maximum 4096 characters and 256 lines or ranges per object and 1 MiB per tree. Oversized objects remain limited with no partial text. Basic zones and review commands now consume this data; large-document viewport seeking and physical listening remain pending.
44 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.
+1 -1
View File
@@ -180,7 +180,7 @@ def inside():
"Values": {"KeyboardLayout": 1, "CthulhuModifierKeys": ["KP_Insert"],
"TimeFormat": "24-hour", "DateFormat": "numbers-mdy",
"SpeechHistoryLimit": 50, "Punctuation": 1,
"Capitalization": "none"}})
"RaisePitchForCapitals": True}})
wait_for(lambda: find_window("Cthulhu Preferences for game.exe"), "chosen application preferences missing")
focus_window("Cthulhu Preferences for game.exe")
key("Escape")
+54 -11
View File
@@ -80,7 +80,8 @@ def inside():
"SpeechHistoryLimit": 50,
"Reading": {"AutomaticallyReadTitleChanges": True},
"KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"],
"Punctuation": 1, "Capitalization": "none",
"Punctuation": 1, "RaisePitchForCapitals": True,
"BeepForCapitals": False, "SayCapForCapitals": False,
"TimeFormat": "24-hour", "DateFormat": "ymd",
"EnableSound": True, "SoundVolume": 0.5,
"SpeechVerbosityLevel": 1,
@@ -643,6 +644,46 @@ def inside():
assert process.wait(timeout=5) == 0
print("PASS: hardware system/synth/device/baud, Apply, software restore, serial keyboard navigation, Cancel")
return
if os.environ.get("CTHULHU_PREFS_CAPITALS_ONLY") == "1":
focus(named("Desktop"))
for page in ("Sounds", "Speech"):
key("ctrl+Page_Down")
wait_for(lambda page=page: named(page).getState().contains(pyatspi.STATE_SELECTED), page + " missing")
labels = ("Raise pitch for capital letters", "Beep for capital letters", "Say “cap” before capital letters")
fields = ("RaisePitchForCapitals", "BeepForCapitals", "SayCapForCapitals")
checks = [named(label) for label in labels]
assert all(check.getRole() == pyatspi.ROLE_CHECK_BOX for check in checks)
assert checks[0].getState().contains(pyatspi.STATE_CHECKED)
assert not any(check.getState().contains(pyatspi.STATE_CHECKED) for check in checks[1:])
focus(checks[0])
key("shift+Tab")
previous = next(n for n in nodes if n.getState().contains(pyatspi.STATE_FOCUSED))
assert previous.getRole() == pyatspi.ROLE_SLIDER and "Rate" in previous.name, previous.name
key("Tab")
for check in checks:
wait_for(lambda check=check: check.getState().contains(pyatspi.STATE_FOCUSED), "capital checkbox Tab order")
key("Tab")
following = next(n for n in nodes if n.getState().contains(pyatspi.STATE_FOCUSED))
assert following not in checks
for check in reversed(checks):
key("shift+Tab")
wait_for(lambda check=check: check.getState().contains(pyatspi.STATE_FOCUSED), "capital checkbox Shift+Tab order")
for mask in range(8):
for bit, check in enumerate(checks):
if check.getState().contains(pyatspi.STATE_CHECKED) != bool(mask & (1 << bit)):
focus(check); key("space")
focus(named("Apply")); key("space")
applied = request()
assert not applied.get("Preview"), applied
assert all(applied["Values"][field] == bool(mask & (1 << bit)) for bit, field in enumerate(fields)), applied
send(dict(initial, Values=applied["Values"], Message="Capital letter settings saved."))
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
for bit, check in enumerate(checks):
assert check.getState().contains(pyatspi.STATE_CHECKED) == bool(mask & (1 << bit))
focus(named("Cancel")); key("space")
assert process.wait(timeout=5) == 0
print("PASS: capital checkboxes, default pitch, Tab/Shift+Tab, eight independent combinations, Apply/reload")
return
if os.environ.get("CTHULHU_PREFS_VOICE_TYPES_ONLY") == "1":
focus(named("Desktop"))
for page in ("Sounds", "Speech"):
@@ -1072,23 +1113,25 @@ def inside():
selected_time = time_selection.getSelectedChild(0).name
assert time_format.description == selected_time, (
"combo value missing", time_format.description, selected_time)
capitalization = next(n for n in nodes if n.name == "Capitalization style" and
n.getRole() == pyatspi.ROLE_COMBO_BOX)
capitalization_selection = capitalization.querySelection()
assert capitalization.description == capitalization_selection.getSelectedChild(0).name
capital_pitch = named("Raise pitch for capital letters")
capital_beep = named("Beep for capital letters")
capital_cap = named("Say “cap” before capital letters")
assert capital_pitch.getState().contains(pyatspi.STATE_CHECKED)
assert not capital_beep.getState().contains(pyatspi.STATE_CHECKED)
assert not capital_cap.getState().contains(pyatspi.STATE_CHECKED)
date_format = next(n for n in nodes if n.name == "Date format" and
n.getRole() == pyatspi.ROLE_COMBO_BOX)
changed = dict(initial, Values=dict(initial["Values"], Capitalization="spell", TimeFormat="12-hour", DateFormat="dmy"))
changed = dict(initial, Values=dict(initial["Values"], BeepForCapitals=True, SayCapForCapitals=True, TimeFormat="12-hour", DateFormat="dmy"))
send(changed)
wait_for(lambda: capitalization.description == "Spell",
"changed combo value was not exposed")
assert capitalization.querySelection().getSelectedChild(0).name == "Spell"
wait_for(lambda: capital_cap.getState().contains(pyatspi.STATE_CHECKED),
"changed capital checkbox value was not exposed")
assert capital_beep.getState().contains(pyatspi.STATE_CHECKED)
wait_for(lambda: time_format.description == time_selection.getSelectedChild(0).name,
"time format did not initialize")
assert date_format.description == date_format.querySelection().getSelectedChild(0).name
send(initial)
wait_for(lambda: capitalization.description == "None",
"restored combo value was not exposed")
wait_for(lambda: not capital_cap.getState().contains(pyatspi.STATE_CHECKED),
"restored capital checkbox value was not exposed")
progress = named("Progress bar output")
assert progress.description == "Beep", progress.description
background = named("Report background progress bars")