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