diff --git a/LIBREOFFICE_SUPPORT.md b/LIBREOFFICE_SUPPORT.md index 1ed22d3..819b333 100644 --- a/LIBREOFFICE_SUPPORT.md +++ b/LIBREOFFICE_SUPPORT.md @@ -61,31 +61,58 @@ References inspected: Orca commit `cd58a88ed` (2026-10-14), `speech_generator.py`, `braille_generator.py`, and shared `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 -1. Formula presence and an on-demand formula command, using current Orca's - `where_am_i_presenter.py:present_cell_formula` and cell attributes. -2. Selected/unselected ranges, rows, columns, and Select All. Represent ranges +1. Selected/unselected ranges, rows, columns, and Select All. Represent ranges compactly; do not expand every selected cell as Orca's current comparison 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 the soffice directory. Spreadsheet full-row reading also needs a bounded, 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. Merged cells, overflow announcements, large-sheet responsiveness, other 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 ```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 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, @@ -93,7 +120,11 @@ document, virtual X11 display, and private session/accessibility buses. It exercises the production native adapter and event loop; recorded speech and Braille are asserted for cell movement and blanks, F2 typing, Escape 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. 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. 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. -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. -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. -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. 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 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 A normal-profile startup under i3 can leave Calc accepting arrow keys while diff --git a/internal/app/browser_events_test.go b/internal/app/browser_events_test.go index 72b30a3..998d248 100644 --- a/internal/app/browser_events_test.go +++ b/internal/app/browser_events_test.go @@ -452,7 +452,7 @@ func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - where := make(chan struct{}) + where := make(chan input.Command) browser := newBrowserModeController(nil, &modeKeys{}, nil) browser.ready, browser.mode = true, review.BrowseMode done := make(chan struct{}) @@ -472,7 +472,7 @@ func TestDelayedDocumentCaretCannotOverrideEditableFocus(t *testing.T) { adapter.events <- atspi.NewEvent(atspi.CaretEvent, oldHeading) // The event loop must finish processing the caret before it can receive // this command. Join before inspecting controller state. - where <- struct{}{} + where <- input.WhereAmI cancel() <-done if browser.focus != entry.ID() || browser.generation != 1 { @@ -524,7 +524,7 @@ func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - where := make(chan struct{}) + where := make(chan input.Command) keys := &modeKeys{} browser := newBrowserModeController(nil, keys, nil) done := make(chan struct{}) @@ -551,7 +551,7 @@ func TestBackgroundDocumentLoadPreservesChromeFocus(t *testing.T) { load := atspi.NewEvent(atspi.DocumentLoadCompleteEvent, document) load.FocusOutsideDocument = entry.ID() adapter.events <- load - where <- struct{}{} + where <- input.WhereAmI expect("Address, autocomplete") // A subsequent load with document focus must still announce and acquire // 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()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - where := make(chan struct{}) + where := make(chan input.Command) keys := &modeKeys{} browser := newBrowserModeController(nil, keys, nil) done := make(chan struct{}) @@ -635,7 +635,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) { if tc.start == atspi.BusyStateEvent { expectActivationSpeech(t, queue.spoken, "Loading. Please wait.") } - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "Example page, document web") if browser.focus.Valid() || browser.ready || keys.enabled { t.Fatal("loading retained browse focus") @@ -646,7 +646,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) { if tc.end == atspi.BusyStateEvent { expectActivationSpeech(t, queue.spoken, "Finished loading New page.") } - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "New page, document web") if browser.focus != end.Source.ID() || !browser.resolving { t.Fatalf("load completion did not schedule document recovery: %+v", browser.focus) @@ -655,7 +655,7 @@ func TestDocumentLifecycleRecoversBrowseFocusWithoutLoadComplete(t *testing.T) { dead := focus dead.Type, dead.Detail1 = atspi.DefunctStateEvent, 1 adapter.events <- dead - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "New page, document web") }) } @@ -665,7 +665,7 @@ func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - where := make(chan struct{}) + where := make(chan input.Command) browser := newBrowserModeController(nil, &modeKeys{}, nil) done := make(chan struct{}) go func() { @@ -683,7 +683,7 @@ func TestDocumentLifecycleKeepsChromeFocus(t *testing.T) { event.FocusOutsideDocument = entry.Source.ID() adapter.events <- event } - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "Address, entry") cancel() <-done @@ -696,7 +696,7 @@ func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - where := make(chan struct{}) + where := make(chan input.Command) keys := &modeKeys{} browser := newBrowserModeController(nil, keys, nil) done := make(chan struct{}) @@ -718,7 +718,7 @@ func TestDocumentBusyCompletionKeepsVerifiedMessageListFocus(t *testing.T) { busy.Type, busy.Detail1 = atspi.BusyStateEvent, 0 busy.FocusOutsideDocument = atspi.ObjectID{} adapter.events <- busy - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "Message one, table row") 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) @@ -761,7 +761,7 @@ func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - where := make(chan struct{}) + where := make(chan input.Command) browser := newBrowserModeController(nil, &modeKeys{}, nil) done := make(chan struct{}) go func() { @@ -793,7 +793,7 @@ func TestDocumentLoadRespectsWindowAndFocusOwnership(t *testing.T) { adapter.events <- dead } // The unbuffered command follows event processing, without a sleep. - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, test.want) cancel() <-done diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 02b7ef8..ac2d200 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -33,7 +33,7 @@ func processEventsWithReview( logger *slog.Logger, speech speechEnqueuer, adapter Adapter, - reviewRequests <-chan struct{}, + reviewRequests <-chan input.Command, debounce time.Duration, configuredPresentation ...presentation.Options, ) error { @@ -45,12 +45,12 @@ func processEventsWithObjectReview( logger *slog.Logger, speech speechEnqueuer, adapter Adapter, - whereAmIRequests <-chan struct{}, + reviewRequests <-chan input.Command, objectReview *review.Worker, debounce time.Duration, configuredPresentation ...presentation.Options, ) 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( @@ -58,13 +58,13 @@ func processEventsWithSettings( logger *slog.Logger, speech speechEnqueuer, adapter Adapter, - whereAmIRequests <-chan struct{}, + reviewRequests <-chan input.Command, objectReview *review.Worker, debounce time.Duration, live *liveSettings, configuredPresentation ...presentation.Options, ) 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( @@ -72,7 +72,7 @@ func processEventsWithInput( logger *slog.Logger, speech speechEnqueuer, adapter Adapter, - whereAmIRequests <-chan struct{}, + reviewRequests <-chan input.Command, objectReview *review.Worker, debounce time.Duration, live *liveSettings, @@ -81,7 +81,7 @@ func processEventsWithInput( notifications *notification.Presenter, configuredPresentation ...presentation.Options, ) 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( @@ -89,7 +89,7 @@ func processEventsWithOCR( logger *slog.Logger, speech speechEnqueuer, adapter Adapter, - whereAmIRequests <-chan struct{}, + reviewRequests <-chan input.Command, objectReview *review.Worker, debounce time.Duration, live *liveSettings, @@ -99,7 +99,7 @@ func processEventsWithOCR( ocrSession *ocrSession, configuredPresentation ...presentation.Options, ) 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( @@ -107,7 +107,7 @@ func processEventsWithAttributes( logger *slog.Logger, speech speechEnqueuer, adapter Adapter, - whereAmIRequests <-chan struct{}, + reviewRequests <-chan input.Command, attributeRequests <-chan struct{}, objectReview *review.Worker, debounce time.Duration, @@ -316,8 +316,12 @@ func processEventsWithAttributes( } event, _ := state.pending.terminalDeletion.take() speak(event) - case <-whereAmIRequests: - reviewHandler.whereAmI() + case command := <-reviewRequests: + if command == input.PresentCellFormula { + reviewHandler.presentCellFormula() + } else { + reviewHandler.whereAmI() + } case <-attributeRequests: if !focusGate.Locked() { syncForeground() diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index 8671a7b..d74b5fa 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -45,6 +45,9 @@ func (handler *eventReviewHandler) whereAmI() { reviewOptions := state.presentationOptions reviewOptions.EnableSound = false spoken := presentation.FocusSpeechWithOptions(state.current, reviewOptions) + if formula := presentation.LibreOfficeCellFormula(state.current); formula != "" { + spoken = spoken.Append(formula, ", ") + } text := spoken.Text role := state.current.Role if text == "" { diff --git a/internal/app/foreground_test.go b/internal/app/foreground_test.go index 37996a3..8866a46 100644 --- a/internal/app/foreground_test.go +++ b/internal/app/foreground_test.go @@ -160,7 +160,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) { state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} a.state.Store(&state) queue := &fakeSpeechQueue{spoken: make(chan string, 16)} - where := make(chan struct{}) + where := make(chan input.Command) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { @@ -180,12 +180,12 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) { // atomic epoch even if the channel notification has not been selected. event.Source.Name = "Stale" a.events <- event - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "Could not find current location.") event.Foreground = next event.ForegroundMismatch = true a.events <- event - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "Could not find current location.") if window != 0 { event.ForegroundMismatch = false @@ -195,7 +195,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) { deactivated := event deactivated.Type = atspi.WindowDeactivatedEvent a.events <- deactivated - where <- struct{}{} + where <- input.WhereAmI expectActivationSpeech(t, queue.spoken, "New, push button") } }) diff --git a/internal/app/learn_mode.go b/internal/app/learn_mode.go index 21fe856..71ed9fd 100644 --- a/internal/app/learn_mode.go +++ b/internal/app/learn_mode.go @@ -171,6 +171,8 @@ func learnCommandDescription(command input.Command) string { return "Choose an application and open its preferences." case input.WhereAmI: return "Speak details of the current item." + case input.PresentCellFormula: + return "Present the formula for the current cell." case input.PresentCharacterAttributes: return "Read the current character's font and formatting attributes." case input.ReadAll: diff --git a/internal/app/libreoffice.go b/internal/app/libreoffice.go index 3e3b94a..5e0346a 100644 --- a/internal/app/libreoffice.go +++ b/internal/app/libreoffice.go @@ -8,6 +8,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/presentation" ) // 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, 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"}) +} diff --git a/internal/app/libreoffice_native_test.go b/internal/app/libreoffice_native_test.go index 3c7626d..a4719d8 100644 --- a/internal/app/libreoffice_native_test.go +++ b/internal/app/libreoffice_native_test.go @@ -16,6 +16,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" ) @@ -49,12 +50,13 @@ func TestNativeLibreOfficeCalc(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 256)} frames := make(chan braille.Frame, 256) done := make(chan error, 1) + commands := make(chan input.Command, 1) var logBuffer bytes.Buffer logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug})) options := presentation.DefaultOptions() options.EnableEchoByCharacter = true 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() { cancel() @@ -168,4 +170,32 @@ func TestNativeLibreOfficeCalc(t *testing.T) { press("ctrl+F6", "Down") waitSpeech("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") } diff --git a/internal/app/libreoffice_test.go b/internal/app/libreoffice_test.go index b4ac603..4a42bbe 100644 --- a/internal/app/libreoffice_test.go +++ b/internal/app/libreoffice_test.go @@ -26,6 +26,42 @@ func calcTestEvent(eventType, path, value, coordinate string) atspi.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) { state := eventLoopState{} state.presentationOptions = presentation.DefaultOptions() diff --git a/internal/app/object_review_events_test.go b/internal/app/object_review_events_test.go index d99b6ff..7b17f82 100644 --- a/internal/app/object_review_events_test.go +++ b/internal/app/object_review_events_test.go @@ -24,6 +24,7 @@ import ( "time" "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/review" ) @@ -34,7 +35,7 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) { interrupt: make(chan struct{}, 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()) defer cancel() completed := make(chan error, 1) @@ -65,7 +66,7 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) { case <-time.After(time.Second): t.Fatal("focus was not presented") } - reviewRequests <- struct{}{} + reviewRequests <- input.WhereAmI select { case <-speech.interrupt: case <-time.After(time.Second): @@ -93,7 +94,7 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) { func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event)} - reviewRequests := make(chan struct{}, 1) + reviewRequests := make(chan input.Command, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan error, 1) @@ -107,7 +108,7 @@ func TestProcessEventsWhereAmIWithoutFocus(t *testing.T) { 0, ) }() - reviewRequests <- struct{}{} + reviewRequests <- input.WhereAmI select { case text := <-speech.spoken: if text != "Could not find current location." { diff --git a/internal/app/runtime_commands.go b/internal/app/runtime_commands.go index e22145b..8db8ec7 100644 --- a/internal/app/runtime_commands.go +++ b/internal/app/runtime_commands.go @@ -51,7 +51,7 @@ type commandRuntime struct { onNotificationList func() live *liveSettings clipboardRequests chan<- struct{} - reviewRequests chan<- struct{} + reviewRequests chan<- input.Command attributeRequests chan<- struct{} objectReview *review.Worker ocr *ocrSession @@ -228,10 +228,14 @@ func (worker commandRuntime) run(ctx context.Context) error { if onNotificationList != nil { onNotificationList() } - case input.WhereAmI: - logger.Info("where am I command received") + case input.WhereAmI, input.PresentCellFormula: + if command == input.WhereAmI { + logger.Info("where am I command received") + } else { + logger.Info("cell formula command received") + } select { - case reviewRequests <- struct{}{}: + case reviewRequests <- command: default: } case input.PresentCharacterAttributes: diff --git a/internal/app/runtime_commands_test.go b/internal/app/runtime_commands_test.go index 4489ed9..df98190 100644 --- a/internal/app/runtime_commands_test.go +++ b/internal/app/runtime_commands_test.go @@ -28,6 +28,38 @@ import ( "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) { settings := config.DefaultSettings() settings.EnableSpeech = false diff --git a/internal/app/runtime_workers.go b/internal/app/runtime_workers.go index e4a7e9c..3c5dc68 100644 --- a/internal/app/runtime_workers.go +++ b/internal/app/runtime_workers.go @@ -29,7 +29,7 @@ import ( // Each done channel closes after its worker exits. commandError is read after // commandDone closes, so no additional synchronization is needed. type runtimeWorkers struct { - reviewRequests <-chan struct{} + reviewRequests <-chan input.Command attributeRequests <-chan struct{} commandDone <-chan struct{} clipboardDone <-chan struct{} @@ -53,7 +53,7 @@ func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListe clipboardRequests := make(chan struct{}, 1) clipboardDone := runClipboardWorker(ctx, clipboardRequests, speech, readClipboardMessage) command.clipboardRequests = clipboardRequests - reviewRequests := make(chan struct{}, 1) + reviewRequests := make(chan input.Command, 1) command.reviewRequests = reviewRequests attributeRequests := make(chan struct{}, 1) command.attributeRequests = attributeRequests diff --git a/internal/atspi/libreoffice.go b/internal/atspi/libreoffice.go index fecaef1..b0dc1b4 100644 --- a/internal/atspi/libreoffice.go +++ b/internal/atspi/libreoffice.go @@ -4,6 +4,11 @@ package atspi +import ( + "strings" + "unicode/utf8" +) + // LibreOfficeContext is bounded, copied metadata from the native owner. It // describes Calc's cell and temporary editor objects, without native handles. // Presentation and application policy must not query the provider themselves. @@ -15,6 +20,7 @@ type LibreOfficeContext struct { ContainerFocused bool CellText string Coordinates string + Formula string } // IsLibreOfficeProcess uses /proc identity verified by the adapter. An @@ -22,3 +28,16 @@ type LibreOfficeContext struct { func IsLibreOfficeProcess(process string) bool { 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) +} diff --git a/internal/atspi/libreoffice_native.go b/internal/atspi/libreoffice_native.go index e5fe042..4dc6648 100644 --- a/internal/atspi/libreoffice_native.go +++ b/internal/atspi/libreoffice_native.go @@ -120,6 +120,9 @@ func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) { grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET if context.Cell { 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) if attributes != nil { columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext") @@ -130,6 +133,16 @@ func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) { if 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) } text := C.atspi_accessible_get_text_iface(source) diff --git a/internal/atspi/libreoffice_test.go b/internal/atspi/libreoffice_test.go new file mode 100644 index 0000000..cb96e34 --- /dev/null +++ b/internal/atspi/libreoffice_test.go @@ -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)) + } +} diff --git a/internal/input/bindings.go b/internal/input/bindings.go index 06ce6b9..e68dccc 100644 --- a/internal/input/bindings.go +++ b/internal/input/bindings.go @@ -53,6 +53,8 @@ func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings { func CommandLabel(command Command) string { switch command { + case PresentCellFormula: + return "Present the formula for the current cell" case OCRObject: return "Recognize object of interest" case OCRWindow: @@ -164,7 +166,7 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo groups[command] = append(groups[command], FormatCommandChord(chord)) } 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 { groups[command] = nil } diff --git a/internal/input/commands.go b/internal/input/commands.go index 5db6769..6219ee4 100644 --- a/internal/input/commands.go +++ b/internal/input/commands.go @@ -107,6 +107,7 @@ const ( Preferences Command = "preferences" ApplicationPreferences Command = "application-preferences" ChooseApplicationPreferences Command = "choose-application-preferences" + PresentCellFormula Command = "present-cell-formula" WhereAmI Command = "where-am-i" PresentCharacterAttributes Command = "present-character-attributes" PresentLastNotification Command = "present-last-notification" @@ -228,6 +229,7 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings { bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] = OCRWindow bindings[CommandChord{Symbol: 0xffab}] = ReadAll // KP_Add bindings[CommandChord{Symbol: keypadEnterKeySymbol}] = WhereAmI + bindings[CommandChord{Symbol: '=', CthulhuModifier: true}] = PresentCellFormula bindings[CommandChord{Symbol: 0xff95, CthulhuModifier: true}] = ReviewWindowStart bindings[CommandChord{Symbol: 0xff9a, CthulhuModifier: true}] = ReviewWindowEnd bindings[CommandChord{Symbol: 0xff96, CthulhuModifier: true}] = ReviewWordAbove diff --git a/internal/input/commands_test.go b/internal/input/commands_test.go index 1724302..2504878 100644 --- a/internal/input/commands_test.go +++ b/internal/input/commands_test.go @@ -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) { for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { bindings := DefaultCommandBindings(layout) diff --git a/internal/platform/xorg/listener_native_test.go b/internal/platform/xorg/listener_native_test.go index 3be5438..9a44dab 100644 --- a/internal/platform/xorg/listener_native_test.go +++ b/internal/platform/xorg/listener_native_test.go @@ -705,7 +705,7 @@ func TestNativeDispatchBeforeRelease(t *testing.T) { {[]string{"Insert", "a"}, input.ToggleDocumentMode}, } 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_Down"}, input.ReviewCurrentCharacter}, diff --git a/internal/presentation/libreoffice.go b/internal/presentation/libreoffice.go index a11e26b..dcbb68d 100644 --- a/internal/presentation/libreoffice.go +++ b/internal/presentation/libreoffice.go @@ -49,3 +49,30 @@ func libreOfficeFocusSnapshot(snapshot atspi.Snapshot, options Options) atspi.Sn } 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" +} diff --git a/internal/presentation/libreoffice_test.go b/internal/presentation/libreoffice_test.go index 5814120..56cc7a1 100644 --- a/internal/presentation/libreoffice_test.go +++ b/internal/presentation/libreoffice_test.go @@ -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) + } + } +} diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index 347a708..bb65381 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -173,6 +173,9 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech { if reading.SpeakMisspelledIndicator && snapshot.Misspelled { fields = append(fields, "misspelled") } + if LibreOfficeCellFormula(snapshot) != "" { + fields = append(fields, "has formula") + } for _, part := range fields { if part != "" && !contains(parts, part) { parts = append(parts, part) diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index b033683..b882bd3 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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-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 -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. diff --git a/tests/libreoffice.fods b/tests/libreoffice.fods index 31c1afa..dd782a3 100644 --- a/tests/libreoffice.fods +++ b/tests/libreoffice.fods @@ -2,6 +2,7 @@ @@ -11,6 +12,9 @@ Alpha 42 + 43 + + two spaces