Touch up our phonetic settings.

This commit is contained in:
Storm Dragon committed 2026-10-20 19:19:04 -04:00
1 parent 08cbcc00cb
commit ffcea2efea
34 files changed
+467 -14

No files matched your search

+21
View File
@@ -130,6 +130,27 @@ 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.
## Flat review spelling and phonetic hints
Press Numpad 2 twice to hear the current character phonetically. Press Numpad 5
once to read the current word, twice to spell it, or three times to spell it
phonetically. Numpad 8 provides the same three choices for the current line.
Successive presses must be within 500 milliseconds. Single presses speak
immediately. Laptop equivalents are Cthulhu+Comma, Cthulhu+K and Cthulhu+I.
Under Speech → Reading settings, **Phonetic character hints** is off by default.
Enable it to hear a phonetic letter about 300 milliseconds after a character
spoken by plain Left or Right, for example “a … alpha”. Further input cancels
the pending hint. Selection, word navigation, Home/End and typing echo do not
add hints. Spaces, punctuation and letters without a phonetic name keep their
normal announcements. This applies to native text controls, browser character
navigation and supported Wine editor character navigation.
The delayed hint uses the normal voice without repeating capital beeps, pitch
changes or “cap”. Explicit phonetic review commands retain capital indicators.
See [phonetic review verification](docs/testing/phonetic-review.md) for checks
and live listening steps.
## Read the current window title
Press **Cthulhu+Numpad Enter** in the desktop keyboard layout, or
+51
View File
@@ -0,0 +1,51 @@
# Flat review spelling and phonetic hints
Build a separate binary, preserving the running reader:
```sh
go build -o /tmp/cthulhu-phonetic-review ./cmd/cthulhu
python3 tests/native_test_session.py go test -race ./internal/input ./internal/config ./internal/preferences ./internal/app ./internal/review ./internal/presentation -count=1
CTHULHU_BINARY=/tmp/cthulhu-phonetic-review CTHULHU_PREFS_READING_ONLY=1 /usr/bin/python3 tests/native_preferences.py
CTHULHU_BINARY=/tmp/cthulhu-phonetic-review /usr/bin/python3 tests/native_x11.py
python3 tests/check_speech.py
```
The native fixtures use private Xvfb, D-Bus and AT-SPI sessions. Run them
sequentially. They verify command consumption and releases, repeat routing,
accessible checkbox state, Space/arrows/Tab/Shift+Tab, Apply and reload. Go tests
verify line spelling including spaces and repeated letters, OCR cursor/Braille
stability, the hint delay and cancellation, silent modes, defaults, persistence
and application overrides. These checks do not establish audible speech or
physical keyboard/Braille behavior.
After reviewing the diff and installing/restarting through the normal process:
1. In a text editor, use a line such as `A book!`. Check single/double Numpad 2,
single/double/triple Numpad 5 and single/double/triple Numpad 8. Presses within
each sequence must be no more than 500 ms apart. Confirm immediate ordinary
speech on the first press, repeated letters, capitals, spaces and punctuation.
Pause longer than 500 ms and confirm the next press starts ordinary reading.
2. Check the same actions in a terminal and a browser. If using laptop layout,
use Cthulhu+Comma, Cthulhu+K and Cthulhu+I. Confirm other review movement and
window start/end still work, with no leaked or stuck keys. If using OCR or
Wine flat review, confirm the line sequence and retained Braille cursor there.
3. In Speech → Reading settings, confirm Phonetic character hints starts
unchecked. Toggle with Space, Apply, and use plain Left/Right through text:
`a` should be followed about 300 ms later by `alpha`. Try capitals and repeated
letters. Move or type before the delay expires: the previous hint must not
follow the new character. Hold an arrow and confirm navigation stays responsive.
With capital beep, raised pitch and “cap” enabled, arrow to `T`: those cues
should occur only with the character, followed by plain `tango` in the normal
voice. Double Numpad 2 and triple Numpad 5/8 should retain capital cues in their
explicitly requested phonetic output.
4. Confirm Ctrl+Left/Right, Shift+Left/Right, Home/End, typing, punctuation and
spaces retain their usual behavior. Repeat in browser browse and editable
text modes and a supported Wine editor when available. Turn speech off and
on; hints suppressed while off must never replay. Confirm capital pitch/beep/
“cap” choices and ordinary speech queues still behave as configured.
5. Uncheck and Apply: arrows should speak characters alone again. Check saved
state after restart and application-specific settings if used. Cancel a draft
toggle and confirm it does not change the saved choice. With Braille enabled,
confirm hints do not move the review or Braille cursor or replace its text.
Human review and these listening checks remain required before submission.
+1
View File
@@ -247,6 +247,7 @@ func Run(ctx context.Context, options Options) error {
settings := live.Load()
return settings.BeepForCapitals, settings.SayCapForCapitals
}
speechMessages.inputActivity = func() input.Activity { return recentInput(inputListener) }
speechMessages.reading = func() config.ReadingSettings { return live.Load().Reading }
speechMessages.verbosity = func() int { return live.Load().SpeechVerbosityLevel }
speechMessages.indentationCue = func(cue sound.Cue) {
+1
View File
@@ -221,6 +221,7 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
text = "blank"
}
request := speechRequest{Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
request.PhoneticHint = result.DocumentCaretCommand == review.CaretNextCharacter || result.DocumentCaretCommand == review.CaretPreviousCharacter
request.Context = result.DocumentCaretContext
if len(request.Context) > 0 && request.Context[0].ID() == result.DocumentPosition.Snapshot.ID() {
request.Context = request.Context[1:]
+35
View File
@@ -18,6 +18,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/preferences"
"git.stormux.org/storm/cthulhu/internal/remote"
)
@@ -429,6 +430,40 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatal("spelling beep count")
}
}
// Delayed arrow hints must not repeat any capital cues. Exercise all
// capital combinations through SSIP, including a specialized source role.
capitals.reading = func() config.ReadingSettings {
return config.ReadingSettings{PhoneticCharacterHints: true}
}
capitals.inputActivity = func() input.Activity {
return input.Activity{PlainCharacterArrow: true, InputSequence: 1}
}
before := beeps
if err := capitals.deliver(ctx, speechRequest{Text: "T", Role: "system", Character: true, PhoneticHint: true}); err != nil {
t.Fatal(err)
}
wantPitch := ""
if choices.RaisePitchForCapitals {
wantPitch = "50"
}
if choices.SayCapForCapitals {
if got := <-spoken; got.text != "cap" || got.pitch != wantPitch {
t.Fatalf("mask %d initial character cap prefix: %+v", mask, got)
}
}
if got := <-spoken; got.text != "T" || got.pitch != wantPitch {
t.Fatalf("mask %d initial character: %+v", mask, got)
}
if got := <-spoken; got.text != "tango" || got.pitch != "" || got.module != "" {
t.Fatalf("mask %d delayed hint repeated capital or specialized voice: %+v", mask, got)
}
wantBeeps := 0
if choices.BeepForCapitals {
wantBeeps = 1
}
if beeps-before != wantBeeps {
t.Fatalf("mask %d delayed hint repeated capital beep: %d", mask, beeps-before)
}
}
if err := speaker.UpdateConfiguration(ctx, settings); err != nil {
t.Fatal(err)
+1
View File
@@ -221,6 +221,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: event.Source.Role, Sound: cues, Cause: event.Type, CustomEcho: custom,
Line: line,
ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow,
PhoneticHint: characterCaret,
Character: event.Type == atspi.CaretEvent && text == event.Source.CaretCharacter || echoKind == "character" || echoKind == "key",
Append: appendTabFocus || appendTableFocus || appendPromptFocus || event.Source.LiveRegion && event.Source.LivePoliteness == "polite" ||
event.Source.Role == "terminal" && event.Type == atspi.TextInsertedEvent,
+1 -1
View File
@@ -35,7 +35,7 @@ func flatReviewCommand(command input.Command) (review.FlatUnit, int) {
return review.WindowEnd, 0
case input.ReviewPreviousLine:
return review.Line, -1
case input.ReviewCurrentLine:
case input.ReviewCurrentLine, input.ReviewSpellLine, input.ReviewPhoneticLine:
return review.Line, 0
case input.ReviewNextLine:
return review.Line, 1
+1 -1
View File
@@ -33,7 +33,7 @@ func ocr_cursor_command(command input.Command) (ocr.Unit, int) {
return ocr.PageUnit, -1
case input.OCRNextPage:
return ocr.PageUnit, 1
case input.ReviewCurrentLine, input.WhereAmI:
case input.ReviewCurrentLine, input.ReviewSpellLine, input.ReviewPhoneticLine, input.WhereAmI:
return ocr.LineUnit, 0
case input.OCRLineStart:
return ocr.LineStart, 0
+2
View File
@@ -226,6 +226,8 @@ func TestOCRSessionCommandsOwnSourceAndRunePresentation(t *testing.T) {
parts []string
}{
{input.ReviewSpellWord, []string{"o", "n", "e"}},
{input.ReviewSpellLine, []string{"é", "space", "o", "n", "e"}},
{input.ReviewPhoneticLine, []string{"é", "space", "oscar", "november", "echo"}},
{input.ReviewPhoneticWord, []string{"oscar", "november", "echo"}},
{input.ReviewPhoneticCharacter, []string{"oscar"}},
} {
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"time"
"unicode/utf8"
)
const phoneticHintDelay = 300 * time.Millisecond
// The speech owner retains the hint with its character request. Waiting here
// keeps output ordered without blocking input or accessibility event handling;
// the normal input, settings, secure-session and shutdown barriers cancel it.
func (worker *speechWorker) deliver_phonetic_hint(ctx context.Context, request speechRequest) (bool, error) {
if !request.PhoneticHint || !request.Character || request.Role == "password text" ||
worker.reading == nil || !worker.reading().PhoneticCharacterHints || worker.inputActivity == nil {
return false, nil
}
activity := worker.inputActivity()
letter, size := utf8.DecodeRuneInString(request.Text)
if !activity.PlainCharacterArrow || size != len(request.Text) || phoneticLetter(letter) == "" {
return false, nil
}
request.PhoneticHint = false
if err := worker.deliver(ctx, request); err != nil {
return true, err
}
hintContext, cancelHint := context.WithCancel(ctx)
defer cancelHint()
worker.mutex.Lock()
if worker.generation != request.deliveryGeneration {
worker.mutex.Unlock()
return true, context.Canceled
}
worker.hintCancel = cancelHint
worker.mutex.Unlock()
defer func() {
worker.mutex.Lock()
worker.hintCancel = nil
worker.mutex.Unlock()
}()
timer := time.NewTimer(phoneticHintDelay)
defer timer.Stop()
select {
case <-hintContext.Done():
return true, hintContext.Err()
case <-timer.C:
}
worker.mutex.Lock()
interrupted := worker.generation != request.deliveryGeneration
worker.mutex.Unlock()
if interrupted || worker.inputActivity().InputSequence != activity.InputSequence || !worker.reading().PhoneticCharacterHints {
return true, nil
}
hint := speechRequest{
// The character already presented its capital cues. This hint only
// clarifies its identity, using the normal voice even in links or Wine.
Text: phoneticLetter(letter), VoiceType: "default", Role: request.Role, Cause: "phonetic-character-hint", Append: true,
Window: request.Window, ExcludeFromHistory: request.ExcludeFromHistory,
deliveryGeneration: request.deliveryGeneration,
}
return true, worker.deliver(hintContext, hint)
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"reflect"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestPhoneticHintsDelayAndEligibility(t *testing.T) {
for _, test := range []struct {
name, text string
enabled, arrow, eligible bool
want []string
}{
{"enabled", "a", true, true, true, []string{"a", "alpha"}},
{"capital", "A", true, true, true, []string{"A", "alpha"}},
{"disabled", "a", false, true, true, []string{"a"}},
{"modified arrow or Home", "a", true, false, true, []string{"a"}},
{"typing or flat review", "a", true, true, false, []string{"a"}},
{"space", "space", true, true, true, []string{"space"}},
{"punctuation", "!", true, true, true, []string{"!"}},
{"non Latin letter", "猫", true, true, true, []string{"猫"}},
} {
t.Run(test.name, func(t *testing.T) {
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.reading = func() config.ReadingSettings { return config.ReadingSettings{PhoneticCharacterHints: test.enabled} }
worker.inputActivity = func() input.Activity { return input.Activity{PlainCharacterArrow: test.arrow, InputSequence: 1} }
start := time.Now()
if err := worker.deliver(context.Background(), speechRequest{Text: test.text, Character: true, PhoneticHint: test.eligible}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(speaker.parts, test.want) {
t.Fatalf("speech = %v, want %v", speaker.parts, test.want)
}
if len(test.want) == 2 && time.Since(start) < phoneticHintDelay {
t.Fatal("hint arrived before its delay")
}
if !reflect.DeepEqual(speaker.characters, []string{test.text}) {
t.Fatalf("character speech changed: %v", speaker.characters)
}
})
}
}
type phoneticHintSpeaker struct{ parts chan string }
func (*phoneticHintSpeaker) Connect(context.Context) error { return nil }
func (*phoneticHintSpeaker) Close(context.Context) error { return nil }
func (*phoneticHintSpeaker) Cancel(context.Context) error { return nil }
func (speaker *phoneticHintSpeaker) Speak(_ context.Context, text string) error {
speaker.parts <- text
return nil
}
func (speaker *phoneticHintSpeaker) SpeakCharacter(ctx context.Context, text string) error {
return speaker.Speak(ctx, text)
}
func TestPhoneticHintFreshInputDoesNotWaitForDelay(t *testing.T) {
speaker := &phoneticHintSpeaker{parts: make(chan string, 4)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.reading = func() config.ReadingSettings { return config.ReadingSettings{PhoneticCharacterHints: true} }
worker.inputActivity = func() input.Activity { return input.Activity{PlainCharacterArrow: true, InputSequence: 1} }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.Enqueue(speechRequest{Text: "a", Character: true, PhoneticHint: true})
if got := <-speaker.parts; got != "a" {
t.Fatal(got)
}
// Wait for the delay to be owned, then exercise the real input barrier.
deadline := time.After(time.Second)
for {
worker.mutex.Lock()
waiting := worker.hintCancel != nil
worker.mutex.Unlock()
if waiting {
break
}
select {
case <-deadline:
t.Fatal("hint delay was never registered")
case <-time.After(time.Millisecond):
}
}
if !worker.InterruptForInput(input.NewKeyEvent(1, true, "Right")) {
t.Fatal("fresh arrow did not interrupt pending hint")
}
worker.Enqueue(speechRequest{Text: "next"})
select {
case got := <-speaker.parts:
if got != "next" {
t.Fatalf("stale hint survived fresh input: %q", got)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("fresh input waited for the phonetic delay")
}
}
func TestCharacterNavigationMarksHintEligibility(t *testing.T) {
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 8), spoken: make(chan string, 8)}
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue}
presenter.speak(atspi.Event{Type: atspi.CaretEvent, Source: atspi.Snapshot{Path: "/entry", Role: "entry", CaretCharacter: "a"}})
if request := <-queue.requests; !request.PhoneticHint || !request.Character {
t.Fatalf("native character = %+v", request)
}
for _, command := range []review.DocumentCaretCommand{review.CaretNextCharacter, review.CaretPreviousCharacter, review.CaretLineStart, review.CaretNextWord} {
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, review.Result{Moved: true, DocumentCaretCommand: command, DocumentCaretText: "a"}, presentation.DefaultOptions())
request := <-queue.requests
want := command == review.CaretNextCharacter || command == review.CaretPreviousCharacter
if request.PhoneticHint != want {
t.Fatalf("%v hint = %t", command, request.PhoneticHint)
}
}
}
+1
View File
@@ -115,6 +115,7 @@ func (presenter remoteSpeech) presentWineEditor(event remote.AccessibleEvent) bo
return presenter.presentRequest(speechRequest{
Text: event.Name, Role: "system", Cause: "wine-accessible-event", Append: true,
Character: character, CustomEcho: custom,
PhoneticHint: slices.Contains(kinds, "kind=5"),
})
}
+3 -2
View File
@@ -25,9 +25,9 @@ func phoneticLetter(letter rune) string {
func flat_review_speech(command input.Command) review.FlatSpeech {
switch command {
case input.ReviewSpellWord:
case input.ReviewSpellWord, input.ReviewSpellLine:
return review.FlatSpell
case input.ReviewPhoneticWord, input.ReviewPhoneticCharacter:
case input.ReviewPhoneticWord, input.ReviewPhoneticCharacter, input.ReviewPhoneticLine:
return review.FlatPhonetic
}
return review.FlatSpeak
@@ -39,6 +39,7 @@ func set_review_spelling(request *speechRequest, text string, style review.FlatS
if style == review.FlatSpeak || strings.TrimSpace(text) == "" {
return
}
request.Line = nil // Spelled lines must not re-announce indentation for each letter.
request.Character = style == review.FlatSpell
for _, letter := range text {
kind := ""
+3
View File
@@ -24,6 +24,9 @@ func TestFlatReviewSpellingPresentation(t *testing.T) {
character bool
}{
{input.ReviewCurrentWord, "book", nil, false},
{input.ReviewCurrentLine, "A book!", nil, false},
{input.ReviewSpellLine, "A book!", []string{"A", "space", "b", "o", "o", "k", "!"}, true},
{input.ReviewPhoneticLine, "A book!", []string{"alpha", "space", "bravo", "oscar", "oscar", "kilo", "!"}, false},
{input.ReviewCurrentCharacter, "b", nil, true},
{input.ReviewPhoneticCharacter, "B", []string{"bravo"}, false},
{input.ReviewSpellWord, "Book", []string{"B", "o", "o", "k"}, true},
+3
View File
@@ -34,6 +34,9 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if handled, err := worker.deliver_mode(ctx, request); handled {
return err
}
if handled, err := worker.deliver_phonetic_hint(ctx, request); handled {
return err
}
// Incoming output always queues. Only input/command handling may cancel
// earlier speech; a delayed state or focus event is not another user action.
if len(request.Spelling) != 0 {
+3
View File
@@ -49,6 +49,9 @@ func (worker *speechWorker) SetEnabled(enabled bool) {
}
func (worker *speechWorker) interruptLocked() {
if worker.hintCancel != nil {
worker.hintCancel()
}
if worker.uninterruptibleCancel != nil {
worker.uninterruptibleCancel()
}
+1 -1
View File
@@ -45,7 +45,7 @@ func TestSpeechModesCoverEveryDeliveryPath(t *testing.T) {
worker.beep = func(context.Context) error { beeps++; return nil }
for _, request := range []speechRequest{
{Text: "Focused button"},
{Text: "A", Character: true},
{Text: "A", Character: true, PhoneticHint: true},
{Text: "book", Spelling: []string{"b", "o", "o", "k"}, Character: true},
{Text: "word", CustomEcho: true},
{Text: "External message", Cause: "remote-speak"},
+4
View File
@@ -28,6 +28,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/sound"
)
@@ -44,6 +45,7 @@ type speechRequest struct {
IntroducesWindow bool
ReadAll []readAllPart
CustomEcho bool
PhoneticHint bool // Eligible character navigation; gated by input and reading settings.
Character bool
VoiceType string
Line *speechLine
@@ -90,6 +92,7 @@ type speechWorker struct {
capitalBeep func(context.Context) error
capitalization func() (beep, sayCap bool)
textTransform func(string) string
inputActivity func() input.Activity
reading func() config.ReadingSettings
verbosity func() int
indentation indentationHistory // Owned by the serialized delivery goroutine.
@@ -99,6 +102,7 @@ type speechWorker struct {
mutex sync.Mutex
enabled bool
mode speechMode
hintCancel context.CancelFunc // Active phonetic delay, owned under mutex.
beepCancel context.CancelFunc
pending []speechRequest
wake chan struct{}
+1
View File
@@ -269,6 +269,7 @@ func TestWineEditorCharacterSpeechRouting(t *testing.T) {
}
request := speech.requests[len(speech.requests)-1]
if request.Text != tc.text || request.Character != tc.character || request.CustomEcho != tc.custom ||
request.PhoneticHint != (tc.states == "kind=5") ||
!request.Append || request.Cause != "wine-accessible-event" {
t.Fatalf("action %+v: request=%+v", tc, request)
}
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"path/filepath"
"testing"
)
func TestPhoneticHintsDefaultPersistenceAndApplicationOverride(t *testing.T) {
settings := DefaultSettings()
if settings.Reading.PhoneticCharacterHints {
t.Fatal("phonetic hints must default off")
}
omitted, err := Load(writeConfig(t, "schema-version = 2\nactive-profile = 'default'\n[profiles.default]\n"))
if err != nil || omitted.Reading.PhoneticCharacterHints {
t.Fatalf("omitted setting = %t, %v", omitted.Reading.PhoneticCharacterHints, err)
}
store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
if err != nil {
t.Fatal(err)
}
settings.Reading.PhoneticCharacterHints = true
if err := store.Save(settings); err != nil {
t.Fatal(err)
}
loaded, err := store.Load()
if err != nil || !loaded.Reading.PhoneticCharacterHints {
t.Fatalf("saved setting = %t, %v", loaded.Reading.PhoneticCharacterHints, err)
}
inherited, _, err := store.LoadForApplication("Editor")
if err != nil || !inherited.Reading.PhoneticCharacterHints {
t.Fatalf("inherited setting = %t, %v", inherited.Reading.PhoneticCharacterHints, err)
}
loaded.Reading.PhoneticCharacterHints = false
if err := store.SaveForApplication("Editor", loaded); err != nil {
t.Fatal(err)
}
overridden, found, err := store.LoadForApplication("Editor")
if err != nil || !found || overridden.Reading.PhoneticCharacterHints {
t.Fatalf("application override = %t, %t, %v", overridden.Reading.PhoneticCharacterHints, found, err)
}
global, err := store.Load()
if err != nil || !global.Reading.PhoneticCharacterHints {
t.Fatalf("application override changed global = %t, %v", global.Reading.PhoneticCharacterHints, err)
}
}
+4
View File
@@ -74,6 +74,7 @@ func profileFromSettings(settings Settings) writableProfile {
Reading: writableReading{
AutomaticallyReadClipboardChanges: boolPointer(settings.Reading.AutomaticallyReadClipboardChanges),
AutomaticallyReadTitleChanges: boolPointer(settings.Reading.AutomaticallyReadTitleChanges),
PhoneticCharacterHints: boolPointer(settings.Reading.PhoneticCharacterHints),
EnablePauseBreaks: boolPointer(settings.Reading.EnablePauseBreaks),
SpeakNumbersAsDigits: boolPointer(settings.Reading.SpeakNumbersAsDigits),
SpeakMultiCaseStringsAsWords: boolPointer(settings.Reading.SpeakMultiCaseStringsAsWords),
@@ -292,6 +293,9 @@ func apply_profile_reading(settings *Settings, profile writableProfile) {
if value := profile.Presentation.Reading.OnlySpeakDisplayedText; value != nil {
settings.Reading.OnlySpeakDisplayedText = *value
}
if value := profile.Presentation.Reading.PhoneticCharacterHints; value != nil {
settings.Reading.PhoneticCharacterHints = *value
}
if value := profile.Presentation.Reading.EnablePauseBreaks; value != nil {
settings.Reading.EnablePauseBreaks = *value
}
+1
View File
@@ -84,6 +84,7 @@ type writableTextAttributes struct {
type writableReading struct {
AutomaticallyReadClipboardChanges *bool `toml:"automatically-read-clipboard-changes,omitempty"`
AutomaticallyReadTitleChanges *bool `toml:"automatically-read-title-changes,omitempty"`
PhoneticCharacterHints *bool `toml:"phonetic-character-hints,omitempty"`
EnablePauseBreaks *bool `toml:"enable-pause-breaks,omitempty"`
SpeakNumbersAsDigits *bool `toml:"speak-numbers-as-digits,omitempty"`
SpeakMultiCaseStringsAsWords *bool `toml:"speak-multicase-strings-as-words,omitempty"`
+1
View File
@@ -38,6 +38,7 @@ type Voice struct {
type ReadingSettings struct {
AutomaticallyReadClipboardChanges bool
AutomaticallyReadTitleChanges bool
PhoneticCharacterHints bool
EnablePauseBreaks bool
SpeakNumbersAsDigits bool
SpeakMultiCaseStringsAsWords bool
+2
View File
@@ -127,6 +127,8 @@ const (
ReviewWindowEnd Command = "review-window-end"
ReviewPreviousLine Command = "review-previous-line"
ReviewCurrentLine Command = "review-current-line"
ReviewSpellLine Command = "review-spell-line"
ReviewPhoneticLine Command = "review-phonetic-line"
ReviewNextLine Command = "review-next-line"
ReviewPreviousWord Command = "review-previous-word"
ReviewCurrentWord Command = "review-current-word"
+8 -2
View File
@@ -28,9 +28,9 @@ func TestReviewMultiPressSequences(t *testing.T) {
router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout))
now := time.Unix(100, 0)
router.now = func() time.Time { return now }
wordKey, charKey := uint32(0xff9d), uint32(0xff99)
wordKey, charKey, lineKey := uint32(0xff9d), uint32(0xff99), uint32(0xff97)
if layout == LaptopKeyboardLayout {
wordKey, charKey = 'k', ','
wordKey, charKey, lineKey = 'k', ',', 'i'
}
press := func(symbol uint32, want Command) {
t.Helper()
@@ -55,6 +55,12 @@ func TestReviewMultiPressSequences(t *testing.T) {
router.Reset()
router.ReplaceBindings(BrowseCommandBindings(layout))
}
press(lineKey, ReviewCurrentLine)
press(lineKey, ReviewSpellLine)
press(lineKey, ReviewPhoneticLine)
press(lineKey, ReviewCurrentLine)
now = now.Add(501 * time.Millisecond)
press(lineKey, ReviewCurrentLine)
press(charKey, ReviewCurrentCharacter)
now = now.Add(500 * time.Millisecond)
press(charKey, ReviewPhoneticCharacter)
+9 -2
View File
@@ -31,6 +31,7 @@ type Activity struct {
// Unlike presentation intent, this counter never expires. Deferred titles
// may be waiting for application settings after ordinary input has aged out.
TitleInterruptionSequence uint64
PlainCharacterArrow bool
Selecting bool
Intent Intent
Sequence uint64
@@ -53,6 +54,7 @@ type IntentTracker struct {
held map[string]bool
intent Intent
clipboard ClipboardAction
plainCharacterArrow bool
selecting bool
at time.Time
}
@@ -69,12 +71,13 @@ func (tracker *IntentTracker) Observe(event Event) {
if tracker.held == nil {
tracker.held = make(map[string]bool)
}
if event.Kind == KeyModifier {
if event.Kind == KeyModifier || event.Name == "Super_L" || event.Name == "Super_R" {
tracker.held[event.Name] = event.Pressed
if event.Pressed {
tracker.intent, tracker.at = OtherIntent, time.Now()
tracker.clipboard = NoClipboardAction
tracker.selecting = false
tracker.plainCharacterArrow = false
}
return
}
@@ -121,6 +124,9 @@ func (tracker *IntentTracker) Observe(event Event) {
}
}
}
tracker.plainCharacterArrow = (name == "Left" || name == "Right") && !ctrl && !shift && !alt &&
!tracker.held["Super_L"] && !tracker.held["Super_R"] &&
!tracker.held["ISO_Level3_Shift"] && !tracker.held["ISO_Level5_Shift"]
tracker.selecting = shift && (intent == CharacterIntent || intent == WordIntent || intent == LineIntent)
tracker.intent, tracker.at = intent, time.Now()
}
@@ -134,6 +140,7 @@ func (tracker *IntentTracker) Reset() {
clear(tracker.held)
tracker.intent, tracker.at = OtherIntent, time.Time{}
tracker.clipboard, tracker.selecting = NoClipboardAction, false
tracker.plainCharacterArrow = false
}
func (tracker *IntentTracker) RecentInput() Activity {
@@ -142,6 +149,6 @@ func (tracker *IntentTracker) RecentInput() Activity {
if time.Since(tracker.at) > time.Second {
return Activity{InputSequence: tracker.sequence, TitleInterruptionSequence: tracker.titleInterruptionSequence}
}
return Activity{InputSequence: tracker.sequence, Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting,
return Activity{PlainCharacterArrow: tracker.plainCharacterArrow, InputSequence: tracker.sequence, Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting,
TitleInterruptionSequence: tracker.titleInterruptionSequence}
}
+24
View File
@@ -122,3 +122,27 @@ func TestNavigationInputIdentitySurvivesIdle(t *testing.T) {
t.Fatalf("idle input identity = %+v", got)
}
}
func TestPlainCharacterArrowsExcludeOtherNavigation(t *testing.T) {
for _, modifier := range []string{"", "Shift_L", "Control_L", "Alt_L", "Super_L", "ISO_Level3_Shift"} {
for _, key := range []string{"Left", "Right", "KP_Left", "Home", "End", "Up", "a"} {
var tracker IntentTracker
if modifier != "" {
tracker.Observe(NewKeyEvent(1, true, modifier))
}
tracker.Observe(NewKeyEvent(2, true, key))
want := modifier == "" && (key == "Left" || key == "Right" || key == "KP_Left")
if got := tracker.RecentInput().PlainCharacterArrow; got != want {
t.Fatalf("%s+%s hint = %t, want %t", modifier, key, got, want)
}
tracker.Observe(NewKeyEvent(2, false, key))
if tracker.RecentInput().PlainCharacterArrow != want {
t.Fatal("releasing arrow discarded presentation intent")
}
tracker.Reset()
if tracker.RecentInput().PlainCharacterArrow {
t.Fatal("reset retained phonetic arrow")
}
}
}
}
+10 -1
View File
@@ -10,7 +10,7 @@ import "time"
// updates. Another key/chord, timeout, or the final press retires it. Held-key
// repeats never reach this helper. Reset only clears held keys, as for time/date.
func (router *CommandRouter) review_press(command Command, chord CommandChord) Command {
if command != ReviewCurrentCharacter && command != ReviewCurrentWord {
if command != ReviewCurrentCharacter && command != ReviewCurrentWord && command != ReviewCurrentLine {
router.lastReviewPress = time.Time{}
return command
}
@@ -35,5 +35,14 @@ func (router *CommandRouter) review_press(command Command, chord CommandChord) C
return ReviewPhoneticWord
}
}
if command == ReviewCurrentLine {
switch router.reviewPresses {
case 2:
return ReviewSpellLine
case 3:
router.lastReviewPress = time.Time{}
return ReviewPhoneticLine
}
}
return command
}
+1
View File
@@ -8,6 +8,7 @@ static const struct { const char *id; const char *label; } reading_rows[] = {
{"only_speak_displayed_text", "Only speak displayed text"},
{"automatically_read_title_changes", "Automatically read title changes"},
{"automatically_read_clipboard_changes", "Automatically read clipboard changes"},
{"phonetic_character_hints", "Phonetic character hints"},
{"enable_pause_breaks", "Break speech into chunks between pauses"},
{"speak_numbers_as_digits", "Speak numbers as digits"},
{"speak_multicase_strings_as_words", "Speak multicase strings as words"},
+2 -1
View File
@@ -51,7 +51,7 @@ func TestReadingValuesRoundTrip(t *testing.T) {
!values.SpeakDescription || values.Reading.ReadFullRowInSpreadSheet ||
!values.EnableSpeech || !values.Reading.EnablePauseBreaks ||
values.Reading.SpeakNumbersAsDigits || values.Reading.SpeakMultiCaseStringsAsWords ||
values.Reading.EnableIndentation {
values.Reading.EnableIndentation || values.Reading.PhoneticCharacterHints {
t.Fatalf("default reading values = %+v", values)
}
@@ -63,6 +63,7 @@ func TestReadingValuesRoundTrip(t *testing.T) {
values.Reading.SpeakNumbersAsDigits = true
values.Reading.SpeakMultiCaseStringsAsWords = true
values.Reading.EnableIndentation = true
values.Reading.PhoneticCharacterHints = true
values.Reading.SpeakBlankLines = false
values.Reading.EnableMnemonicSpeaking = true
values.Reading.EnablePositionSpeaking = true
+1
View File
@@ -41,6 +41,7 @@ func readingChecks(values *Values) []struct {
{"only_speak_displayed_text", &values.Reading.OnlySpeakDisplayedText},
{"automatically_read_title_changes", &values.Reading.AutomaticallyReadTitleChanges},
{"automatically_read_clipboard_changes", &values.Reading.AutomaticallyReadClipboardChanges},
{"phonetic_character_hints", &values.Reading.PhoneticCharacterHints},
{"enable_pause_breaks", &values.Reading.EnablePauseBreaks},
{"speak_numbers_as_digits", &values.Reading.SpeakNumbersAsDigits},
{"speak_multicase_strings_as_words", &values.Reading.SpeakMultiCaseStringsAsWords},
+2 -2
View File
@@ -37,8 +37,8 @@ self-voice-dbus-speak,src/cthulhu/dbus_service.py,remote,Speak external plain te
self-voice-dbus-braille,src/cthulhu/dbus_service.py,remote,Display an external persistent braille message over the session D-Bus service,Storm Dragon,internal/remote internal/app,in-progress,go test -tags brlapi ./internal/remote ./internal/app -count=1,Focused tests confirm connected-worker queueing and rejection when disconnected.,,,Pending physical BrailleMessage delivery and persistent-display acceptance,BrailleMessage queues a persistent frame only after the BrlAPI helper reports ready. It rejects empty and over-10-KiB text; physical display and Wine client acceptance remain open.
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 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.
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. Numpad 2 supports phonetic double press; Numpad 5 and 8 support literal and phonetic spelling on second and third presses within 500 ms. Shared desktop/laptop routing plus private X11 consumption and OCR cursor tests pass; live spelling listening remains pending. See docs/testing/phonetic-review.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 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. Phonetic character hints is an unchecked Speech Reading option; plain Left/Right character navigation can append a cancellable 300 ms phonetic hint. Private GTK default toggle Apply/reload and cancellation checks pass; physical listening remains pending. See docs/testing/phonetic-review.md.
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
37 self-voice-dbus-braille src/cthulhu/dbus_service.py remote Display an external persistent braille message over the session D-Bus service Storm Dragon internal/remote internal/app in-progress go test -tags brlapi ./internal/remote ./internal/app -count=1 Focused tests confirm connected-worker queueing and rejection when disconnected. Pending physical BrailleMessage delivery and persistent-display acceptance BrailleMessage queues a persistent frame only after the BrlAPI helper reports ready. It rejects empty and over-10-KiB text; physical display and Wine client acceptance remain open.
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. 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. Numpad 2 supports phonetic double press; Numpad 5 and 8 support literal and phonetic spelling on second and third presses within 500 ms. Shared desktop/laptop routing plus private X11 consumption and OCR cursor tests pass; live spelling listening remains pending. See docs/testing/phonetic-review.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 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. 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. Phonetic character hints is an unchecked Speech Reading option; plain Left/Right character navigation can append a cancellable 300 ms phonetic hint. Private GTK default toggle Apply/reload and cancellation checks pass; physical listening remains pending. See docs/testing/phonetic-review.md.
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.
+8 -1
View File
@@ -864,7 +864,12 @@ def inside():
assert clipboardCell.name == "unchecked" and not clipboardCell.getState().contains(pyatspi.STATE_CHECKED)
focus(clipboardCell); key("space")
wait_for(lambda: clipboardCell.name == "checked", "clipboard preference did not toggle")
for label, expected in (("Announce lists in Say All", "checked"),
hintRow = next(row for row in range(readingTable.nRows)
if readingTable.getAccessibleAt(row, 1).name == "Phonetic character hints")
hintCell = readingTable.getAccessibleAt(hintRow, 0)
assert hintCell.name == "unchecked" and not hintCell.getState().contains(pyatspi.STATE_CHECKED)
for label, expected in (("Phonetic character hints", "checked"),
("Announce lists in Say All", "checked"),
("Present tooltips", "checked"),
("Speak object under mouse", "checked")):
row = next(row for row in range(readingTable.nRows)
@@ -898,6 +903,7 @@ def inside():
assert applied["Values"]["Reading"]["OnlySpeakDisplayedText"] != wasChecked, applied
assert not applied["Values"]["Reading"]["AutomaticallyReadTitleChanges"], applied
assert applied["Values"]["Reading"]["AutomaticallyReadClipboardChanges"], applied
assert applied["Values"]["Reading"]["PhoneticCharacterHints"], applied
assert applied["Values"]["Reading"]["SayAllContextList"], applied
assert applied["Values"]["Reading"]["PresentTooltips"], applied
assert applied["Values"]["Reading"]["EnableMouseReview"], applied
@@ -906,6 +912,7 @@ def inside():
assert readingTable.getAccessibleAt(0, 0).name == ("unchecked" if wasChecked else "checked")
assert readingTable.getAccessibleAt(titleRow, 0).name == "unchecked"
assert readingTable.getAccessibleAt(clipboardRow, 0).name == "checked"
assert readingTable.getAccessibleAt(hintRow, 0).name == "checked"
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)
if readingTable.getAccessibleAt(row, 1).name == "Announce lists in Say All"), 0).name == "checked"
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)
+17
View File
@@ -564,6 +564,22 @@ def run_inside(directory):
flush_probe_events()
assert events.read_text().count(', ' + desktop_key + ')') == before_key, \
'desktop review key leaked: ' + desktop_key
for reviewKey, expectedCommands in (
('KP_Down', ('review-current-character', 'review-phonetic-character')),
('KP_Begin', ('review-current-word', 'review-spell-word', 'review-phonetic-word')),
('KP_Up', ('review-current-line', 'review-spell-line', 'review-phonetic-line'))):
beforeKey = events.read_text().count(', ' + reviewKey + ')')
beforeCommands = {command: count_records(log, '"message":"flat review command received"',
'"command":"' + command + '"')
for command in expectedCommands}
subprocess.run(['xdotool', 'key', '--delay', '80'] + [reviewKey] * len(expectedCommands), check=True)
for expectedCommand in expectedCommands:
wait_for(lambda command=expectedCommand: count_records(log, '"message":"flat review command received"',
'"command":"' + command + '"') == beforeCommands[command] + 1,
'review multipress missing: ' + expectedCommand)
flush_probe_events()
assert events.read_text().count(', ' + reviewKey + ')') == beforeKey, 'multipress review key leaked'
print('PASS: double character and triple word/line review commands with no application leakage')
for desktop_key, command in [('KP_Home', 'review-window-start'), ('KP_Page_Up', 'review-window-end')]:
before_key = events.read_text().count(', ' + desktop_key + ')')
for modifier_first in (False, True):
@@ -652,6 +668,7 @@ def run_inside(directory):
expected_command = command
if modifier_first:
expected_command = {
'review-current-line': 'review-spell-line',
'review-current-word': 'review-spell-word',
'review-current-character': 'review-phonetic-character',
}.get(command, command)