diff --git a/README.md b/README.md index 688d646..f2008a1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/testing/phonetic-review.md b/docs/testing/phonetic-review.md new file mode 100644 index 0000000..1afa1dc --- /dev/null +++ b/docs/testing/phonetic-review.md @@ -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. diff --git a/internal/app/app.go b/internal/app/app.go index 37dfcaa..0131d36 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) { diff --git a/internal/app/document_review.go b/internal/app/document_review.go index 15cbe11..8bce04d 100644 --- a/internal/app/document_review.go +++ b/internal/app/document_review.go @@ -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:] diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go index 57d6199..67f7583 100644 --- a/internal/app/echo_speaker_test.go +++ b/internal/app/echo_speaker_test.go @@ -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) diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index c10e5c0..b6537fc 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -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, diff --git a/internal/app/flat_review.go b/internal/app/flat_review.go index c234928..ef2cd4f 100644 --- a/internal/app/flat_review.go +++ b/internal/app/flat_review.go @@ -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 diff --git a/internal/app/ocr_commands.go b/internal/app/ocr_commands.go index 63f0183..d554cf6 100644 --- a/internal/app/ocr_commands.go +++ b/internal/app/ocr_commands.go @@ -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 diff --git a/internal/app/ocr_session_test.go b/internal/app/ocr_session_test.go index 57db4e0..3e344ca 100644 --- a/internal/app/ocr_session_test.go +++ b/internal/app/ocr_session_test.go @@ -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"}}, } { diff --git a/internal/app/phonetic_hints.go b/internal/app/phonetic_hints.go new file mode 100644 index 0000000..c0ea6c5 --- /dev/null +++ b/internal/app/phonetic_hints.go @@ -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) +} diff --git a/internal/app/phonetic_hints_test.go b/internal/app/phonetic_hints_test.go new file mode 100644 index 0000000..a2f53f4 --- /dev/null +++ b/internal/app/phonetic_hints_test.go @@ -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) + } + } +} diff --git a/internal/app/remote.go b/internal/app/remote.go index 64592d9..26dee86 100644 --- a/internal/app/remote.go +++ b/internal/app/remote.go @@ -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"), }) } diff --git a/internal/app/review_spelling.go b/internal/app/review_spelling.go index 09c1a2a..93eeb46 100644 --- a/internal/app/review_spelling.go +++ b/internal/app/review_spelling.go @@ -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 := "" diff --git a/internal/app/review_spelling_test.go b/internal/app/review_spelling_test.go index 9acf664..8db4f82 100644 --- a/internal/app/review_spelling_test.go +++ b/internal/app/review_spelling_test.go @@ -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}, diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go index 2162424..842bb7a 100644 --- a/internal/app/speech_delivery.go +++ b/internal/app/speech_delivery.go @@ -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 { diff --git a/internal/app/speech_interrupt.go b/internal/app/speech_interrupt.go index b28ca78..96d4586 100644 --- a/internal/app/speech_interrupt.go +++ b/internal/app/speech_interrupt.go @@ -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() } diff --git a/internal/app/speech_mode_test.go b/internal/app/speech_mode_test.go index 3044542..640e63d 100644 --- a/internal/app/speech_mode_test.go +++ b/internal/app/speech_mode_test.go @@ -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"}, diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go index fbbfd2e..4ef8052 100644 --- a/internal/app/speech_worker.go +++ b/internal/app/speech_worker.go @@ -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{} diff --git a/internal/app/wine_events_test.go b/internal/app/wine_events_test.go index 4ada535..61e4c0e 100644 --- a/internal/app/wine_events_test.go +++ b/internal/app/wine_events_test.go @@ -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) } diff --git a/internal/config/phonetic_hints_test.go b/internal/config/phonetic_hints_test.go new file mode 100644 index 0000000..c65848a --- /dev/null +++ b/internal/config/phonetic_hints_test.go @@ -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) + } +} diff --git a/internal/config/profile_mapping.go b/internal/config/profile_mapping.go index bbdd13d..4b61ddf 100644 --- a/internal/config/profile_mapping.go +++ b/internal/config/profile_mapping.go @@ -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 } diff --git a/internal/config/schema.go b/internal/config/schema.go index 8359c23..0653d64 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -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"` diff --git a/internal/config/settings.go b/internal/config/settings.go index 486de02..125f10b 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -38,6 +38,7 @@ type Voice struct { type ReadingSettings struct { AutomaticallyReadClipboardChanges bool AutomaticallyReadTitleChanges bool + PhoneticCharacterHints bool EnablePauseBreaks bool SpeakNumbersAsDigits bool SpeakMultiCaseStringsAsWords bool diff --git a/internal/input/commands.go b/internal/input/commands.go index 3a0bcb6..953799a 100644 --- a/internal/input/commands.go +++ b/internal/input/commands.go @@ -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" diff --git a/internal/input/flat_review_test.go b/internal/input/flat_review_test.go index e50f6a1..777c55d 100644 --- a/internal/input/flat_review_test.go +++ b/internal/input/flat_review_test.go @@ -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) diff --git a/internal/input/intent.go b/internal/input/intent.go index fbe8f6c..a775585 100644 --- a/internal/input/intent.go +++ b/internal/input/intent.go @@ -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} } diff --git a/internal/input/intent_test.go b/internal/input/intent_test.go index f7fced3..ac09de3 100644 --- a/internal/input/intent_test.go +++ b/internal/input/intent_test.go @@ -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") + } + } + } +} diff --git a/internal/input/review_presses.go b/internal/input/review_presses.go index 3ad0f74..b215f28 100644 --- a/internal/input/review_presses.go +++ b/internal/input/review_presses.go @@ -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 } diff --git a/internal/preferences/native_reading.c b/internal/preferences/native_reading.c index 7091242..74d71ac 100644 --- a/internal/preferences/native_reading.c +++ b/internal/preferences/native_reading.c @@ -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"}, diff --git a/internal/preferences/protocol_test.go b/internal/preferences/protocol_test.go index ebde143..a1b60a8 100644 --- a/internal/preferences/protocol_test.go +++ b/internal/preferences/protocol_test.go @@ -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 diff --git a/internal/preferences/reading.go b/internal/preferences/reading.go index 89dbd24..6d143f9 100644 --- a/internal/preferences/reading.go +++ b/internal/preferences/reading.go @@ -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}, diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 205ba9f..b9a0aff 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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. diff --git a/tests/native_preferences.py b/tests/native_preferences.py index f28663a..a4b13d8 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -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) diff --git a/tests/native_x11.py b/tests/native_x11.py index fcdb4ad..38049e8 100644 --- a/tests/native_x11.py +++ b/tests/native_x11.py @@ -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)