Touch up our phonetic settings.
This commit is contained in:
1 parent
08cbcc00cb
commit
ffcea2efea
34 files changed
+467
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
} {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -38,6 +38,7 @@ type Voice struct {
|
||||
type ReadingSettings struct {
|
||||
AutomaticallyReadClipboardChanges bool
|
||||
AutomaticallyReadTitleChanges bool
|
||||
PhoneticCharacterHints bool
|
||||
EnablePauseBreaks bool
|
||||
SpeakNumbersAsDigits bool
|
||||
SpeakMultiCaseStringsAsWords bool
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
Vendored
+2
-2
@@ -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.
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user