More work on LibreOffice support.
This commit is contained in:
1 parent
e0c8c6198c
commit
4ac59d4ced
25 files changed
+390
-55
No files matched your search
+64
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-11
@@ -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:
|
||||
case command := <-reviewRequests:
|
||||
if command == input.PresentCellFormula {
|
||||
reviewHandler.presentCellFormula()
|
||||
} else {
|
||||
reviewHandler.whereAmI()
|
||||
}
|
||||
case <-attributeRequests:
|
||||
if !focusGate.Locked() {
|
||||
syncForeground()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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." {
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
@@ -2,6 +2,7 @@
|
||||
<office:document
|
||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office: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"
|
||||
office:version="1.2"
|
||||
office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
|
||||
@@ -11,6 +12,9 @@
|
||||
<table:table-row>
|
||||
<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 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:=""' office:value-type="string"><text:p/></table:table-cell>
|
||||
<table:table-cell table:formula='of:="two spaces"' 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-cell/>
|
||||
|
||||
Reference in new issue
Block a user