More work on LibreOffice support.

This commit is contained in:
Storm Dragon committed 2026-10-15 00:50:49 -04:00
1 parent e0c8c6198c
commit 4ac59d4ced
25 files changed
+392 -57

No files matched your search

+64 -13
View File
@@ -61,31 +61,58 @@ References inspected: Orca commit `cd58a88ed` (2026-10-14),
`speech_generator.py`, `braille_generator.py`, and shared `speech_generator.py`, `braille_generator.py`, and shared
`speech_generator.py` / `ax_utilities_table.py`. `speech_generator.py` / `ax_utilities_table.py`.
## Formula slice
- Normal cell speech appends “has formula” after the value and address.
Only-displayed-text mode continues to omit synthetic metadata.
- Basic Where Am I includes the formula after the cell information.
- **Cthulhu+=** presents the current cell's formula in the desktop layout,
following Orca's `where_am_i_presenter.py:present_cell_formula`. The semantic
command is `present-cell-formula`; it is listed in preferences and Learn Mode.
As in current Orca, the laptop layout leaves it unbound; assign a shortcut
in Key Bindings if desired.
- A cell without a formula gives its value, or “empty” for an empty cell.
The command currently applies to Calc cells; other focus reports “Not in a
table.” Temporary F2 editors are separate objects, so leave editing before
requesting the cell's formula.
- Native capture reads the uncached `formula` attribute (or `Formula`, as
accepted by Orca) alongside the existing bounded cell metadata. Formula
string-literal spacing is preserved. Expressions are bounded to the native
snapshot field limit, retaining the beginning of long expressions.
Commands consume the latest accepted focus snapshot; they never scan a sheet
or query a provider from the event loop. A provider change without an event
requires revisiting the cell to refresh its snapshot.
- Formula speech uses the existing command and speech queues, foreground and
screen-lock gates. Normal Braille retains the value and address, with no new
routes or formula suffix. The explicit formula command uses the existing
system-message flash path and its Braille preferences, as in Orca.
## Next slices ## Next slices
1. Formula presence and an on-demand formula command, using current Orca's 1. Selected/unselected ranges, rows, columns, and Select All. Represent ranges
`where_am_i_presenter.py:present_cell_formula` and cell attributes.
2. Selected/unselected ranges, rows, columns, and Select All. Represent ranges
compactly; do not expand every selected cell as Orca's current comparison compactly; do not expand every selected cell as Orca's current comparison
does. Test very large and disjoint selections. does. Test very large and disjoint selections.
3. Dynamic row/column headers, sheet-scoped state, preferences and bindings. 2. Dynamic row/column headers, sheet-scoped state, preferences and bindings.
Inspect current Orca's shared table navigator rather than copying only Inspect current Orca's shared table navigator rather than copying only
the soffice directory. Spreadsheet full-row reading also needs a bounded, the soffice directory. Spreadsheet full-row reading also needs a bounded,
explicitly defined extent; the generic setting is not full Calc support. explicitly defined extent; the generic setting is not full Calc support.
4. Writer paragraph navigation, selection, Say All, spell-check presentation, 3. Writer paragraph navigation, selection, Say All, spell-check presentation,
and Braille panning. Compare each actual workflow with current Orca. and Braille panning. Compare each actual workflow with current Orca.
Merged cells, overflow announcements, large-sheet responsiveness, other Merged cells, overflow announcements, large-sheet responsiveness, other
LibreOffice backends, and independent-workbook acceptance remain separate LibreOffice backends, and independent-workbook acceptance remain separate
checks. This first slice does not establish complete Office support. checks. These slices do not establish complete Office support.
## Verification ## Verification
```sh ```sh
GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1 GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation ./internal/input ./internal/platform/xorg -count=1
/usr/bin/python3 tests/native_libreoffice.py /usr/bin/python3 tests/native_libreoffice.py
python3 tests/native_atspi.py --table-only python3 tests/native_atspi.py --table-only
python3 tests/native_x11.py GOCACHE=/tmp/cthulhu-go-cache go build -o /tmp/cthulhu-libreoffice ./cmd/cthulhu
GOCACHE=/tmp/cthulhu-go-cache go test -c -o /tmp/cthulhu-libreoffice-xorg.test ./internal/platform/xorg
CTHULHU_BINARY=/tmp/cthulhu-libreoffice python3 tests/native_x11.py
xvfb-run -a env CTHULHU_NATIVE_X11=1 /tmp/cthulhu-libreoffice-xorg.test -test.run '^TestNativeDispatchBeforeRelease$' -test.v
``` ```
The Calc harness launches installed LibreOffice with a disposable profile, The Calc harness launches installed LibreOffice with a disposable profile,
@@ -93,7 +120,11 @@ document, virtual X11 display, and private session/accessibility buses.
It exercises the production native adapter and event loop; recorded speech It exercises the production native adapter and event loop; recorded speech
and Braille are asserted for cell movement and blanks, F2 typing, Escape and Braille are asserted for cell movement and blanks, F2 typing, Escape
cancellation, Enter commit, caret movement inside an existing numeric cell, cancellation, Enter commit, caret movement inside an existing numeric cell,
and switching between two sheets. and switching between two sheets. Formula assertions cover a numeric result,
an empty string result, literal spaces, replacement with a number, formula
and basic Where Am I requests, and non-formula cells on a second sheet.
The native listener tests check the desktop formula shortcut before key release;
the Calc harness sends semantic requests through the production event loop.
No active reader is replaced and no normal Office profile is changed. No active reader is replaced and no normal Office profile is changed.
These automated checks do not establish audible speech, physical input, or These automated checks do not establish audible speech, physical input, or
@@ -101,13 +132,19 @@ physical Braille. Before submission, review the diff and test a rebuilt binary:
1. In a disposable Calc workbook, populate text, numbers, formulas and blanks. 1. In a disposable Calc workbook, populate text, numbers, formulas and blanks.
Arrow between them and listen for the value followed by its address. Arrow between them and listen for the value followed by its address.
2. Press F2, edit, cancel with Escape, then edit and commit with Enter. Check 2. Visit a formula cell: listen for “has formula,” then use **Cthulhu+=**
and basic Where Am I. With Braille flash messages enabled, verify that the
formula command flashes its result and restores the cell display. Try a formula returning an empty string, a formula
containing a string literal with repeated spaces, a number, and a blank.
Replace a formula with a number and revisit it; the formula cue must clear.
With only-displayed-text enabled, automatic speech should omit the cue.
3. Press F2, edit, cancel with Escape, then edit and commit with Enter. Check
character/word navigation in an existing cell editor and continued arrows. character/word navigation in an existing cell editor and continued arrows.
3. Switch sheets and switch between two workbooks. Return from menus, dialogs, 4. Switch sheets and switch between two workbooks. Return from menus, dialogs,
the formula bar, and another application; check logical focus recovery. the formula bar, and another application; check logical focus recovery.
4. Check the cell value/address, spacing, editor cursor and return to cell 5. Check the cell value/address, spacing, editor cursor and return to cell
display on physical Braille. Text routing applies inside the editor. display on physical Braille. Text routing applies inside the editor.
5. Repeat ordinary GTK table movement/multiselection, browser typing and table 6. Repeat ordinary GTK table movement/multiselection, browser typing and table
reading, and Steam navigation. Check lock/unlock and stale/background events. reading, and Steam navigation. Check lock/unlock and stale/background events.
Human review and testing remain required by `CONTRIBUTING.md`. Passing this Human review and testing remain required by `CONTRIBUTING.md`. Passing this
@@ -121,6 +158,20 @@ failed on an extra title announcement both here and on untouched commit
then passed on the baseline and on a rerun here; the cause of that first then passed on the baseline and on a rerun here; the cause of that first
failure was not established. Physical acceptance remains pending. failure was not established. Physical acceptance remains pending.
Formula-slice verification passed the isolated real Calc harness, focused
race tests, vet/build, ordinary GTK table/multiselection checks, and the rebuilt
reader's Xorg smoke test. A separate private X11 listener test verified the
formula shortcut with both release orders. The broader app race run exposed
`TestRunReachesSoundStopAfterEarlierServiceStopError`: it can cancel during
startup and receive `context canceled` instead of its expected shutdown error.
The same failure reproduced on unchanged commit `e0c8c61` with
`GOMAXPROCS=8 go test -race ./internal/app -run '^TestRunReachesSoundStopAfterEarlierServiceStopError$' -count=100`.
The remaining app race tests passed with that one test excluded.
The broader native listener suite also has an existing C helper link failure
for missing xkbcommon symbols; the formula shortcut was tested directly using
the command above. Audible formula speech, physical input/Braille, and real
workbook acceptance remain pending.
## Calc startup focus diagnosis ## Calc startup focus diagnosis
A normal-profile startup under i3 can leave Calc accepting arrow keys while A normal-profile startup under i3 can leave Calc accepting arrow keys while
+14 -14
View File
@@ -452,7 +452,7 @@ func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)} queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan struct{}) where := make(chan input.Command)
browser := newBrowserModeController(nil, &modeKeys{}, nil) browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.ready, browser.mode = true, review.BrowseMode browser.ready, browser.mode = true, review.BrowseMode
done := make(chan struct{}) done := make(chan struct{})
@@ -472,7 +472,7 @@ func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) {
adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldHeading) adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldHeading)
// The event loop must finish processing the caret before it can receive // The event loop must finish processing the caret before it can receive
// this command. Join before inspecting controller state. // this command. Join before inspecting controller state.
where <- struct{}{} where <- input.WhereAmI
cancel() cancel()
<-done <-done
if browser.focus != entry.ID() || browser.generation != 1 { if browser.focus != entry.ID() || browser.generation != 1 {
@@ -524,7 +524,7 @@ func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)} queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan struct{}) where := make(chan input.Command)
keys := &modeKeys{} keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil) browser := newBrowserModeController(nil, keys, nil)
done := make(chan struct{}) done := make(chan struct{})
@@ -551,7 +551,7 @@ func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) {
load := atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document) load := atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document)
load.FocusOutsideDocument = entry.ID() load.FocusOutsideDocument = entry.ID()
adapter.events <- load adapter.events <- load
where <- struct{}{} where <- input.WhereAmI
expect("Address, autocomplete") expect("Address, autocomplete")
// A subsequent load with document focus must still announce and acquire // A subsequent load with document focus must still announce and acquire
// the new document, even if the previous chrome snapshot was editable. // the new document, even if the previous chrome snapshot was editable.
@@ -615,7 +615,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)} queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan struct{}) where := make(chan input.Command)
keys := &modeKeys{} keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil) browser := newBrowserModeController(nil, keys, nil)
done := make(chan struct{}) done := make(chan struct{})
@@ -635,7 +635,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) {
if tc.start == atspi.BusyStateEvent { if tc.start == atspi.BusyStateEvent {
expectActivationSpeech(t, queue.spoken, "Loading. Please wait.") expectActivationSpeech(t, queue.spoken, "Loading. Please wait.")
} }
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Example page, document web") expectActivationSpeech(t, queue.spoken, "Example page, document web")
if browser.focus.Valid() || browser.ready || keys.enabled { if browser.focus.Valid() || browser.ready || keys.enabled {
t.Fatal("loading retained browse focus") t.Fatal("loading retained browse focus")
@@ -646,7 +646,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) {
if tc.end == atspi.BusyStateEvent { if tc.end == atspi.BusyStateEvent {
expectActivationSpeech(t, queue.spoken, "Finished loading New page.") expectActivationSpeech(t, queue.spoken, "Finished loading New page.")
} }
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "New page, document web") expectActivationSpeech(t, queue.spoken, "New page, document web")
if browser.focus != end.Source.ID() || !browser.resolving { if browser.focus != end.Source.ID() || !browser.resolving {
t.Fatalf("load completion did not schedule document recovery: %+v", browser.focus) t.Fatalf("load completion did not schedule document recovery: %+v", browser.focus)
@@ -655,7 +655,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) {
dead := focus dead := focus
dead.Type, dead.Detail1 = atspi.DefunctStateEvent, 1 dead.Type, dead.Detail1 = atspi.DefunctStateEvent, 1
adapter.events <- dead adapter.events <- dead
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "New page, document web") expectActivationSpeech(t, queue.spoken, "New page, document web")
}) })
} }
@@ -665,7 +665,7 @@ func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)} queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan struct{}) where := make(chan input.Command)
browser := newBrowserModeController(nil, &modeKeys{}, nil) browser := newBrowserModeController(nil, &modeKeys{}, nil)
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
@@ -683,7 +683,7 @@ func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) {
event.FocusOutsideDocument = entry.Source.ID() event.FocusOutsideDocument = entry.Source.ID()
adapter.events <- event adapter.events <- event
} }
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Address, entry") expectActivationSpeech(t, queue.spoken, "Address, entry")
cancel() cancel()
<-done <-done
@@ -696,7 +696,7 @@ func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)} queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan struct{}) where := make(chan input.Command)
keys := &modeKeys{} keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil) browser := newBrowserModeController(nil, keys, nil)
done := make(chan struct{}) done := make(chan struct{})
@@ -718,7 +718,7 @@ func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) {
busy.Type, busy.Detail1 = atspi.BusyStateEvent, 0 busy.Type, busy.Detail1 = atspi.BusyStateEvent, 0
busy.FocusOutsideDocument = atspi.ObjectID{} busy.FocusOutsideDocument = atspi.ObjectID{}
adapter.events <- busy adapter.events <- busy
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Message one, table row") expectActivationSpeech(t, queue.spoken, "Message one, table row")
if browser.focus != row.Source.ID() || browser.mode != review.InactiveMode || keys.enabled { if browser.focus != row.Source.ID() || browser.mode != review.InactiveMode || keys.enabled {
t.Fatalf("preview busy completion took message-list arrows: focus %v, mode %v, grabs %v", browser.focus, browser.mode, keys.enabled) t.Fatalf("preview busy completion took message-list arrows: focus %v, mode %v, grabs %v", browser.focus, browser.mode, keys.enabled)
@@ -761,7 +761,7 @@ func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)} queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
where := make(chan struct{}) where := make(chan input.Command)
browser := newBrowserModeController(nil, &modeKeys{}, nil) browser := newBrowserModeController(nil, &modeKeys{}, nil)
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
@@ -793,7 +793,7 @@ func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) {
adapter.events <- dead adapter.events <- dead
} }
// The unbuffered command follows event processing, without a sleep. // The unbuffered command follows event processing, without a sleep.
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, test.want) expectActivationSpeech(t, queue.spoken, test.want)
cancel() cancel()
<-done <-done
+16 -12
View File
@@ -33,7 +33,7 @@ func processEventsWithReview(
logger *slog.Logger, logger *slog.Logger,
speech speechEnqueuer, speech speechEnqueuer,
adapter Adapter, adapter Adapter,
reviewRequests <-chan struct{}, reviewRequests <-chan input.Command,
debounce time.Duration, debounce time.Duration,
configuredPresentation ...presentation.Options, configuredPresentation ...presentation.Options,
) error { ) error {
@@ -45,12 +45,12 @@ func processEventsWithObjectReview(
logger *slog.Logger, logger *slog.Logger,
speech speechEnqueuer, speech speechEnqueuer,
adapter Adapter, adapter Adapter,
whereAmIRequests <-chan struct{}, reviewRequests <-chan input.Command,
objectReview *review.Worker, objectReview *review.Worker,
debounce time.Duration, debounce time.Duration,
configuredPresentation ...presentation.Options, configuredPresentation ...presentation.Options,
) error { ) error {
return processEventsWithSettings(ctx, logger, speech, adapter, whereAmIRequests, objectReview, debounce, nil, configuredPresentation...) return processEventsWithSettings(ctx, logger, speech, adapter, reviewRequests, objectReview, debounce, nil, configuredPresentation...)
} }
func processEventsWithSettings( func processEventsWithSettings(
@@ -58,13 +58,13 @@ func processEventsWithSettings(
logger *slog.Logger, logger *slog.Logger,
speech speechEnqueuer, speech speechEnqueuer,
adapter Adapter, adapter Adapter,
whereAmIRequests <-chan struct{}, reviewRequests <-chan input.Command,
objectReview *review.Worker, objectReview *review.Worker,
debounce time.Duration, debounce time.Duration,
live *liveSettings, live *liveSettings,
configuredPresentation ...presentation.Options, configuredPresentation ...presentation.Options,
) error { ) error {
return processEventsWithInput(ctx, logger, speech, adapter, whereAmIRequests, objectReview, debounce, live, nil, nil, nil, configuredPresentation...) return processEventsWithInput(ctx, logger, speech, adapter, reviewRequests, objectReview, debounce, live, nil, nil, nil, configuredPresentation...)
} }
func processEventsWithInput( func processEventsWithInput(
@@ -72,7 +72,7 @@ func processEventsWithInput(
logger *slog.Logger, logger *slog.Logger,
speech speechEnqueuer, speech speechEnqueuer,
adapter Adapter, adapter Adapter,
whereAmIRequests <-chan struct{}, reviewRequests <-chan input.Command,
objectReview *review.Worker, objectReview *review.Worker,
debounce time.Duration, debounce time.Duration,
live *liveSettings, live *liveSettings,
@@ -81,7 +81,7 @@ func processEventsWithInput(
notifications *notification.Presenter, notifications *notification.Presenter,
configuredPresentation ...presentation.Options, configuredPresentation ...presentation.Options,
) error { ) error {
return processEventsWithOCR(ctx, logger, speech, adapter, whereAmIRequests, objectReview, debounce, live, inputSource, focusGate, notifications, nil, configuredPresentation...) return processEventsWithOCR(ctx, logger, speech, adapter, reviewRequests, objectReview, debounce, live, inputSource, focusGate, notifications, nil, configuredPresentation...)
} }
func processEventsWithOCR( func processEventsWithOCR(
@@ -89,7 +89,7 @@ func processEventsWithOCR(
logger *slog.Logger, logger *slog.Logger,
speech speechEnqueuer, speech speechEnqueuer,
adapter Adapter, adapter Adapter,
whereAmIRequests <-chan struct{}, reviewRequests <-chan input.Command,
objectReview *review.Worker, objectReview *review.Worker,
debounce time.Duration, debounce time.Duration,
live *liveSettings, live *liveSettings,
@@ -99,7 +99,7 @@ func processEventsWithOCR(
ocrSession *ocrSession, ocrSession *ocrSession,
configuredPresentation ...presentation.Options, configuredPresentation ...presentation.Options,
) error { ) error {
return processEventsWithAttributes(ctx, logger, speech, adapter, whereAmIRequests, nil, objectReview, debounce, live, inputSource, focusGate, notifications, ocrSession, configuredPresentation...) return processEventsWithAttributes(ctx, logger, speech, adapter, reviewRequests, nil, objectReview, debounce, live, inputSource, focusGate, notifications, ocrSession, configuredPresentation...)
} }
func processEventsWithAttributes( func processEventsWithAttributes(
@@ -107,7 +107,7 @@ func processEventsWithAttributes(
logger *slog.Logger, logger *slog.Logger,
speech speechEnqueuer, speech speechEnqueuer,
adapter Adapter, adapter Adapter,
whereAmIRequests <-chan struct{}, reviewRequests <-chan input.Command,
attributeRequests <-chan struct{}, attributeRequests <-chan struct{},
objectReview *review.Worker, objectReview *review.Worker,
debounce time.Duration, debounce time.Duration,
@@ -316,8 +316,12 @@ func processEventsWithAttributes(
} }
event, _ := state.pending.terminalDeletion.take() event, _ := state.pending.terminalDeletion.take()
speak(event) speak(event)
case <-whereAmIRequests: case command := <-reviewRequests:
reviewHandler.whereAmI() if command == input.PresentCellFormula {
reviewHandler.presentCellFormula()
} else {
reviewHandler.whereAmI()
}
case <-attributeRequests: case <-attributeRequests:
if !focusGate.Locked() { if !focusGate.Locked() {
syncForeground() syncForeground()
+3
View File
@@ -45,6 +45,9 @@ func (handler *eventReviewHandler) whereAmI() {
reviewOptions := state.presentationOptions reviewOptions := state.presentationOptions
reviewOptions.EnableSound = false reviewOptions.EnableSound = false
spoken := presentation.FocusSpeechWithOptions(state.current, reviewOptions) spoken := presentation.FocusSpeechWithOptions(state.current, reviewOptions)
if formula := presentation.LibreOfficeCellFormula(state.current); formula != "" {
spoken = spoken.Append(formula, ", ")
}
text := spoken.Text text := spoken.Text
role := state.current.Role role := state.current.Role
if text == "" { if text == "" {
+4 -4
View File
@@ -160,7 +160,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
a.state.Store(&state) a.state.Store(&state)
queue := &fakeSpeechQueue{spoken: make(chan string, 16)} queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
where := make(chan struct{}) where := make(chan input.Command)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
@@ -180,12 +180,12 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
// atomic epoch even if the channel notification has not been selected. // atomic epoch even if the channel notification has not been selected.
event.Source.Name = "Stale" event.Source.Name = "Stale"
a.events <- event a.events <- event
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Could not find current location.") expectActivationSpeech(t, queue.spoken, "Could not find current location.")
event.Foreground = next event.Foreground = next
event.ForegroundMismatch = true event.ForegroundMismatch = true
a.events <- event a.events <- event
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Could not find current location.") expectActivationSpeech(t, queue.spoken, "Could not find current location.")
if window != 0 { if window != 0 {
event.ForegroundMismatch = false event.ForegroundMismatch = false
@@ -195,7 +195,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
deactivated := event deactivated := event
deactivated.Type = atspi.WindowDeactivatedEvent deactivated.Type = atspi.WindowDeactivatedEvent
a.events <- deactivated a.events <- deactivated
where <- struct{}{} where <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "New, push button") expectActivationSpeech(t, queue.spoken, "New, push button")
} }
}) })
+2
View File
@@ -171,6 +171,8 @@ func learnCommandDescription(command input.Command) string {
return "Choose an application and open its preferences." return "Choose an application and open its preferences."
case input.WhereAmI: case input.WhereAmI:
return "Speak details of the current item." return "Speak details of the current item."
case input.PresentCellFormula:
return "Present the formula for the current cell."
case input.PresentCharacterAttributes: case input.PresentCharacterAttributes:
return "Read the current character's font and formatting attributes." return "Read the current character's font and formatting attributes."
case input.ReadAll: case input.ReadAll:
+14
View File
@@ -8,6 +8,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
) )
// prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy. // prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy.
@@ -89,3 +90,16 @@ func libreOfficeBrailleFrame(snapshot atspi.Snapshot, options config.BrailleOpti
return braille.Frame{Text: brailleJoin(context.CellText, context.Coordinates), Cursor: 0, return braille.Frame{Text: brailleJoin(context.CellText, context.Coordinates), Cursor: 0,
WordWrap: options.WordWrap}, true WordWrap: options.WordWrap}, true
} }
// The event loop owns the accepted focus snapshot. Formula commands share its
// foreground/lock gates and output queue, with no separate application state.
func (handler *eventReviewHandler) presentCellFormula() {
if handler.focusGate.Locked() || handler.focusGate.Suspended() {
return
}
handler.syncForeground()
handler.state.pending.clear()
text := presentation.LibreOfficeFormulaText(handler.state.current)
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "cell-formula"})
}
+31 -1
View File
@@ -16,6 +16,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/presentation"
) )
@@ -49,12 +50,13 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 256)} speech := &fakeSpeechQueue{spoken: make(chan string, 256)}
frames := make(chan braille.Frame, 256) frames := make(chan braille.Frame, 256)
done := make(chan error, 1) done := make(chan error, 1)
commands := make(chan input.Command, 1)
var logBuffer bytes.Buffer var logBuffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug})) logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
options := presentation.DefaultOptions() options := presentation.DefaultOptions()
options.EnableEchoByCharacter = true options.EnableEchoByCharacter = true
go func() { go func() {
done <- processEvents(ctx, logger, outputQueue{speech: speech, braille: calcFrameQueue{frames}}, adapter, 0, options) done <- processEventsWithReview(ctx, logger, outputQueue{speech: speech, braille: calcFrameQueue{frames}}, adapter, commands, 0, options)
}() }()
defer func() { defer func() {
cancel() cancel()
@@ -168,4 +170,32 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
press("ctrl+F6", "Down") press("ctrl+F6", "Down")
waitSpeech("Beta, B2") waitSpeech("Beta, B2")
waitFrame("Beta B2") waitFrame("Beta B2")
press("ctrl+Home", "Right", "Right")
waitSpeech("43, C1, has formula")
waitFrame("43 C1")
commands <- input.PresentCellFormula
waitSpeech("B1+1")
commands <- input.WhereAmI
waitSpeech("43, C1, has formula, B1+1")
press("Right")
waitSpeech("blank, D1, has formula")
commands <- input.PresentCellFormula
waitSpeech(`""`)
// Replacing a formula must refresh metadata for the same cell identity.
press("7", "Return", "Up")
waitSpeech("7, D1")
commands <- input.PresentCellFormula
waitSpeech("7")
press("Right")
waitSpeech("two spaces, E1, has formula")
commands <- input.PresentCellFormula
waitSpeech(`"two spaces"`)
press("Right")
waitSpeech("blank, F1")
commands <- input.PresentCellFormula
waitSpeech("empty")
press("ctrl+Page_Down", "ctrl+Home", "Right", "Right")
waitSpeech("blank, C1")
commands <- input.PresentCellFormula
waitSpeech("empty")
} }
+36
View File
@@ -26,6 +26,42 @@ func calcTestEvent(eventType, path, value, coordinate string) atspi.Event {
return event return event
} }
func TestLibreOfficeFormulaCommandUsesOwnedFocusAndSecurityGates(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
state := eventLoopState{}
state.current = calcTestEvent(atspi.FocusEvent, "/cell", "43", "C1").Source
state.current.LibreOffice.Formula = "B1+1"
gate := &focusGate{}
display := &flashRecorder{}
live := &liveSettings{}
live.Store(config.DefaultSettings())
queue := outputQueue{speech: speech, braille: display, live: live}
handler := eventReviewHandler{state: &state, speech: queue, focusGate: gate,
syncForeground: func() {}}
handler.presentCellFormula()
if got := <-speech.spoken; got != "B1+1" {
t.Fatalf("formula = %q", got)
}
if len(display.messages) != 1 || display.messages[0] != "B1+1" {
t.Fatalf("formula Braille flash = %v", display.messages)
}
gate.secure.Store(true)
handler.presentCellFormula()
gate.secure.Store(false)
gate.suspended.Store(true)
handler.presentCellFormula()
gate.suspended.Store(false)
if len(speech.spoken) != 0 || len(display.messages) != 1 {
t.Fatal("formula command bypassed lock or suspension")
}
// Synchronizing foreground ownership can retire the old Calc focus.
handler.syncForeground = func() { state.current = atspi.Snapshot{} }
handler.presentCellFormula()
if got := <-speech.spoken; got != "Not in a table." {
t.Fatalf("stale foreground formula = %q", got)
}
}
func TestLibreOfficeMovementEditingAndFocusReturn(t *testing.T) { func TestLibreOfficeMovementEditingAndFocusReturn(t *testing.T) {
state := eventLoopState{} state := eventLoopState{}
state.presentationOptions = presentation.DefaultOptions() state.presentationOptions = presentation.DefaultOptions()
+5 -4
View File
@@ -24,6 +24,7 @@ import (
"time" "time"
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/review"
) )
@@ -34,7 +35,7 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) {
interrupt: make(chan struct{}, 1), interrupt: make(chan struct{}, 1),
} }
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
reviewRequests := make(chan struct{}, 1) reviewRequests := make(chan input.Command, 1)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
completed := make(chan error, 1) completed := make(chan error, 1)
@@ -65,7 +66,7 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) {
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("focus was not presented") t.Fatal("focus was not presented")
} }
reviewRequests <- struct{}{} reviewRequests <- input.WhereAmI
select { select {
case <-speech.interrupt: case <-speech.interrupt:
case <-time.After(time.Second): case <-time.After(time.Second):
@@ -93,7 +94,7 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) {
func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) { func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 1)} speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
adapter := &fakeAdapter{events: make(chan atspi.Event)} adapter := &fakeAdapter{events: make(chan atspi.Event)}
reviewRequests := make(chan struct{}, 1) reviewRequests := make(chan input.Command, 1)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
completed := make(chan error, 1) completed := make(chan error, 1)
@@ -107,7 +108,7 @@ func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) {
0, 0,
) )
}() }()
reviewRequests <- struct{}{} reviewRequests <- input.WhereAmI
select { select {
case text := <-speech.spoken: case text := <-speech.spoken:
if text != "Could not find current location." { if text != "Could not find current location." {
+8 -4
View File
@@ -51,7 +51,7 @@ type commandRuntime struct {
onNotificationList func() onNotificationList func()
live *liveSettings live *liveSettings
clipboardRequests chan<- struct{} clipboardRequests chan<- struct{}
reviewRequests chan<- struct{} reviewRequests chan<- input.Command
attributeRequests chan<- struct{} attributeRequests chan<- struct{}
objectReview *review.Worker objectReview *review.Worker
ocr *ocrSession ocr *ocrSession
@@ -228,10 +228,14 @@ func (worker commandRuntime) run(ctx context.Context) error {
if onNotificationList != nil { if onNotificationList != nil {
onNotificationList() onNotificationList()
} }
case input.WhereAmI: case input.WhereAmI, input.PresentCellFormula:
logger.Info("where am I command received") if command == input.WhereAmI {
logger.Info("where am I command received")
} else {
logger.Info("cell formula command received")
}
select { select {
case reviewRequests <- struct{}{}: case reviewRequests <- command:
default: default:
} }
case input.PresentCharacterAttributes: case input.PresentCharacterAttributes:
+32
View File
@@ -28,6 +28,38 @@ import (
"git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/input"
) )
func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
commands := &fakeCommands{commands: make(chan input.CommandEvent, 1)}
requests := make(chan input.Command, 1)
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands,
reviewRequests: requests, stopRun: cancel}
done := make(chan error, 1)
go func() { done <- worker.run(ctx) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(time.Second):
t.Error("command worker did not stop")
}
}()
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula} {
commands.commands <- input.CommandEvent{Command: command}
select {
case got := <-requests:
if got != command {
t.Fatalf("focus request = %q, want %q", got, command)
}
case <-time.After(time.Second):
t.Fatalf("missing focus request %q", command)
}
}
}
func TestRunRoutesPreferencesWithoutStoppingCommandHandling(t *testing.T) { func TestRunRoutesPreferencesWithoutStoppingCommandHandling(t *testing.T) {
settings := config.DefaultSettings() settings := config.DefaultSettings()
settings.EnableSpeech = false settings.EnableSpeech = false
+2 -2
View File
@@ -29,7 +29,7 @@ import (
// Each done channel closes after its worker exits. commandError is read after // Each done channel closes after its worker exits. commandError is read after
// commandDone closes, so no additional synchronization is needed. // commandDone closes, so no additional synchronization is needed.
type runtimeWorkers struct { type runtimeWorkers struct {
reviewRequests <-chan struct{} reviewRequests <-chan input.Command
attributeRequests <-chan struct{} attributeRequests <-chan struct{}
commandDone <-chan struct{} commandDone <-chan struct{}
clipboardDone <-chan struct{} clipboardDone <-chan struct{}
@@ -53,7 +53,7 @@ func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListe
clipboardRequests := make(chan struct{}, 1) clipboardRequests := make(chan struct{}, 1)
clipboardDone := runClipboardWorker(ctx, clipboardRequests, speech, readClipboardMessage) clipboardDone := runClipboardWorker(ctx, clipboardRequests, speech, readClipboardMessage)
command.clipboardRequests = clipboardRequests command.clipboardRequests = clipboardRequests
reviewRequests := make(chan struct{}, 1) reviewRequests := make(chan input.Command, 1)
command.reviewRequests = reviewRequests command.reviewRequests = reviewRequests
attributeRequests := make(chan struct{}, 1) attributeRequests := make(chan struct{}, 1)
command.attributeRequests = attributeRequests command.attributeRequests = attributeRequests
+19
View File
@@ -4,6 +4,11 @@
package atspi package atspi
import (
"strings"
"unicode/utf8"
)
// LibreOfficeContext is bounded, copied metadata from the native owner. It // LibreOfficeContext is bounded, copied metadata from the native owner. It
// describes Calc's cell and temporary editor objects, without native handles. // describes Calc's cell and temporary editor objects, without native handles.
// Presentation and application policy must not query the provider themselves. // Presentation and application policy must not query the provider themselves.
@@ -15,6 +20,7 @@ type LibreOfficeContext struct {
ContainerFocused bool ContainerFocused bool
CellText string CellText string
Coordinates string Coordinates string
Formula string
} }
// IsLibreOfficeProcess uses /proc identity verified by the adapter. An // IsLibreOfficeProcess uses /proc identity verified by the adapter. An
@@ -22,3 +28,16 @@ type LibreOfficeContext struct {
func IsLibreOfficeProcess(process string) bool { func IsLibreOfficeProcess(process string) bool {
return process == "soffice.bin" return process == "soffice.bin"
} }
// Preserve spaces inside formula string literals and keep the beginning when
// bounding long expressions. Text context's default suffix is for caret lines.
func sanitizeLibreOfficeFormula(value string) string {
value = strings.ToValidUTF8(value, "�")
if len(value) > maxSnapshotFieldLength {
value = value[:maxSnapshotFieldLength]
for !utf8.ValidString(value) {
value = value[:len(value)-1]
}
}
return sanitizeTextContext(value)
}
+13
View File
@@ -120,6 +120,9 @@ func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) {
grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
if context.Cell { if context.Cell {
context.Coordinates = snapshot.Name context.Coordinates = snapshot.Name
// Orca requests uncached attributes: editing can replace a formula
// without replacing the accessible cell.
C.atspi_accessible_clear_cache_single(source)
attributes := C.atspi_accessible_get_attributes(source, nil) attributes := C.atspi_accessible_get_attributes(source, nil)
if attributes != nil { if attributes != nil {
columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext") columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext")
@@ -130,6 +133,16 @@ func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) {
if column != "" && row != "" { if column != "" && row != "" {
context.Coordinates = sanitizeSnapshotField(column + row) context.Coordinates = sanitizeSnapshotField(column + row)
} }
// Current providers use lowercase; Orca also accepts Formula.
for _, key := range []string{"formula", "Formula"} {
name := C.CString(key)
value := C.g_hash_table_lookup(attributes, C.gconstpointer(name))
C.free(unsafe.Pointer(name))
if value != nil {
context.Formula = sanitizeLibreOfficeFormula(C.GoString((*C.gchar)(value)))
break
}
}
C.g_hash_table_unref(attributes) C.g_hash_table_unref(attributes)
} }
text := C.atspi_accessible_get_text_iface(source) text := C.atspi_accessible_get_text_iface(source)
+23
View File
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"strings"
"testing"
"unicode/utf8"
)
func TestLibreOfficeFormulaPreservesLiteralSpacesAndBoundedPrefix(t *testing.T) {
formula := `="two spaces"`
if got := sanitizeLibreOfficeFormula(formula); got != formula {
t.Fatalf("formula literal changed: %q", got)
}
formula += strings.Repeat("猫", maxSnapshotFieldLength)
got := sanitizeLibreOfficeFormula(formula)
if len(got) > maxSnapshotFieldLength || !utf8.ValidString(got) || !strings.HasPrefix(formula, got) {
t.Fatalf("invalid bounded formula prefix: bytes=%d", len(got))
}
}
+3 -1
View File
@@ -53,6 +53,8 @@ func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings {
func CommandLabel(command Command) string { func CommandLabel(command Command) string {
switch command { switch command {
case PresentCellFormula:
return "Present the formula for the current cell"
case OCRObject: case OCRObject:
return "Recognize object of interest" return "Recognize object of interest"
case OCRWindow: case OCRWindow:
@@ -164,7 +166,7 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo
groups[command] = append(groups[command], FormatCommandChord(chord)) groups[command] = append(groups[command], FormatCommandChord(chord))
} }
if scope == "global" { if scope == "global" {
for _, command := range []Command{PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} { for _, command := range []Command{PresentCellFormula, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
if _, exists := groups[command]; !exists { if _, exists := groups[command]; !exists {
groups[command] = nil groups[command] = nil
} }
+2
View File
@@ -107,6 +107,7 @@ const (
Preferences Command = "preferences" Preferences Command = "preferences"
ApplicationPreferences Command = "application-preferences" ApplicationPreferences Command = "application-preferences"
ChooseApplicationPreferences Command = "choose-application-preferences" ChooseApplicationPreferences Command = "choose-application-preferences"
PresentCellFormula Command = "present-cell-formula"
WhereAmI Command = "where-am-i" WhereAmI Command = "where-am-i"
PresentCharacterAttributes Command = "present-character-attributes" PresentCharacterAttributes Command = "present-character-attributes"
PresentLastNotification Command = "present-last-notification" PresentLastNotification Command = "present-last-notification"
@@ -228,6 +229,7 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings {
bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] = OCRWindow bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] = OCRWindow
bindings[CommandChord{Symbol: 0xffab}] = ReadAll // KP_Add bindings[CommandChord{Symbol: 0xffab}] = ReadAll // KP_Add
bindings[CommandChord{Symbol: keypadEnterKeySymbol}] = WhereAmI bindings[CommandChord{Symbol: keypadEnterKeySymbol}] = WhereAmI
bindings[CommandChord{Symbol: '=', CthulhuModifier: true}] = PresentCellFormula
bindings[CommandChord{Symbol: 0xff95, CthulhuModifier: true}] = ReviewWindowStart bindings[CommandChord{Symbol: 0xff95, CthulhuModifier: true}] = ReviewWindowStart
bindings[CommandChord{Symbol: 0xff9a, CthulhuModifier: true}] = ReviewWindowEnd bindings[CommandChord{Symbol: 0xff9a, CthulhuModifier: true}] = ReviewWindowEnd
bindings[CommandChord{Symbol: 0xff96, CthulhuModifier: true}] = ReviewWordAbove bindings[CommandChord{Symbol: 0xff96, CthulhuModifier: true}] = ReviewWordAbove
+19
View File
@@ -115,6 +115,25 @@ func TestLaptopBindingRoutesWhereAmI(t *testing.T) {
} }
} }
func TestCellFormulaBindingAndLaptopOverride(t *testing.T) {
chord := CommandChord{Symbol: '=', CthulhuModifier: true}
if got := DefaultCommandBindings(DesktopKeyboardLayout)[chord]; got != PresentCellFormula {
t.Fatalf("desktop formula binding = %q", got)
}
if got := DefaultCommandBindings(LaptopKeyboardLayout)[chord]; got != "" {
t.Fatalf("laptop formula should be unbound by default: %q", got)
}
overrides := []BindingOverride{{Layout: LaptopKeyboardLayout, Scope: "global",
Command: PresentCellFormula, Keys: []string{"Cthulhu+="}}}
if err := ValidateBindingOverrides(overrides); err != nil {
t.Fatal(err)
}
bindings, err := ResolveCommandBindings(LaptopKeyboardLayout, false, overrides)
if err != nil || bindings[chord] != PresentCellFormula {
t.Fatalf("laptop formula override = %q, %v", bindings[chord], err)
}
}
func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) { func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
bindings := DefaultCommandBindings(layout) bindings := DefaultCommandBindings(layout)
@@ -705,7 +705,7 @@ func TestNativeDispatchBeforeRelease(t *testing.T) {
{[]string{"Insert", "a"}, input.ToggleDocumentMode}, {[]string{"Insert", "a"}, input.ToggleDocumentMode},
} }
if layout == input.DesktopKeyboardLayout { if layout == input.DesktopKeyboardLayout {
cases = append(cases, bindingCase{[]string{"KP_Add"}, input.ReadAll}) cases = append(cases, bindingCase{[]string{"KP_Add"}, input.ReadAll}, bindingCase{[]string{"Insert", "equal"}, input.PresentCellFormula})
cases = append(cases, bindingCase{[]string{"KP_Enter"}, input.WhereAmI}, bindingCase{[]string{"KP_Up"}, input.ReviewCurrentLine}) cases = append(cases, bindingCase{[]string{"KP_Enter"}, input.WhereAmI}, bindingCase{[]string{"KP_Up"}, input.ReviewCurrentLine})
cases = append(cases, cases = append(cases,
bindingCase{[]string{"KP_Down"}, input.ReviewCurrentCharacter}, bindingCase{[]string{"KP_Down"}, input.ReviewCurrentCharacter},
+27
View File
@@ -49,3 +49,30 @@ func libreOfficeFocusSnapshot(snapshot atspi.Snapshot, options Options) atspi.Sn
} }
return snapshot return snapshot
} }
// LibreOfficeCellFormula limits Office presentation to verified Calc cells.
func LibreOfficeCellFormula(snapshot atspi.Snapshot) string {
context := snapshot.LibreOffice
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Defunct ||
snapshot.Role != "table cell" || context == nil || !context.InSpreadsheet || !context.Cell {
return ""
}
return context.Formula
}
// LibreOfficeFormulaText follows Orca's explicit formula command: non-formula
// cells report their value, including "empty" when there is no value.
func LibreOfficeFormulaText(snapshot atspi.Snapshot) string {
context := snapshot.LibreOffice
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Defunct ||
snapshot.Role != "table cell" || context == nil || !context.InSpreadsheet || !context.Cell {
return "Not in a table."
}
if formula := LibreOfficeCellFormula(snapshot); formula != "" {
return formula
}
if strings.TrimSpace(context.CellText) != "" {
return context.CellText
}
return "empty"
}
+46
View File
@@ -44,3 +44,49 @@ func TestLibreOfficePresentationRequiresVerifiedCellContext(t *testing.T) {
} }
} }
} }
func TestLibreOfficeFormulaPresentation(t *testing.T) {
for _, value := range []string{"43", ""} {
snapshot := atspi.Snapshot{Role: "table cell", ApplicationProcess: "soffice.bin",
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true,
Coordinates: "C1", CellText: value, Formula: "=B1+1"}}
want := value + ", C1, has formula"
if value == "" {
want = "blank, C1, has formula"
}
if got := FocusText(snapshot); got != want {
t.Fatalf("formula cell = %q, want %q", got, want)
}
if got := LibreOfficeFormulaText(snapshot); got != "=B1+1" {
t.Fatalf("formula command = %q", got)
}
options := DefaultOptions()
reading := config.DefaultSettings().Reading
reading.OnlySpeakDisplayedText = true
options.Reading = &reading
if got := FocusTextWithOptions(snapshot, options); got != value {
t.Fatalf("displayed text includes formula metadata: %q", got)
}
snapshot.LibreOffice.Formula = ""
want = value
if value == "" {
want = "empty"
}
if got := LibreOfficeFormulaText(snapshot); got != want {
t.Fatalf("non-formula command = %q, want %q", got, want)
}
}
for _, snapshot := range []atspi.Snapshot{
{},
{Role: "paragraph", ApplicationProcess: "soffice.bin"},
{Role: "table cell", ApplicationProcess: "chromium", LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, Formula: "wrong"}},
{Role: "table cell", Defunct: true, ApplicationProcess: "soffice.bin", LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, Formula: "wrong"}},
} {
if got := LibreOfficeFormulaText(snapshot); got != "Not in a table." {
t.Fatalf("unverified cell = %q", got)
}
if got := LibreOfficeCellFormula(snapshot); got != "" {
t.Fatalf("unverified formula = %q", got)
}
}
}
+3
View File
@@ -173,6 +173,9 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech {
if reading.SpeakMisspelledIndicator && snapshot.Misspelled { if reading.SpeakMisspelledIndicator && snapshot.Misspelled {
fields = append(fields, "misspelled") fields = append(fields, "misspelled")
} }
if LibreOfficeCellFormula(snapshot) != "" {
fields = append(fields, "has formula")
}
for _, part := range fields { for _, part := range fields {
if part != "" && !contains(parts, part) { if part != "" && !contains(parts, part) {
parts = append(parts, part) parts = append(parts, part)
+1 -1
View File
@@ -54,5 +54,5 @@ web-browse-selection,src/cthulhu/scripts/web/script.py;src/cthulhu/script_utilit
web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md. web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md.
web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole,web,Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation,Storm Dragon,internal/atspi internal/review internal/app,in-progress,"go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py",Private Brave/Xorg named-link and key checks passed,,Fixture speech verified; real Steam listening pending,Named-link browse braille uses the full accessible label; physical check pending,docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole,web,Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation,Storm Dragon,internal/atspi internal/review internal/app,in-progress,"go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py",Private Brave/Xorg named-link and key checks passed,,Fixture speech verified; real Steam listening pending,Named-link browse braille uses the full accessible label; physical check pending,docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates
steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging
libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md.
steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md. steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
54 web-browse-braille-line src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py braille Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor internal/atspi internal/review internal/app implemented go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending. Existing browse speech assertions pass in the private fixture. Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending. Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md.
55 web-explicit-link-names src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole web Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation Storm Dragon internal/atspi internal/review internal/app in-progress go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py Private Brave/Xorg named-link and key checks passed Fixture speech verified; real Steam listening pending Named-link browse braille uses the full accessible label; physical check pending docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates
56 steam-notifications src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py web Coalesce verified Steam background toasts and present once through speech braille and notification history Storm Dragon internal/webscripts internal/atspi internal/app in-progress go test -race ./...; python3 tests/native_webscripts.py Private GTK/AT-SPI process matching and alert-root capture passed Event-loop background delivery and Chromium isolation passed; real Steam toast pending Event-loop flash verified; physical Steam toast display pending docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging
57 libreoffice-calc-basic Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py libreoffice Present Calc cells and recover logical focus around editing and sheet changes Storm Dragon internal/app internal/atspi internal/presentation in-progress GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes. Recorded speech assertions pass; audible and physical-input acceptance pending. Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending. Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md.
58 steam-shader-progress Steam shader dialog AT-SPI events web Report Steam Vulkan shader percentages through shared progress output internal/webscripts internal/app in-progress go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py Private GTK and Brave HTML/ARIA progress checks passed. Tone requests verified; live Steam listening pending. Unrelated Braille ownership covered by unit tests; physical check pending. English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md.
+4
View File
@@ -2,6 +2,7 @@
<office:document <office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
office:version="1.2" office:version="1.2"
office:mimetype="application/vnd.oasis.opendocument.spreadsheet"> office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
@@ -11,6 +12,9 @@
<table:table-row> <table:table-row>
<table:table-cell office:value-type="string"><text:p>Alpha</text:p></table:table-cell> <table:table-cell office:value-type="string"><text:p>Alpha</text:p></table:table-cell>
<table:table-cell office:value-type="float" office:value="42"><text:p>42</text:p></table:table-cell> <table:table-cell office:value-type="float" office:value="42"><text:p>42</text:p></table:table-cell>
<table:table-cell table:formula="of:=[.B1]+1" office:value-type="float" office:value="43"><text:p>43</text:p></table:table-cell>
<table:table-cell table:formula='of:=&quot;&quot;' office:value-type="string"><text:p/></table:table-cell>
<table:table-cell table:formula='of:=&quot;two spaces&quot;' office:value-type="string" office:string-value="two spaces"><text:p>two spaces</text:p></table:table-cell>
</table:table-row> </table:table-row>
<table:table-row> <table:table-row>
<table:table-cell/> <table:table-cell/>