6 Commits
66 changed files with 2548 additions and 129 deletions

No files matched your search

+133 -22
View File
@@ -22,13 +22,15 @@ presentation code never retain native objects or perform provider queries.
`internal/presentation/libreoffice.go` owns the corresponding speech
formatting; LibreOffice Braille formatting stays beside its controller.
The first implementation has no independent mutable application state:
logical focus belongs to the existing event loop. Future selections and
dynamic headers must be keyed by application bus, document, and sheet identity.
Retire them on document/sheet destruction or application exit. Focus loss
must cancel pending output while preserving document header choices; shutdown
and screen lock must retire pending operations. Add command routing when the
first real Office command requires it, using the existing input machinery.
Logical focus belongs to the existing event loop. Selection comparison keeps
one bounded snapshot for the focused sheet, keyed by its application bus and
accessible sheet path and admitted only within the owned document window.
It is retired on sheet/application focus changes and ordinary owned-focus
reset, including destruction, foreground loss, and screen lock. There are no
selection workers, timers, or retained native references. Future dynamic
headers must be keyed by application bus, document, and sheet identity;
focus loss must cancel output while preserving document header choices.
Use the existing input machinery for additional Office commands.
Application code must not bypass security/foreground gates or manage its own
speech connection, key grabs, or background event loop.
@@ -61,31 +63,94 @@ 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.
## Selection slice
- Shift+Arrow selection growth and shrinkage announce selected/unselected
coordinates. Adjacent cells form compact rectangles; disjoint cells retain
their gaps. The active anchor is left to ordinary cell presentation.
- Full rows and columns use row/column labels. Select All says “All cells
selected”; leaving a selection says “Selection cleared.” Ctrl+A can first
select the used area; press it again to select the entire sheet.
These announcements require a provider event. An independent AT-SPI observer
on LibreOffice 26.2.6.3 confirmed that Shift+Up after extending whole rows,
and Shift+Left after extending whole columns, change the selection without
emitting a selection event. Ctrl+Home can also clear those selections without
an event. Immediate announcements for those cases remain unsupported; the
next selection event refreshes the snapshot. The reader does not poll or
infer selection changes from keystrokes.
- Small selections capture at most 64 cells, rows, or columns. Messages with
more than eight rectangles use a count. Larger selections use a count and
two boundary samples for duplicate suppression, without treating endpoints
as proof of a filled rectangle. Overlapping count-only selections report
the new total rather than falsely announcing the entire old range unselected.
- The GTK bridge caps cell counts at 2147483647; partial-sheet selections at
that limit say “At least … cells selected.” Whole-sheet selection is checked
separately. Large selections with identical counts and boundary samples
cannot be distinguished by this bounded snapshot; interior-only changes may
therefore be suppressed. Full topology/delta reporting remains future work.
- Native capture uses a fixed number of count queries and at most 64 selected
positions. It requests no selected-row/column arrays. Ordinary small cell
selections skip full-row/column counts; LibreOffice itself scans its row
marks when larger selections need a selected-row count. Fixed RPC counts
do not guarantee a provider's response time.
- Selection events keep both logical and retained native focus on the active
cell. Formula commands, object review, cell Braille frames, and routing
continue to use that cell. Selection messages use the existing system-message
Braille flash path and preferences. Only-displayed-text mode suppresses them.
- Current Orca's selection callbacks and utilities guide the behavior. The
[LibreOffice spreadsheet provider](https://github.com/LibreOffice/core/blob/libreoffice-26-2/sc/source/ui/Accessibility/AccessibleSpreadsheet.cxx)
and [GTK selection bridge](https://github.com/LibreOffice/core/blob/libreoffice-26-2/vcl/unx/gtk3/a11y/atkselection.cxx)
were also inspected for selection ordering, row-count cost, and count limits.
## 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
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.
1. 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,
2. 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 +158,15 @@ 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.
Selection checks cover cell-range growth/shrinkage, clearing, row/column extension,
Select All, disjoint cells made through the provider selection
API, a 2097150-cell selection, and a partial-sheet selection above GTK's count
limit. Formula commands during selection verify preservation of the active cell.
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 +174,27 @@ 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. Use Shift+Arrows to grow and shrink a range; check selected/unselected
addresses and continued active-cell/formula output. Try Ctrl+Space,
Shift+Space, their Shift+Arrow extensions, and Ctrl+A twice. Clear each
selection with an arrow. Select disjoint areas with Ctrl+click and a large
area with Ctrl+Shift+Arrows; check gaps in small selections and count summaries
for large ones. Enable Braille flash messages and verify selection messages
restore the cell display. Enable only-displayed-text and verify selection
chatter is omitted. Repeat on another sheet and in a second workbook.
7. 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 +208,30 @@ 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.
Selection-slice verification passed the isolated real Calc harness (including
disjoint and capped selections), focused race tests, vet/build, parity-ledger
validation, and ordinary GTK table/multiselection checks. The full affected-package
race run again hit the startup timing test above; all remaining affected-package
race tests passed with
`go test -race ./internal/atspi ./internal/app ./internal/presentation -skip '^TestRunReachesSoundStopAfterEarlierServiceStopError$' -count=1`.
The whole-row/column contraction event gaps described above were independently
observed without the reader. Audible output, physical input/Braille, and
independent-workbook acceptance remain pending.
## Calc startup focus diagnosis
A normal-profile startup under i3 can leave Calc accepting arrow keys while
+44
View File
@@ -0,0 +1,44 @@
# Progress reporting
Progress bars use the configured Off, Beep, Speak, or Both output mode and
percentage thresholds. Background reporting is optional. Updates do not take
focus or replace Braille for an unrelated object.
Native HTML `<progress>` and ARIA `progressbar` controls use their AT-SPI
progress role and value events. Steam's shader dialog instead exposes changing
static text such as `Processing Vulkan shaders (37%)`. The Steam webscript
recognizes that English status under the verified `steamwebhelper` process and
passes its percentage to the shared progress handler. Other text, containing
sections, and ordinary browser processes do not use this exception. Translated
Steam shader messages and other custom text-only progress widgets are not
covered by this recognition.
## Automated checks
```sh
GOCACHE=/tmp/cthulhu-go-cache 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
```
The isolated GTK test checks foreground, background, and hidden progress. The
isolated browser test drives real HTML and ARIA controls in a Chromium-family
browser and verifies the resulting tone requests. Unit tests cover Steam text
recognition, output modes, duplicate suppression, percentage thresholds,
security, stale events, background settings, and unrelated Braille ownership.
These checks do not establish audible output from the running reader.
## Live review before submission
Rebuild and restart the reader, then launch a Steam game that needs shader
processing. With Beep selected and Steam in the foreground, confirm that pitch
rises as the percentage increases without navigating onto the status text.
With Speak or Both selected, verify the configured speech threshold. With Off
selected, verify that automatic progress output stops.
Switch to another application during processing: reporting should follow the
background-progress setting without moving focus or replacing that application's
Braille. Check ordinary Steam navigation and notifications afterward. Open
`tests/browser-progress.html` in a browser and activate Update progress to check
native and ARIA bars. Physical keyboard, listening, and Braille checks remain
separate acceptance gates.
+2
View File
@@ -5,6 +5,8 @@ Cthulhu is a Linux screen reader written in Go.
- [Repository Guidelines](AGENTS.md): layout, commands, and working safeguards.
- [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy.
- [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification.
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
+67
View File
@@ -0,0 +1,67 @@
# Steam keyboard focus on Xorg
Steam Big Picture can retain its accessible focus while physical keyboard
events reach the outer Steam window instead of its embedded Chromium window.
In the reported game/workspace round trip, Escape stopped opening or closing
the menu, and switching browse/focus mode did not help. A delivery trace showed
both Escape edges reaching the outer window with no held modifiers. Restoring
native focus to the browser child recovered the open menu without restarting
Steam or Cthulhu. The original trigger remains unverified.
Cthulhu's Xorg command owner remembers the most recently focused, mapped
browser child of a Steam webhelper window. During the first second after that
window becomes foreground again, it allows Steam to restore its own focus.
Only if two consecutive focus samples still identify the outer window does it
attempt to restore the remembered child. A successful repair ends the attempt
for that activation.
The repair verifies the foreground window, Steam window class, process IDs,
child ancestry, and mapping state immediately before changing focus. It leaves
valid inner focus alone, including menus, search fields, and dialogs. It does
not run with keys held, reader modal input active, OCR active or pending, or
secure input active. Hidden, destroyed, unrelated, or unverified targets cause
it to leave focus unchanged. It does not guess another child or retain a
hard-coded window ID. The cache holds one Steam window and expires with the
command listener; restarting Cthulhu in an already broken session may require
fresh Steam focus before a valid child can be remembered.
This is an Xorg Steam workaround. Other applications and native Wayland focus
handling do not receive automatic keyboard-focus changes.
## Verification
The policy tests cover activation grace, remembered popup focus, bounded
attempts, process/window boundaries, and preservation of valid browser focus.
The native fixture verifies real Escape press/release delivery after repair and
rejects hidden or destroyed popups, unrelated children, changed process IDs,
held modifiers, other applications, and ordinary window classes.
```sh
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/platform/xorg ./internal/app -count=1
xvfb-run -a env CTHULHU_NATIVE_STEAM_FOCUS=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/platform/xorg -run '^TestNativeSteamFocusRepair$' -count=1 -v
CTHULHU_NATIVE_BROWSER_TESTS=keys,modes python3 tests/native_browser.py
python3 tests/native_x11.py
```
These checks passed for the initial patch. The existing native browser editing
check failed on both the patch and unchanged commit `9205e72`: its first typed
`c` was accompanied by `Cthulhu Browser Probe - Brave`. That separate title
announcement failure was not changed here.
Human review and live Steam acceptance remain required before submission:
1. Restart Cthulhu using the normal `cthulhu-go.sh -r` launcher, then start fresh
Steam Big Picture so the reader can observe a valid browser focus.
2. Verify Escape and game activation initially work. Launch a game, switch to
another workspace and back, and verify Escape and game activation again.
3. Repeat with the Steam menu open before switching away. Check Escape closes
it and arrows/Enter still select commands.
4. Type in Steam search, use Tab and Shift+Tab, and move away/back while search
or a dialog has focus. Check text entry, selection, and dialog buttons retain
their intended focus.
5. Switch to a normal browser and a GTK application. Check native typing,
browser structural navigation, and Cthulhu's XTerm/Fenrir handoff. Quit
Cthulhu and confirm normal application keys remain available.
Do not count a native fixture pass as audible speech, physical Braille, or
successful operation of Steam's actual Chromium/controller navigation.
+5 -2
View File
@@ -16,13 +16,16 @@ type applicationEventResult struct {
// prepareApplicationEvent runs after security/foreground checks and before
// generic focus and text filters. Application policy receives copied semantics;
// the reader retains ownership, review, cancellation, and output services.
// The first handler is stateless: document-specific state can be owned here
// when a concrete feature needs it, rather than hidden in native callbacks.
// Application-specific state stays on the event loop with an explicit focus
// lifetime, rather than hidden in native callbacks.
func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) {
result := applicationEventResult{event: event}
if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) {
result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner)
}
if !result.suppress || event.Type == atspi.SelectionChangedEvent {
handler.handleLibreOfficeSelection(result.event)
}
if result.adoptFocus {
if handler.focusGate.Suspended() {
return event, false
+14 -14
View File
@@ -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
+21 -4
View File
@@ -282,11 +282,18 @@ func TestNativeBrowserEditing(t *testing.T) {
}
}()
defer func() { cancel(); <-relayDone }()
queue := &fakeSpeechQueue{spoken: make(chan string, 128)}
queue := &fakeSpeechQueue{spoken: make(chan string, 128), interrupt: make(chan struct{}, 128)}
logger := slog.New(slog.DiscardHandler)
inputDone := make(chan struct{})
go func() {
defer close(inputDone)
processInputEvents(ctx, logger, queue, listener, input.EchoSettings{}, nil, nil)
}()
defer func() { cancel(); <-inputDone }()
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil)
processEventsWithInput(ctx, logger, queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil)
}()
defer func() { cancel(); <-done }()
keys := func(chord string) {
@@ -315,11 +322,17 @@ func TestNativeBrowserEditing(t *testing.T) {
keys("ctrl+l")
collect()
for _, letter := range []string{"c", "t", "h"} {
for len(queue.interrupt) > 0 {
<-queue.interrupt
}
keys(letter)
got := collect()
if len(got) != 1 || got[0] != letter {
t.Fatalf("typing %q: %q", letter, got)
}
if len(queue.interrupt) == 0 {
t.Fatalf("typing %q did not interrupt speech", letter)
}
}
keys("Down")
got := collect()
@@ -380,9 +393,13 @@ func TestNativeBrowserEditing(t *testing.T) {
if len(got) != 1 || got[0] != "• Registered: September 2026" {
t.Fatalf("embedded browser line text: %q", got)
}
// Preserve the browser-specific suffix. AT-SPI can report this change
// before the native X11 title cache catches up.
wantTitle := strings.Replace(adapter.ForegroundTitle().Title.Title, "Cthulhu Browser Probe", "Cthulhu Browser Probe updated", 1)
keys("F8")
if got := collect(); len(got) != 0 || !browserNameChanged.Load() {
t.Fatalf("browser title update: speech=%q, native event=%v", got, browserNameChanged.Load())
got = collect()
if len(got) != 1 || got[0] != wantTitle || !strings.Contains(wantTitle, "Cthulhu Browser Probe updated") || !browserNameChanged.Load() {
t.Fatalf("browser title update: speech=%q, expected=%q, native event=%v", got, wantTitle, browserNameChanged.Load())
}
// Seed a typed browser URL locally, then replace it with a prefix. This
+24 -14
View File
@@ -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,
@@ -151,7 +151,7 @@ func processEventsWithAttributes(
foregroundChanges = foreground.ForegroundChanges()
state.foregroundState = foreground.Foreground()
}
titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground}
titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground, inputSource: inputSource}
titles.source, _ = adapter.(windowTitleSource)
var titleChanges <-chan struct{}
if titles.source != nil {
@@ -194,7 +194,13 @@ func processEventsWithAttributes(
if live != nil && live.wineReview != nil {
// Wine control revisions reset object review, but a pending title and
// its duplicate history belong to the foreground window's lifetime.
wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearObjectFocus}
// These notices carry metadata, not an instruction to stop speech.
// Foreground switches and user input already own speech cancellation.
clearWineReviewFocus := func() {
ocrSession.Retire()
state.clearObjectFocus(brailleOutput, browser, objectReview)
}
wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearWineReviewFocus}
wineReviewChanges = live.wineReview.Changes()
wineReview.changed()
defer wineReview.retire()
@@ -310,8 +316,12 @@ func processEventsWithAttributes(
}
event, _ := state.pending.terminalDeletion.take()
speak(event)
case <-whereAmIRequests:
reviewHandler.whereAmI()
case command := <-reviewRequests:
if command == input.PresentCellFormula {
reviewHandler.presentCellFormula()
} else {
reviewHandler.whereAmI()
}
case <-attributeRequests:
if !focusGate.Locked() {
syncForeground()
+3
View File
@@ -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 == "" {
+14 -6
View File
@@ -45,11 +45,12 @@ type eventForegroundState struct {
}
type eventFocusState struct {
current atspi.Snapshot
brailleSnapshot atspi.Snapshot
focusedID atspi.ObjectID
managedContainerID atspi.ObjectID
managedDescendantID atspi.ObjectID
libreOfficeSelection libreOfficeSelectionState
current atspi.Snapshot
brailleSnapshot atspi.Snapshot
focusedID atspi.ObjectID
managedContainerID atspi.ObjectID
managedDescendantID atspi.ObjectID
}
type eventDocumentState struct {
@@ -130,6 +131,14 @@ func (state *eventLoopState) ownedAutocompletePopup(event atspi.Event) bool {
// clearOwnedFocus retires only the state cleared by the former event-loop
// closure. Session presentation history and chrome load verification survive.
func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker, speech speechEnqueuer) {
state.clearObjectFocus(brailleOutput, browser, objectReview)
speech.Interrupt()
}
// Review metadata can change without new input or a foreground switch. Reset
// its navigation state independently so title announcements keep playing.
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
state.libreOfficeSelection = libreOfficeSelectionState{}
state.selection = textSelection{}
state.documentSelection = textSelection{}
state.lastClipboardSequence = 0
@@ -169,7 +178,6 @@ func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer,
if objectReview != nil {
objectReview.ClearFocus()
}
speech.Interrupt()
}
func (state *eventLoopState) syncForeground(foreground foregroundMonitor, logger *slog.Logger, clearOwnedFocus func()) {
+4 -4
View File
@@ -160,7 +160,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
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")
}
})
+2
View File
@@ -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:
+16 -2
View File
@@ -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.
@@ -45,8 +46,8 @@ func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner wi
}
if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent {
// Selection can name the first cell of a range rather than the
// active cell. Movement follows active-descendant events; range
// presentation belongs to the later selection-support slice.
// active cell. Dedicated selection presentation consumes copied
// sheet metadata without changing logical focus or review.
result.suppress = true
}
if context != nil && context.InSpreadsheet && event.Source.Role == "table" &&
@@ -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"})
}
+80 -2
View File
@@ -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()
@@ -81,7 +83,7 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
t.Fatalf("Calc capacity/state chatter: %q", got)
}
if got == want {
if got == want || strings.HasSuffix(got, ". "+want) {
return
}
case <-timeout.C:
@@ -168,4 +170,80 @@ 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")
press("ctrl+Home")
waitSpeech("Gamma, A1")
press("shift+Right")
waitSpeech("Cell B1 selected.")
press("shift+Down")
waitSpeech("Cells A2 to B2 selected.")
press("shift+Up")
waitSpeech("Cells A2 to B2 unselected.")
// Formula commands must still use the active cell, not the range's
// first selected child or the sheet that emitted the selection event.
commands <- input.PresentCellFormula
waitSpeech("Gamma")
press("Right")
waitSpeech("Selection cleared.")
press("ctrl+Home", "shift+space")
waitSpeech("Row 1 selected.")
press("shift+Down")
waitSpeech("Row 2 selected.")
press("ctrl+Home", "ctrl+space")
waitSpeech("Column A selected.")
press("shift+Right")
waitSpeech("Column B selected.")
press("ctrl+Home", "ctrl+a", "ctrl+a")
waitSpeech("All cells selected.")
// Calc omits events while shrinking full-column ranges. Growing the
// shrunken selection again supplies an event for a capped partial sheet.
press("shift+Left", "shift+Left", "shift+Right")
waitSpeech("At least 2147483647 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
press("Down")
waitSpeech("blank, B2")
// Use the provider's selection API to make actual disjoint selections;
// these are distinct from the synthetic event/presentation unit checks.
if output, err := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/native_libreoffice.py", "--select-disjoint").CombinedOutput(); err != nil {
t.Fatalf("disjoint Calc selection: %v: %s", err, output)
}
waitSpeech("Cell C1 selected.")
commands <- input.PresentCellFormula
waitSpeech("empty")
press("Right")
waitSpeech("Selection cleared.")
// A large non-column selection has an exact count, without enumerating
// its cells or inferring a rectangle from boundary samples.
press("ctrl+Home", "shift+Right", "ctrl+shift+Down", "shift+Up")
waitSpeech("2097150 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"slices"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
// Owned by the event loop, with one bounded snapshot for the focused sheet.
// Retired on sheet/application focus changes and the ordinary focus reset
// (including lock, destruction, foreground loss, and shutdown). No timers or
// provider references survive a selection event.
type libreOfficeSelectionState struct {
sheet atspi.ObjectID
previous *atspi.LibreOfficeSelection
}
func calcSheet(snapshot atspi.Snapshot) atspi.ObjectID {
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.LibreOffice == nil || !snapshot.LibreOffice.InSpreadsheet {
return atspi.ObjectID{}
}
if snapshot.Role == "table" {
return snapshot.ID()
}
if snapshot.LibreOffice.Cell {
return atspi.NewObjectID(snapshot.BusName, snapshot.TablePath)
}
return atspi.ObjectID{}
}
func (handler *accessibilityEventHandler) handleLibreOfficeSelection(event atspi.Event) {
state := handler.state
if isFocusGainEvent(event) && state.libreOfficeSelection.sheet != calcSheet(event.Source) {
state.libreOfficeSelection = libreOfficeSelectionState{}
}
context := event.Source.LibreOffice
if event.Type != atspi.SelectionChangedEvent || context == nil || context.Selection == nil ||
event.Source.Role != "table" || event.Source.Defunct || !context.InSpreadsheet ||
!atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) ||
handler.focusGate.Suspended() || !state.owner.window.Valid() ||
state.owner.window != eventWindow(event) || state.owner.applicationBus != event.Source.BusName ||
calcSheet(state.current) != event.Source.ID() {
return
}
history := &state.libreOfficeSelection
if history.sheet != event.Source.ID() {
*history = libreOfficeSelectionState{sheet: event.Source.ID()}
}
selection := context.Selection
handler.logger.Debug("received Calc selection snapshot", "kind", selection.Kind,
"count", selection.Count, "count_limited", selection.CountLimited,
"complete", selection.Complete, "cells", selection.Cells,
"first", selection.First, "last", selection.Last)
previous := history.previous
history.previous = selection
if previous != nil && previous.Kind == selection.Kind && previous.Count == selection.Count &&
previous.CountLimited == selection.CountLimited && previous.Complete == selection.Complete &&
previous.First == selection.First && previous.Last == selection.Last && slices.Equal(previous.Cells, selection.Cells) {
return
}
reading := state.presentationOptions.Reading
if handler.live != nil {
settings := handler.live.Load()
reading = &settings.Reading
}
if reading != nil && reading.OnlySpeakDisplayedText {
return
}
var messages []string
if previous != nil && (previous.Count != 0 || previous.Kind == "all") {
if selection.Count == 0 && selection.Kind != "all" {
messages = append(messages, "Selection cleared.")
} else if previous.Kind == selection.Kind && previous.Kind != "all" && previous.Complete && selection.Complete {
removed := *previous
removed.Cells = calcSelectionDifference(previous.Cells, selection.Cells)
removed.Count = len(removed.Cells)
if text := presentation.LibreOfficeSelectionText(removed, "unselected"); text != "" {
messages = append(messages, text)
}
}
}
added := *selection
if selection.Complete && (selection.Kind == "cells" || previous != nil && previous.Kind == selection.Kind) {
if previous != nil && previous.Complete && previous.Kind == selection.Kind {
added.Cells = calcSelectionDifference(selection.Cells, previous.Cells)
}
if selection.Kind == "cells" && state.current.TablePositionKnown {
// Orca leaves the anchor to ordinary active-cell presentation.
anchor := atspi.LibreOfficeCell{Row: state.current.TableRowIndex, Column: state.current.TableColumnIndex}
added.Cells = calcSelectionDifference(added.Cells, []atspi.LibreOfficeCell{anchor})
}
added.Count = len(added.Cells)
}
if text := presentation.LibreOfficeSelectionText(added, "selected"); text != "" {
messages = append(messages, text)
}
if text := strings.Join(messages, " "); text != "" {
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-selection"})
}
}
func calcSelectionDifference(first, second []atspi.LibreOfficeCell) []atspi.LibreOfficeCell {
var result []atspi.LibreOfficeCell
for _, cell := range first {
if !slices.Contains(second, cell) {
result = append(result, cell)
}
}
return result
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"log/slog"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestLibreOfficeSelectionChangesKeepActiveCellAndBraille(t *testing.T) {
current := calcTestEvent(atspi.FocusEvent, "/cell", "43", "C1")
current.Source.TableColumnIndex = 2
current.Source.LibreOffice.Formula = "B1+1"
state := eventLoopState{}
state.current, state.focusedID = current.Source, current.Source.ID()
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
state.presentationOptions = presentation.DefaultOptions()
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
display := &flashRecorder{}
live := &liveSettings{}
live.Store(config.DefaultSettings())
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
adapter: &fakeAdapter{}, speech: outputQueue{speech: speech, braille: display, live: live}, syncForeground: func() {}, webScripts: &webScriptController{}}
event := current
event.Type = atspi.SelectionChangedEvent
event.Source.Path, event.Source.Role = "/sheet", "table"
selection := func(cells ...atspi.LibreOfficeCell) {
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true,
Selection: &atspi.LibreOfficeSelection{Kind: "cells", Count: len(cells), Complete: true, Cells: cells}}
handler.handle(event)
}
expect := func(want string) {
t.Helper()
select {
case got := <-speech.spoken:
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
default:
t.Fatalf("missing %q", want)
}
if state.current.ID() != current.Source.ID() || state.focusedID != current.Source.ID() || presentation.LibreOfficeCellFormula(state.current) != "B1+1" {
t.Fatal("selection displaced active cell/formula")
}
if display.messages[len(display.messages)-1] != want {
t.Fatal("selection did not use system Braille flash")
}
}
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2})
expect("Cell A1 selected.")
handler.handle(event)
if len(speech.spoken) != 0 {
t.Fatal("duplicate selection repeated")
}
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
expect("Cell E1 selected.")
selection(atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
expect("Cell A1 unselected.")
selection()
expect("Selection cleared.")
// A count-only change reports the new total, never a fabricated
// unselection of the whole old range when selections overlap.
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000}
handler.handle(event)
expect("1000000 cells selected.")
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001}
handler.handle(event)
expect("1000001 cells selected.")
// The same total with different boundary samples is a changed selection.
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001, First: atspi.LibreOfficeCell{Row: 2, Column: 2}}
handler.handle(event)
expect("1000001 cells selected.")
// Whole-row/column shrinkage has the same delta policy when the
// provider supplies an event (the real Calc harness documents gaps).
for _, kind := range []string{"rows", "columns"} {
state.libreOfficeSelection = libreOfficeSelectionState{}
positions := []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}}
label := "Row 2 unselected."
selectedLabel := "Rows 1 to 2 selected."
if kind == "columns" {
positions[1] = atspi.LibreOfficeCell{Row: 0, Column: 1}
label = "Column B unselected."
selectedLabel = "Columns A to B selected."
}
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 2, Complete: true, Cells: positions}
handler.handle(event)
expect(selectedLabel)
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 1, Complete: true, Cells: positions[:1]}
handler.handle(event)
expect(label)
}
state.clearObjectFocus(nil, nil, nil)
if state.libreOfficeSelection.previous != nil {
t.Fatal("selection survived owned-focus reset")
}
}
func TestLibreOfficeSelectionRejectsUnownedAndSuppressedOutput(t *testing.T) {
for _, name := range []string{"other sheet", "other bus", "other window", "wrong process", "defunct", "locked", "suspended", "stale foreground", "displayed text"} {
t.Run(name, func(t *testing.T) {
current := calcTestEvent(atspi.FocusEvent, "/cell", "42", "B1")
state := eventLoopState{}
state.current, state.focusedID = current.Source, current.Source.ID()
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
state.foregroundState = atspi.ForegroundState{Epoch: 2}
state.presentationOptions = presentation.DefaultOptions()
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
gate := newFocusGate(nil)
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{}, speech: speech, focusGate: gate,
foreground: &ocrTestForeground{state: state.foregroundState}, syncForeground: func() {}, webScripts: &webScriptController{}}
event := current
event.Type = atspi.SelectionChangedEvent
event.Foreground.Epoch = 2
event.Source.Path, event.Source.Role = "/sheet", "table"
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, Selection: &atspi.LibreOfficeSelection{Kind: "all", Complete: true}}
switch name {
case "other sheet":
event.Source.Path = "/other_sheet"
case "other bus":
event.Source.BusName = ":1.99"
case "other window":
event.Window.Path = "/other_window"
case "wrong process":
event.Source.ApplicationProcess = "example"
case "defunct":
event.Source.Defunct = true
case "locked":
gate.secure.Store(true)
case "suspended":
gate.suspended.Store(true)
case "stale foreground":
event.Foreground.Epoch = 1
case "displayed text":
reading := config.DefaultSettings().Reading
reading.OnlySpeakDisplayedText = true
state.presentationOptions.Reading = &reading
}
// Test the application hook after the ordinary foreground/security
// gates; wrong-process events belong to the generic application path.
if name == "wrong process" {
handler.handleLibreOfficeSelection(event)
} else {
handler.handle(event)
}
if len(speech.spoken) != 0 || state.current.ID() != current.Source.ID() {
t.Fatal("unowned/suppressed selection changed output or focus")
}
})
}
}
+36
View File
@@ -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()
+12 -4
View File
@@ -176,14 +176,19 @@ func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter *
}
defer worker.Stop(context.Background())
for _, step := range []struct {
target review.DocumentTarget
level int
backwards bool
name string
}{
{false, "Offscreen first topic"},
{false, "Offscreen second topic"},
{true, "Offscreen first topic"},
{review.DocumentHeading, 6, false, "Offscreen first topic"},
{review.DocumentHeading, 6, false, "Offscreen second topic"},
{review.DocumentHeading, 6, true, "Offscreen first topic"},
{review.DocumentLink, 0, false, "Offscreen first topic"},
{review.DocumentLink, 0, false, "Offscreen second topic"},
{review.DocumentLink, 0, true, "Offscreen first topic"},
} {
if err := worker.MoveDocument(review.DocumentHeading, 6, step.backwards); err != nil {
if err := worker.MoveDocument(step.target, step.level, step.backwards); err != nil {
t.Fatal(err)
}
select {
@@ -193,6 +198,9 @@ func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter *
t.Fatalf("scrolled heading result = %+v", result)
}
link := result.DocumentPosition.LinkedHeading
if step.target == review.DocumentLink {
link = &result.DocumentPosition.Snapshot
}
if link == nil || !link.SoundGeometry.ObjectKnown || !link.SoundGeometry.ReferenceKnown {
t.Fatalf("scrolled link lacks fresh geometry: %+v", link)
}
+5 -4
View File
@@ -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." {
+11 -3
View File
@@ -12,6 +12,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/sound"
"git.stormux.org/storm/cthulhu/internal/webscripts"
)
type ProgressBarEntry struct {
@@ -64,7 +65,8 @@ func (history *ProgressBarHistory) report(id atspi.ObjectID, percentage float64,
// Handle progress before ordinary focused-object filtering. This path never
// changes window ownership, native focus, review or browser mode.
func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event) bool {
if !isValueChangeEvent(event.Type) || event.Source.Role != "progress bar" {
percentage, shaderProgress := webscripts.ShaderProgress(event.Source.ApplicationProcess, event.Type, event.Source.Role, event.Source.Name)
if !shaderProgress && (!isValueChangeEvent(event.Type) || event.Source.Role != "progress bar") {
return false
}
settings := config.DefaultSettings()
@@ -75,10 +77,16 @@ func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event)
// NVDA falls back to ordinary focused-control value reporting here.
return false
}
percentage, valid := presentation.ProgressPercentage(event.Source)
if !valid {
if shaderProgress && (event.Source.Defunct || event.Source.Indeterminate) {
return false
}
if !shaderProgress {
var valid bool
percentage, valid = presentation.ProgressPercentage(event.Source)
if !valid {
return false
}
}
state := handler.state
if handler.foreground != nil && event.Foreground.Epoch != state.foregroundState.Epoch {
return true
+68
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"fmt"
"log/slog"
"testing"
@@ -15,6 +16,73 @@ import (
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestSteamShaderProgressUsesSharedOutputAndGates(t *testing.T) {
for _, step := range []struct {
name, mode string
background, reportBackground bool
hidden, defunct, locked, stale bool
wantHandled, wantBeep, wantSpeech bool
}{
{name: "beep", mode: "beep", wantHandled: true, wantBeep: true},
{name: "speak", mode: "speak", wantHandled: true, wantSpeech: true},
{name: "both", mode: "both", wantHandled: true, wantBeep: true, wantSpeech: true},
{name: "off", mode: "off"},
{name: "background suppressed", mode: "both", background: true, wantHandled: true},
{name: "background enabled", mode: "both", background: true, reportBackground: true, wantHandled: true, wantBeep: true, wantSpeech: true},
{name: "hidden", mode: "both", hidden: true},
{name: "defunct", mode: "both", defunct: true},
{name: "secure", mode: "both", locked: true, wantHandled: true},
{name: "stale", mode: "both", stale: true, wantHandled: true},
} {
t.Run(step.name, func(t *testing.T) {
settings := config.DefaultSettings()
settings.ProgressBar.OutputMode = step.mode
settings.ProgressBar.ReportBackground = step.reportBackground
live := &liveSettings{}
live.Store(settings)
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
frames := frameRecorder{frames: make(chan braille.Frame, 8)}
state := &eventLoopState{}
state.current = atspi.Snapshot{BusName: ":1.1", Path: "/button", Role: "button"}
before := state.current
gate := &focusGate{}
gate.secure.Store(step.locked)
handler := &accessibilityEventHandler{state: state, live: live, speech: speech, focusGate: gate, brailleOutput: frames}
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{BusName: ":1.1", Path: "/shader-status", Role: "static",
ApplicationProcess: "steamwebhelper", StatesKnown: true, Visible: true, Showing: !step.hidden, Defunct: step.defunct})
event.ForegroundMismatch = step.background
if step.stale {
handler.foreground = &titleProbeSource{}
state.foregroundState.Epoch = 1
}
for _, percentage := range []int{18, 18, 19, 100} {
event.Source.Name = fmt.Sprintf("Processing Vulkan shaders (%d%%)", percentage)
if handled := handler.handle_progress_bar(event); handled != step.wantHandled {
t.Fatalf("handled=%t, want %t", handled, step.wantHandled)
}
}
wantCount := 0
if step.wantBeep {
wantCount = 3
} else if step.wantSpeech {
wantCount = 2
}
if len(speech.requests) != wantCount {
t.Fatalf("got %d requests, want %d", len(speech.requests), wantCount)
}
if wantCount > 0 {
request := <-speech.requests
if !request.Append || (len(request.Sound) == 1) != step.wantBeep || (request.Text == "18 percent") != step.wantSpeech {
t.Fatalf("shader output: %+v", request)
}
}
if state.current.ID() != before.ID() || state.focusedID.Valid() || len(frames.frames) != 0 {
t.Fatal("shader progress changed focus or unrelated braille")
}
})
}
}
func TestProgressSecureEpochAndBrailleGates(t *testing.T) {
settings := config.DefaultSettings()
settings.ProgressBar.ReportBackground = true
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"os/exec"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// Standard web progress must reach the shared handler without Steam policy.
func TestNativeBrowserProgress(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
t.Fatalf("fixture focus: %s, %v", output, err)
}
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == "Update progress" {
goto focused
}
case <-ctx.Done():
t.Fatal("update button focus missing")
}
}
focused:
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech}
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "space").CombinedOutput(); err != nil {
t.Fatalf("update progress: %s, %v", output, err)
}
seen := make(map[string]bool)
for len(seen) < 2 {
select {
case event := <-adapter.Events():
if !isValueChangeEvent(event.Type) || event.Source.Role != "progress bar" {
continue
}
wantTone := ""
switch event.Source.Name {
case "Native progress":
wantTone = "generated-progress-tone:20"
case "ARIA progress":
wantTone = "generated-progress-tone:30"
default:
continue
}
if seen[event.Source.Name] {
continue
}
if !handler.handle_progress_bar(event) || len(speech.requests) != 1 {
t.Fatalf("web progress was lost: %+v", event)
}
request := <-speech.requests
if len(request.Sound) != 1 || request.Sound[0].Name != wantTone || !request.Append || request.Text != "" {
t.Fatalf("web progress output: %+v", request)
}
seen[event.Source.Name] = true
case <-ctx.Done():
t.Fatalf("web progress events missing; received %v", seen)
}
}
}
+8 -4
View File
@@ -51,7 +51,7 @@ type commandRuntime struct {
onNotificationList func()
live *liveSettings
clipboardRequests chan<- struct{}
reviewRequests chan<- struct{}
reviewRequests chan<- input.Command
attributeRequests chan<- struct{}
objectReview *review.Worker
ocr *ocrSession
@@ -228,10 +228,14 @@ func (worker commandRuntime) run(ctx context.Context) error {
if onNotificationList != nil {
onNotificationList()
}
case input.WhereAmI:
logger.Info("where am I command received")
case input.WhereAmI, input.PresentCellFormula:
if command == input.WhereAmI {
logger.Info("where am I command received")
} else {
logger.Info("cell formula command received")
}
select {
case reviewRequests <- struct{}{}:
case reviewRequests <- command:
default:
}
case input.PresentCharacterAttributes:
+32
View File
@@ -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
+2 -2
View File
@@ -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
+1 -2
View File
@@ -19,6 +19,5 @@ func (policy *titleInputPolicy) observe(request speechRequest) {
}
func (policy *titleInputPolicy) preserves(event input.Event) bool {
return policy.protectRepeats && event.Repeat && event.Pressed &&
event.Name != "Control_L" && event.Name != "Control_R" && event.Name != "Escape"
return policy.protectRepeats && event.Pressed && !input.ShouldInterruptTitle(event)
}
+17 -7
View File
@@ -20,13 +20,14 @@ type windowTitleSource interface {
}
type pendingWindowTitle struct {
text string
window atspi.ObjectID
epoch uint64
native xorg.WindowTitle
changed bool
application string
observedAt time.Time
text string
window atspi.ObjectID
epoch uint64
native xorg.WindowTitle
changed bool
application string
observedAt time.Time
inputSequence uint64
}
// windowTitleController is owned by the accessibility event loop. Discovery
@@ -40,6 +41,7 @@ type windowTitleController struct {
logger *slog.Logger
source windowTitleSource
foreground foregroundMonitor
inputSource any
lastNative atspi.ForegroundTitle
lastTitle string
application string
@@ -96,6 +98,11 @@ func (titles *windowTitleController) applicationForEvent(event atspi.Event) stri
func (titles *windowTitleController) queue(pending pendingWindowTitle) {
if pending.observedAt.IsZero() {
pending.observedAt = time.Now()
pending.inputSequence = recentInput(titles.inputSource).TitleInterruptionSequence
}
if titles.pending != nil {
// Coalescing must not revive a burst interrupted since its first sample.
pending.inputSequence = titles.pending.inputSequence
}
if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch &&
previous.window == pending.window && previous.native.Window == pending.native.Window &&
@@ -246,6 +253,9 @@ func (titles *windowTitleController) flush() {
return
}
titles.lastTitle = pending.text
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
return
}
if titles.logger != nil {
titles.logger.Debug("queued automatic window title", "title_changed", pending.changed,
"observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds())
+41 -3
View File
@@ -15,6 +15,8 @@ import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
@@ -46,9 +48,22 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
listener := xorg.New()
if err = listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
inputDone := make(chan struct{})
go func() {
defer close(inputDone)
processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, input.EchoSettings{}, nil, nil)
}()
defer func() { cancel(); <-inputDone }()
completed := make(chan error, 1)
go func() { completed <- processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0) }()
go func() {
completed <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, listener, nil, nil)
}()
defer func() {
cancel()
if err := <-completed; err != nil {
@@ -60,9 +75,10 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
for {
select {
case got := <-queue.spoken:
if got == want {
return
if got != want {
t.Fatalf("title = %q, want %q", got, want)
}
return
case <-deadline:
t.Fatalf("title %q was not queued promptly", want)
case <-ctx.Done():
@@ -84,4 +100,26 @@ func TestNativeAutomaticTitleDispatchLatency(t *testing.T) {
wait(title, deadline)
t.Logf("X11 title update to speech queue: %s", time.Since(began))
}
// The switch chord must not suppress the destination's introduction. The
// fixture has no window manager, so activate each destination explicitly.
for _, destination := range []struct{ window, title string }{
{"unrelated", "Unrelated title"}, {"normal", "Status three"},
} {
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "alt+Tab").CombinedOutput(); err != nil {
t.Fatalf("switch keys: %v %s", err, out)
}
data, _ := json.Marshal(map[string]string{"action": "activate", "window": destination.window})
if _, err = stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
if !scanner.Scan() || scanner.Text() != "ready" {
t.Fatal("fixture switch failed")
}
wait(destination.title, time.After(2*time.Second))
}
select {
case got := <-queue.spoken:
t.Fatalf("duplicate window title: %q", got)
case <-time.After(2 * windowTitleIntroductionDelay):
}
}
+85
View File
@@ -13,6 +13,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/preferences"
)
@@ -80,6 +81,90 @@ func TestAutomaticTitlePolicyCoalescesAndPreservesSpeech(t *testing.T) {
}
}
func TestScheduledTitleInterruptedBeforeSpeechQueue(t *testing.T) {
for _, name := range []string{"c", "Tab", "Control_L", "Escape"} {
t.Run(name, func(t *testing.T) {
titles, queue, changed := titleControllerFixture(t)
tracker := &input.IntentTracker{}
titles.inputSource = tracker
changed.Source.Name = "Scheduled status"
titles.accessible(changed)
tracker.Observe(input.NewKeyEvent(0, true, name))
// A newer sample in the same burst must not undo cancellation.
changed.Source.Name = "Latest status"
titles.accessible(changed)
titles.flush()
if len(queue.requests) != 0 || titles.pending != nil {
t.Fatal("interrupted title reached the speech queue")
}
// A genuinely later change still announces normally.
changed.Source.Name = "New status"
titles.accessible(changed)
titles.flush()
if len(queue.requests) != 1 || queue.requests[0].Text != "New status" {
t.Fatalf("later title = %+v", queue.requests)
}
})
}
}
func TestScheduledNativeIntroductionPreservesSwitchAndRepeatInput(t *testing.T) {
titles, queue, _ := titleControllerFixture(t)
titles.state.owner.clear()
tracker := &input.IntentTracker{}
titles.inputSource = tracker
source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Other window"}}}
titles.source, titles.foreground = source, source
// Input initiating a switch precedes discovery of its destination title.
tracker.Observe(input.NewKeyEvent(0, true, "Tab"))
titles.nativeChanged()
repeat := input.NewKeyEvent(0, true, "Tab")
repeat.Repeat = true
tracker.Observe(repeat)
titles.flush()
if len(queue.requests) != 1 || queue.requests[0].Text != "Other window" {
t.Fatalf("destination title = %+v", queue.requests)
}
// Fresh typing after a subsequent switch cancels its delayed introduction.
source.title.Epoch++
source.title.Title.Window, source.title.Title.TitleWindow = 21, 21
source.title.Title.Title = "Next window"
titles.nativeChanged()
tracker.Observe(input.NewKeyEvent(0, true, "c"))
repeat = input.NewKeyEvent(0, true, "c")
repeat.Repeat = true
tracker.Observe(repeat)
titles.flush()
if len(queue.requests) != 1 {
t.Fatal("typing interruption was hidden by its later repeat")
}
// Returning to that window must introduce it again despite cancellation.
source.title.Epoch++
titles.nativeChanged()
titles.flush()
if len(queue.requests) != 2 || queue.requests[1].Text != "Next window" {
t.Fatalf("return title = %+v", queue.requests)
}
}
func TestScheduledTitleCannotReviveWhileWaitingForSettings(t *testing.T) {
titles, queue, changed := titleControllerFixture(t)
tracker := &input.IntentTracker{}
titles.inputSource = tracker
titles.live.onApplication = func(string) {}
titles.application = "Example Application"
changed.Source.Name = "Pending application title"
titles.accessible(changed)
titles.flush() // Requeued until the application override is committed.
tracker.Observe(input.NewKeyEvent(0, true, "c"))
application := titles.application
titles.live.application.Store(&application)
titles.flush()
if len(queue.requests) != 0 || titles.pending != nil {
t.Fatal("settings retry revived an interrupted title")
}
}
func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) {
titles, queue, changed := titleControllerFixture(t)
display := &flashRecorder{}
@@ -7,11 +7,13 @@ import (
"context"
"log/slog"
"os"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/wine"
)
@@ -20,6 +22,131 @@ type wineTitleLoopAdapter struct {
*reviewFocusMonitor
}
type changingWineTitleAdapter struct {
wineTitleLoopAdapter
mu sync.Mutex
changes chan struct{}
}
func (adapter *changingWineTitleAdapter) Foreground() atspi.ForegroundState {
adapter.mu.Lock()
defer adapter.mu.Unlock()
return adapter.state
}
func (adapter *changingWineTitleAdapter) ForegroundTitle() atspi.ForegroundTitle {
adapter.mu.Lock()
defer adapter.mu.Unlock()
return adapter.title
}
func (adapter *changingWineTitleAdapter) TitleChanges() <-chan struct{} { return adapter.changes }
func TestPrivateWineReviewUpdatesPreserveQueuedTitleSpeech(t *testing.T) {
if os.Getenv("CTHULHU_WINE_TITLE_DBUS") != "1" {
t.Skip("requires a private D-Bus session")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
logger := slog.New(slog.DiscardHandler)
registry := wine.NewReviewRegistry(logger)
if err := registry.Start(ctx); err != nil {
t.Fatal(err)
}
defer registry.Stop(context.Background())
_, fixture, monitor, reviewWorker := reviewFocusSetup(t)
notice := fixture.notices[0]
if !registry.RegisterProvider(notice.Sender, wine.ReviewObjectPath, wine.ReviewProtocolVersion) || !registry.PresentFocus(notice.Sender, notice) {
t.Fatal("could not register private Wine focus")
}
adapter := &changingWineTitleAdapter{wineTitleLoopAdapter: wineTitleLoopAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event)}, reviewFocusMonitor: monitor}, changes: make(chan struct{}, 1)}
live := &liveSettings{wineReview: registry}
live.Store(config.DefaultSettings())
speech := newSpeechWorker(logger, &fakeSpeaker{spoken: make(chan string, 4)})
speech.running = true
done := make(chan error, 1)
go func() {
done <- processEventsWithInput(ctx, logger, speech, adapter, nil, reviewWorker, 0, live, nil, nil, nil)
}()
defer func() {
cancel()
if err := <-done; err != nil {
t.Error(err)
}
}()
wait := func(check func() bool) {
t.Helper()
for !check() {
select {
case <-ctx.Done():
t.Fatal("Wine title replay timed out")
case <-time.After(time.Millisecond):
}
}
}
wait(func() bool { return reviewWorker.Focus() == notice.ID() })
speech.mutex.Lock()
initiallyInterrupted := speech.generation != 0
speech.mutex.Unlock()
if initiallyInterrupted {
t.Fatal("Wine review focus reset requested speech cancellation")
}
var initial speechRequest
wait(func() bool { var ok bool; initial, ok = speech.next(); return ok })
if err := speech.deliver(ctx, initial); err != nil {
t.Fatal(err)
}
for _, title := range []string{"East", "In front of the safe zone", "Leaving in front of the safe zone"} {
// The native caption changes before the helper's next metadata notice.
// The old notice loses eligibility, then a new revision reclaims focus.
adapter.mu.Lock()
adapter.title.Title.Title = title
adapter.mu.Unlock()
adapter.changes <- struct{}{}
wait(func() bool { return !reviewWorker.Focus().Valid() })
speech.mutex.Lock()
interrupted := speech.generation != 0
speech.mutex.Unlock()
if interrupted {
t.Fatal("Wine metadata retirement canceled title speech")
}
notice.Title = title
notice.Revision++
if !registry.PresentFocus(notice.Sender, notice) {
t.Fatal("could not update Wine metadata")
}
wait(func() bool { return reviewWorker.Focus() == notice.ID() })
wait(func() bool {
speech.mutex.Lock()
defer speech.mutex.Unlock()
return len(speech.pending) > 0 && speech.pending[len(speech.pending)-1].Text == title
})
if speech.InterruptForInput(input.Event{Name: "Up", Pressed: true, Repeat: true}) {
t.Fatal("metadata update removed held-key protection")
}
}
speech.mutex.Lock()
generation := speech.generation
count := len(speech.pending)
speech.mutex.Unlock()
if generation != 0 || count != 3 {
t.Fatalf("metadata interrupted titles: generation=%d pending=%d", generation, count)
}
for _, want := range []string{"East", "In front of the safe zone", "Leaving in front of the safe zone"} {
request, ok := speech.next()
if !ok || request.Text != want || !request.Append {
t.Fatalf("queued title = %+v, want %q", request, want)
}
if err := speech.deliver(ctx, request); err != nil {
t.Fatal(err)
}
}
if !speech.InterruptForInput(input.Event{Name: "Down", Pressed: true}) {
t.Fatal("fresh press failed to stop speech")
}
}
func TestPrivateWineTitleSurvivesReviewFocusBurst(t *testing.T) {
if os.Getenv("CTHULHU_WINE_TITLE_DBUS") != "1" {
t.Skip("requires a private D-Bus session")
+4 -2
View File
@@ -93,8 +93,10 @@ func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object
if !adapter.captureSound.Load() {
return [2]SoundGeometry{}, nil
}
result, err := adapter.documentOperation(ctx, treeCaptureRequest{soundObjects: [2]ObjectReference{object, link}})
return result.soundGeometry, err
return retryDocumentSoundGeometry(ctx, link.Valid(), func(ctx context.Context) ([2]SoundGeometry, error) {
result, err := adapter.documentOperation(ctx, treeCaptureRequest{soundObjects: [2]ObjectReference{object, link}})
return result.soundGeometry, err
})
}
func (adapter *Adapter) captureNativeDocumentSoundGeometry(ctx context.Context, objects [2]ObjectReference) ([2]SoundGeometry, error) {
@@ -302,6 +302,10 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
}
if !focusEvent.ForegroundMismatch {
captureLibreOfficeContext(target, &snapshot)
if eventType == SelectionChangedEvent && snapshot.Role == "table" &&
snapshot.LibreOffice != nil && snapshot.LibreOffice.InSpreadsheet {
snapshot.LibreOffice.Selection = captureLibreOfficeSelection(target)
}
focusEvent.Source = snapshot
if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent {
snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target)
@@ -477,7 +481,8 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
focusEvent.Window = originID
}
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) {
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) &&
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
adapter.rememberNativeFocus(target, snapshot.ID())
}
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"time"
)
// A successful ScrollTo can precede the browser's visibility/geometry update.
// Retry missing geometry on the calling worker, leaving the native event owner
// free to process those updates. Unsupported geometry gets at most 50ms of
// extra waiting; native calls remain bounded by the caller's context.
func retryDocumentSoundGeometry(ctx context.Context, linked bool, capture func(context.Context) ([2]SoundGeometry, error)) ([2]SoundGeometry, error) {
for attempt := 0; ; attempt++ {
if err := ctx.Err(); err != nil {
return [2]SoundGeometry{}, err
}
geometry, err := capture(ctx)
if err != nil {
return [2]SoundGeometry{}, err
}
ready := geometry[0].ObjectKnown && geometry[0].ReferenceKnown
if linked {
ready = ready && geometry[1].ObjectKnown && geometry[1].ReferenceKnown
}
if ready || attempt == 5 {
return geometry, nil
}
timer := time.NewTimer(10 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return [2]SoundGeometry{}, ctx.Err()
case <-timer.C:
}
}
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"errors"
"testing"
)
func TestDocumentSoundGeometryWaitsForBrowserScroll(t *testing.T) {
ready := SoundGeometry{ObjectKnown: true, ReferenceKnown: true,
Object: Rectangle{X: 18, Y: 147, Width: 102, Height: 13},
Reference: Rectangle{Width: 800, Height: 600}}
for _, linked := range []bool{false, true} {
calls := 0
geometry, err := retryDocumentSoundGeometry(context.Background(), linked, func(context.Context) ([2]SoundGeometry, error) {
calls++
// Scroll completion can reach the heading and link at different times.
if calls == 1 {
return [2]SoundGeometry{}, nil
}
if calls == 2 {
return [2]SoundGeometry{ready, {}}, nil
}
return [2]SoundGeometry{ready, ready}, nil
})
wantCalls := 2
if linked {
wantCalls = 3
}
if err != nil || calls != wantCalls || geometry[0] != ready || linked && geometry[1] != ready {
t.Fatalf("linked=%t: calls=%d geometry=%+v err=%v", linked, calls, geometry, err)
}
}
}
func TestDocumentSoundGeometryRetryBoundsAndCancellation(t *testing.T) {
for _, scenario := range []string{"ready", "unavailable", "stale", "cancelled", "already cancelled"} {
t.Run(scenario, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if scenario == "already cancelled" {
cancel()
}
calls := 0
_, err := retryDocumentSoundGeometry(ctx, false, func(context.Context) ([2]SoundGeometry, error) {
calls++
switch scenario {
case "ready":
return [2]SoundGeometry{{ObjectKnown: true, ReferenceKnown: true}, {}}, nil
case "stale":
return [2]SoundGeometry{}, ErrStaleObject
case "cancelled":
cancel()
}
return [2]SoundGeometry{}, nil
})
wantCalls, wantErr := 1, error(nil)
switch scenario {
case "unavailable":
wantCalls = 6
case "stale":
wantErr = ErrStaleObject
case "cancelled":
wantErr = context.Canceled
case "already cancelled":
wantCalls, wantErr = 0, context.Canceled
}
if calls != wantCalls || !errors.Is(err, wantErr) {
t.Fatalf("calls=%d err=%v, want calls=%d err=%v", calls, err, wantCalls, wantErr)
}
})
}
}
+39
View File
@@ -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,10 +20,44 @@ type LibreOfficeContext struct {
ContainerFocused bool
CellText string
Coordinates string
Formula string
Selection *LibreOfficeSelection
}
// LibreOfficeSelection never expands a sheet. Complete small selections carry
// at most 64 positions; larger selections carry counts and boundary samples.
// Kind is cells, rows, columns, or all. Count is a lower bound when
// CountLimited is true.
type LibreOfficeSelection struct {
Kind string
Count int
CountLimited bool
Complete bool
Cells []LibreOfficeCell
// Boundary samples distinguish equal-sized large selections. They do
// not prove that every cell between the endpoints is selected.
First, Last LibreOfficeCell
}
type LibreOfficeCell struct{ Row, Column int }
const libreOfficeSelectionLimit = 64
// IsLibreOfficeProcess uses /proc identity verified by the adapter. An
// accessible name or document title must never enable application policy.
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)
}
+32 -3
View File
@@ -11,15 +11,31 @@ import "C"
import "unsafe"
// libreOfficeFocusTarget resolves only a Calc editor's direct text children.
// F2 focuses its temporary panel; the paragraph can appear later, on typing.
// libreOfficeFocusTarget resolves a Calc editor's direct text children and
// preserves the sheet source for selection events. F2 focuses its temporary
// panel; the paragraph can appear later, on typing.
// The callback owns and releases the returned reference. No sheet is walked.
func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible {
if eventType != FocusEvent && eventType != FocusedStateEvent ||
if eventType != FocusEvent && eventType != FocusedStateEvent && eventType != SelectionChangedEvent ||
!IsLibreOfficeProcess(adapter.applicationProcess(event.source)) {
return nil
}
role := C.atspi_accessible_get_role(event.source, nil)
if eventType == SelectionChangedEvent {
// Keep the sheet as the selection source. Its first selected cell
// is not necessarily the active cell (and can be far offscreen).
if role == C.ATSPI_ROLE_TABLE {
parent := C.atspi_accessible_get_parent(event.source, nil)
if parent != nil {
spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
C.g_object_unref(C.gpointer(parent))
if spreadsheet {
return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(event.source)))
}
}
}
return nil
}
if role == C.ATSPI_ROLE_TABLE {
parent := C.atspi_accessible_get_parent(event.source, nil)
if parent != nil {
@@ -120,6 +136,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 +149,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,135 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
// Only the native owner calls this, after foreground validation. Counts use a
// fixed number of RPCs; no selected-row/column arrays or sheet walks are used.
// LibreOffice itself scans its row marks when answering selected-row counts.
func captureLibreOfficeSelection(source *C.AtspiAccessible) *LibreOfficeSelection {
selection := C.atspi_accessible_get_selection_iface(source)
table := C.atspi_accessible_get_table_iface(source)
if selection == nil || table == nil {
if selection != nil {
C.g_object_unref(C.gpointer(selection))
}
if table != nil {
C.g_object_unref(C.gpointer(table))
}
return nil
}
defer C.g_object_unref(C.gpointer(selection))
defer C.g_object_unref(C.gpointer(table))
var queryError *C.GError
count := int(C.atspi_selection_get_n_selected_children(selection, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
if count < 0 {
return nil
}
result := &LibreOfficeSelection{Kind: "cells", Count: count, CountLimited: count == 1<<31-1}
if count == 0 {
result.Complete = true
return result
}
rows := int(C.atspi_table_get_n_rows(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
columns := int(C.atspi_table_get_n_columns(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
selectedRows, selectedColumns := 0, 0
// A small selection cannot contain a whole row or column. Avoid making
// LibreOffice scan all its row marks for ordinary Shift+Arrow movement.
if count >= columns {
selectedRows = int(C.atspi_table_get_n_selected_rows(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
}
if count >= rows {
selectedColumns = int(C.atspi_table_get_n_selected_columns(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
}
if rows <= 0 || columns <= 0 || selectedRows < 0 || selectedColumns < 0 {
return nil
}
if selectedRows == rows || selectedColumns == columns {
return &LibreOfficeSelection{Kind: "all", Complete: true}
}
stride := 1
// Only classify pure row/column selections. Mixed selections retain an
// honest cell count. GTK caps counts above INT_MAX, rather than wrapping.
if !result.CountLimited && selectedRows > 0 && int64(selectedRows)*int64(columns) == int64(count) {
result.Kind, result.Count, result.CountLimited = "rows", selectedRows, false
stride = columns
} else if !result.CountLimited && selectedColumns > 0 && int64(selectedColumns)*int64(rows) == int64(count) {
result.Kind, result.Count, result.CountLimited = "columns", selectedColumns, false
}
indices := make([]int, 0, libreOfficeSelectionLimit)
if result.Count > libreOfficeSelectionLimit {
indices = append(indices, 0, count-1)
} else {
for index := 0; index < result.Count; index++ {
indices = append(indices, index*stride)
}
}
for _, index := range indices {
child := C.atspi_selection_get_selected_child(selection, C.gint(index), &queryError)
if queryError != nil {
C.g_error_free(queryError)
if child != nil {
C.g_object_unref(C.gpointer(child))
}
return nil
}
if child == nil {
return nil
}
cell := C.atspi_accessible_get_table_cell(child)
var row, column C.gint
valid := false
if cell != nil {
valid = C.atspi_table_cell_get_position(cell, &row, &column, &queryError) != 0
C.g_object_unref(C.gpointer(cell))
}
C.g_object_unref(C.gpointer(child))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
if !valid || row < 0 || column < 0 || int(row) >= rows || int(column) >= columns {
return nil
}
position := LibreOfficeCell{Row: int(row), Column: int(column)}
if result.Kind == "rows" {
position.Column = 0
}
if result.Kind == "columns" {
position.Row = 0
}
result.Cells = append(result.Cells, position)
}
result.First, result.Last = result.Cells[0], result.Cells[len(result.Cells)-1]
result.Complete = result.Count <= libreOfficeSelectionLimit
if !result.Complete {
result.Cells = nil
}
return result
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"strings"
"testing"
"unicode/utf8"
)
func TestLibreOfficeFormulaPreservesLiteralSpacesAndBoundedPrefix(t *testing.T) {
formula := `="two spaces"`
if got := sanitizeLibreOfficeFormula(formula); got != formula {
t.Fatalf("formula literal changed: %q", got)
}
formula += strings.Repeat("猫", maxSnapshotFieldLength)
got := sanitizeLibreOfficeFormula(formula)
if len(got) > maxSnapshotFieldLength || !utf8.ValidString(got) || !strings.HasPrefix(formula, got) {
t.Fatalf("invalid bounded formula prefix: bytes=%d", len(got))
}
}
+3 -1
View File
@@ -53,6 +53,8 @@ func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings {
func CommandLabel(command Command) string {
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
}
+2
View File
@@ -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
+19
View File
@@ -115,6 +115,25 @@ func TestLaptopBindingRoutesWhereAmI(t *testing.T) {
}
}
func TestCellFormulaBindingAndLaptopOverride(t *testing.T) {
chord := CommandChord{Symbol: '=', CthulhuModifier: true}
if got := DefaultCommandBindings(DesktopKeyboardLayout)[chord]; got != PresentCellFormula {
t.Fatalf("desktop formula binding = %q", got)
}
if got := DefaultCommandBindings(LaptopKeyboardLayout)[chord]; got != "" {
t.Fatalf("laptop formula should be unbound by default: %q", got)
}
overrides := []BindingOverride{{Layout: LaptopKeyboardLayout, Scope: "global",
Command: PresentCellFormula, Keys: []string{"Cthulhu+="}}}
if err := ValidateBindingOverrides(overrides); err != nil {
t.Fatal(err)
}
bindings, err := ResolveCommandBindings(LaptopKeyboardLayout, false, overrides)
if err != nil || bindings[chord] != PresentCellFormula {
t.Fatalf("laptop formula override = %q, %v", bindings[chord], err)
}
}
func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
bindings := DefaultCommandBindings(layout)
+6
View File
@@ -123,3 +123,9 @@ func (modifiers ModifierKeycodes) Contains(keycode uint8) bool {
func ShouldInterruptSpeech(event Event) bool {
return event.Pressed
}
// Automatic titles survive held-key repeats, but fresh input and explicit
// stop keys cancel both scheduled and already queued title speech.
func ShouldInterruptTitle(event Event) bool {
return event.Pressed && (!event.Repeat || event.Name == "Control_L" || event.Name == "Control_R" || event.Name == "Escape")
}
+21 -13
View File
@@ -24,10 +24,13 @@ const (
)
type Activity struct {
Selecting bool
Intent Intent
Sequence uint64
Clipboard ClipboardAction
// Unlike presentation intent, this counter never expires. Deferred titles
// may be waiting for application settings after ordinary input has aged out.
TitleInterruptionSequence uint64
Selecting bool
Intent Intent
Sequence uint64
Clipboard ClipboardAction
}
type ClipboardAction uint8
@@ -40,13 +43,14 @@ const (
)
type IntentTracker struct {
sequence uint64
mutex sync.Mutex
held map[string]bool
intent Intent
clipboard ClipboardAction
selecting bool
at time.Time
titleInterruptionSequence uint64
sequence uint64
mutex sync.Mutex
held map[string]bool
intent Intent
clipboard ClipboardAction
selecting bool
at time.Time
}
func (tracker *IntentTracker) Observe(event Event) {
@@ -55,6 +59,9 @@ func (tracker *IntentTracker) Observe(event Event) {
if event.Pressed {
tracker.sequence++
}
if ShouldInterruptTitle(event) {
tracker.titleInterruptionSequence++
}
if tracker.held == nil {
tracker.held = make(map[string]bool)
}
@@ -129,7 +136,8 @@ func (tracker *IntentTracker) RecentInput() Activity {
tracker.mutex.Lock()
defer tracker.mutex.Unlock()
if time.Since(tracker.at) > time.Second {
return Activity{}
return Activity{TitleInterruptionSequence: tracker.titleInterruptionSequence}
}
return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting}
return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting,
TitleInterruptionSequence: tracker.titleInterruptionSequence}
}
+30
View File
@@ -83,3 +83,33 @@ func TestSelectionIntentEndsWithPlainNavigation(t *testing.T) {
t.Fatalf("Right = %+v", got)
}
}
func TestTitleInterruptionTracksFreshPressesAndStopKeys(t *testing.T) {
tracker := &IntentTracker{}
for _, step := range []struct {
name string
pressed bool
repeat bool
want uint64
}{
{"a", true, false, 1}, {"a", false, false, 1},
{"a", true, true, 1}, {"Down", true, false, 2},
{"Down", true, true, 2}, {"Control_L", true, true, 3},
{"Control_R", true, true, 4}, {"Escape", true, true, 5},
} {
event := NewKeyEvent(0, step.pressed, step.name)
event.Repeat = step.repeat
tracker.Observe(event)
if got := tracker.RecentInput().TitleInterruptionSequence; got != step.want {
t.Fatalf("%+v: title interruption sequence = %d", step, got)
}
}
tracker.at = time.Now().Add(-2 * time.Second)
if got := tracker.RecentInput(); got.Intent != NoIntent || got.TitleInterruptionSequence != 5 {
t.Fatalf("expired activity = %+v", got)
}
tracker.Reset()
if got := tracker.RecentInput().TitleInterruptionSequence; got != 5 {
t.Fatalf("reset lost interruption history: %d", got)
}
}
+10 -2
View File
@@ -171,7 +171,7 @@ Foreground read_foreground(Display *display) {
pthread_mutex_unlock(&error_mutex);
return result;
}
static int cthulhu_display_active_window_xterm_match_unchecked(Display *display) {
int cthulhu_focus_metadata_unchecked(Display *display, SteamFocusSample *steam) {
Window root = DefaultRootWindow(display);
Atom activeAtom = XInternAtom(display, "_NET_ACTIVE_WINDOW", False);
Atom type = None;
@@ -184,6 +184,10 @@ static int cthulhu_display_active_window_xterm_match_unchecked(Display *display)
}
Window window = ((Window *)data)[0];
XFree(data);
if (steam != NULL) {
steam->known = 1;
steam->window = window;
}
if (window == None || window == root) {
return 0;
}
@@ -197,6 +201,10 @@ static int cthulhu_display_active_window_xterm_match_unchecked(Display *display)
char *identifier = (char *)data;
if (cthulhu_identifier_is_xterm(identifier)) matched = 1;
char *classIdentifier = identifier + strlen(identifier) + 1;
if (steam != NULL && (unsigned char *)classIdentifier < data + items) {
steam->steam = strcmp(identifier, "steamwebhelper") == 0 &&
strcmp(classIdentifier, "steam") == 0;
}
if (!matched && (unsigned char *)classIdentifier < data + items) {
matched = cthulhu_identifier_is_xterm(classIdentifier);
}
@@ -236,7 +244,7 @@ int cthulhu_display_active_window_xterm_match(Display *display) {
XSync(display,False);
error_display=display; grab_error=0;
previous_error_handler=XSetErrorHandler(command_error);
int match=cthulhu_display_active_window_xterm_match_unchecked(display);
int match=cthulhu_focus_metadata_unchecked(display, NULL);
XSync(display,False);
if (grab_error) match=0;
XSetErrorHandler(previous_error_handler); error_display=NULL;
+7
View File
@@ -30,11 +30,18 @@ typedef struct {
unsigned long window;
} Foreground;
typedef struct { int known, pid, x, y; unsigned long window; } PointerPosition;
typedef struct { int known, pid, steam, innerFocus; Window window, focus; } SteamFocusSample;
typedef struct { int xterm; SteamFocusSample steam; } CommandFocusSample;
int cthulhu_active_window_is_xterm(void);
int cthulhu_display_active_window_xterm_match(Display *display);
Foreground read_foreground(Display *display);
PointerPosition cthulhu_read_pointer(Display *display);
// Caller holds the X11 read guard; optional Steam metadata shares the same
// active-window and WM_CLASS reads as the XTerm match.
int cthulhu_focus_metadata_unchecked(Display *display, SteamFocusSample *steam);
CommandFocusSample cthulhu_read_command_focus(Display *display, int steam_enabled);
int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target);
void cthulhu_x11_begin_read(Display *display);
int cthulhu_x11_end_read(Display *display);
int set_command_keys(CommandDevice *device, int enabled);
@@ -38,6 +38,7 @@ type commandOwner struct {
uiWindow, uiFocus C.Window
lastLocks, lastFocusMatch C.int
lastFocusCheck, drainDeadline time.Time
steamFocus steamFocusRepair
}
func (listener *CommandListener) runCommandLoop(workerContext context.Context, device *C.CommandDevice, layout input.KeyboardLayout, modifiers []string, core, tentacles []input.BindingOverride, symbols []uint32) {
@@ -71,7 +72,7 @@ func (owner *commandOwner) run() {
return
}
if time.Since(owner.lastFocusCheck) >= xtermFocusCheckInterval {
owner.publishFocus()
owner.check_steam_focus(owner.publishFocus())
}
owner.resetForSecureInput()
if !owner.syncGrabs() {
@@ -102,16 +103,17 @@ func (owner *commandOwner) run() {
}
}
func (owner *commandOwner) publishFocus() {
match := C.cthulhu_display_active_window_xterm_match(owner.device.display)
func (owner *commandOwner) publishFocus() steamFocusSample {
match, steam := read_command_focus(owner.device.display, !owner.listener.secure.Load() && owner.workerContext.Err() == nil)
owner.lastFocusCheck = time.Now()
if match == 0 || match == owner.lastFocusMatch {
return
if match == 0 || C.int(match) == owner.lastFocusMatch {
return steam
}
owner.lastFocusMatch = match
owner.lastFocusMatch = C.int(match)
state := FocusState{Known: true, XTerm: match == 1}
owner.listener.SetCommandGrabs(!state.XTerm)
owner.listener.publishFocusState(state)
return steam
}
// Native xterm focus vetoes delayed accessibility requests to restore grabs.
@@ -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},
+106
View File
@@ -0,0 +1,106 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "commands_native.h"
#include <X11/Xatom.h>
#include <X11/Xutil.h>
#include <string.h>
static int steam_active_window(Display *display, Window *window) {
Atom type; int format;
unsigned long count, remaining;
unsigned char *data = NULL;
int known = XGetWindowProperty(display, DefaultRootWindow(display), XInternAtom(display, "_NET_ACTIVE_WINDOW", False),
0, 1, False, XA_WINDOW, &type, &format, &count, &remaining, &data) == Success &&
data != NULL && type == XA_WINDOW && format == 32 && count == 1;
if (known) *window = ((Window *)data)[0];
if (data != NULL) XFree(data);
return known;
}
static int steam_window_pid(Display *display, Window window) {
Atom type; int format;
unsigned long count, remaining;
unsigned char *data = NULL;
int pid = 0;
if (XGetWindowProperty(display, window, XInternAtom(display, "_NET_WM_PID", False),
0, 1, False, XA_CARDINAL, &type, &format, &count,
&remaining, &data) == Success && data != NULL &&
type == XA_CARDINAL && format == 32 && count == 1) {
pid = (int)((unsigned long *)data)[0];
}
if (data != NULL) XFree(data);
return pid;
}
static int steam_wrapper(Display *display, Window window) {
XClassHint hint = {0};
int matches = XGetClassHint(display, window, &hint) &&
hint.res_name != NULL && hint.res_class != NULL &&
strcmp(hint.res_name, "steamwebhelper") == 0 && strcmp(hint.res_class, "steam") == 0;
if (hint.res_name != NULL) XFree(hint.res_name);
if (hint.res_class != NULL) XFree(hint.res_class);
return matches;
}
static int steam_browser_child(Display *display, Window wrapper, int pid, Window child) {
XWindowAttributes attributes;
if (child == None || child == PointerRoot || child == wrapper ||
!XGetWindowAttributes(display, child, &attributes) || attributes.map_state != IsViewable ||
attributes.class != InputOutput || steam_window_pid(display, child) != pid) return 0;
for (int depth = 0; depth < 32; depth++) {
Window root, parent, *children = NULL;
unsigned int count;
if (!XQueryTree(display, child, &root, &parent, &children, &count)) return 0;
if (children != NULL) XFree(children);
if (parent == wrapper) return 1;
if (parent == None || parent == root) return 0;
child = parent;
}
return 0;
}
CommandFocusSample cthulhu_read_command_focus(Display *display, int steam_enabled) {
CommandFocusSample result = {0};
SteamFocusSample *sample = &result.steam;
cthulhu_x11_begin_read(display);
result.xterm = cthulhu_focus_metadata_unchecked(display, steam_enabled ? sample : NULL);
if (sample->steam) {
sample->pid = steam_window_pid(display, sample->window);
sample->steam = sample->pid > 0;
int revert;
XGetInputFocus(display, &sample->focus, &revert);
if (sample->steam) sample->innerFocus = steam_browser_child(display, sample->window, sample->pid, sample->focus);
}
if (cthulhu_x11_end_read(display)) result = (CommandFocusSample){0};
return result;
}
int cthulhu_restore_steam_focus(Display *display, Window wrapper, int pid, Window target) {
cthulhu_x11_begin_read(display);
int restored = 0, revert;
Window focus, active = None;
XGetInputFocus(display, &focus, &revert);
char heldKeys[32];
XQueryKeymap(display, heldKeys);
int keyHeld = 0;
for (int i = 0; i < 32; i++) keyHeld |= heldKeys[i];
if (!keyHeld && focus == wrapper && steam_active_window(display, &active) && active == wrapper &&
steam_wrapper(display, wrapper) &&
steam_window_pid(display, wrapper) == pid && steam_browser_child(display, wrapper, pid, target)) {
// Recheck foreground immediately before changing input focus: cached
// children must never pull keyboard input out of a different client.
if (steam_active_window(display, &active) && active == wrapper) {
XGetInputFocus(display, &focus, &revert);
if (focus == wrapper) {
XSetInputFocus(display, target, RevertToParent, CurrentTime);
XSync(display, False);
XGetInputFocus(display, &focus, &revert);
restored = focus == target;
}
}
}
if (cthulhu_x11_end_read(display)) restored = 0;
return restored;
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package xorg
/*
#include "commands_native.h"
*/
import "C"
type steamFocusSample struct {
window, focus uint64
pid int
known, steam bool
innerFocus bool
}
// The command owner retains one verified Steam browser child. A foreground
// transition arms at most one repair during the next ten 100ms focus samples.
// Steam's own restoration wins; we never choose a child from its geometry.
type steamFocusRepair struct {
previousWindow, window, target uint64
pid int
checksLeft, outerSamples int
}
func (repair *steamFocusRepair) observe(sample steamFocusSample) uint64 {
if !sample.known {
return 0
}
if sample.window != repair.previousWindow {
repair.previousWindow = sample.window
repair.checksLeft, repair.outerSamples = 10, 0
}
if !sample.steam {
repair.checksLeft, repair.outerSamples = 0, 0
return 0
}
if sample.innerFocus {
repair.window, repair.pid, repair.target = sample.window, sample.pid, sample.focus
repair.outerSamples = 0
}
if repair.checksLeft == 0 {
return 0
}
repair.checksLeft--
if sample.focus != sample.window {
repair.outerSamples = 0
return 0
}
repair.outerSamples++
if repair.outerSamples < 2 || sample.window != repair.window || sample.pid != repair.pid {
return 0
}
return repair.target
}
func read_command_focus(display *C.Display, steamEnabled bool) (int, steamFocusSample) {
var enabled C.int
if steamEnabled {
enabled = 1
}
result := C.cthulhu_read_command_focus(display, enabled)
value := result.steam
return int(result.xterm), steamFocusSample{window: uint64(value.window), focus: uint64(value.focus), pid: int(value.pid),
known: value.known != 0, steam: value.steam != 0, innerFocus: value.innerFocus != 0}
}
func restore_steam_focus(display *C.Display, window uint64, pid int, target uint64) bool {
return C.cthulhu_restore_steam_focus(display, C.Window(window), C.int(pid), C.Window(target)) != 0
}
func (owner *commandOwner) check_steam_focus(sample steamFocusSample) {
if owner.listener.secure.Load() || owner.workerContext.Err() != nil {
owner.steamFocus.checksLeft = 0
return
}
target := owner.steamFocus.observe(sample)
if target == 0 || !owner.commandGrabsAllowed() || len(owner.held) != 0 || owner.device.active != 0 ||
owner.learnMode || owner.uiSession != 0 || owner.ocrReview || owner.ocrPending {
return
}
if restore_steam_focus(owner.device.display, sample.window, sample.pid, target) {
owner.steamFocus.checksLeft = 0
}
}
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package xorg
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"runtime"
"testing"
"time"
)
func TestNativeSteamFocusRepair(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_STEAM_FOCUS") != "1" {
t.Skip("requires private X11 Steam fixture")
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fixture := exec.CommandContext(ctx, "python3", "-u", "../../../tests/steam_focus_fixture.py")
stdin, err := fixture.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
fixture.Stderr = os.Stderr
if err := fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() { stdin.Close(); fixture.Wait() }()
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() {
t.Fatal("Steam fixture did not start")
}
var windows map[string]uint64
if err := json.Unmarshal(scanner.Bytes(), &windows); err != nil {
t.Fatal(err)
}
command := func(action, window string) {
t.Helper()
data, _ := json.Marshal(map[string]string{"action": action, "window": window})
if _, err := stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
if !scanner.Scan() || scanner.Text() != "ready" {
t.Fatal("Steam fixture command failed")
}
}
key := func(action, name string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", action, name).CombinedOutput(); err != nil {
t.Fatalf("key: %v %s", err, out)
}
}
reader := OpenForegroundReader()
if reader == nil {
t.Fatal("cannot open private X11 display")
}
defer reader.Close()
readSteam := func() steamFocusSample {
t.Helper()
match, sample := read_command_focus(reader.display, true)
if match != 2 {
t.Fatalf("Steam/ordinary window XTerm match = %d", match)
}
return sample
}
match, disabled := read_command_focus(reader.display, false)
if match != 2 || disabled != (steamFocusSample{}) {
t.Fatalf("disabled Steam sampling = %d, %+v", match, disabled)
}
sample := readSteam()
if !sample.steam || !sample.innerFocus || sample.focus != windows["browser"] {
t.Fatalf("initial browser focus not recognized: %+v", sample)
}
repair := steamFocusRepair{}
repair.observe(sample)
roundTrip := func() uint64 {
t.Helper()
command("activate", "other")
other := readSteam()
if !other.known || other.window != windows["other"] || other.steam || other.focus != 0 || other.pid != 0 {
t.Fatalf("ordinary window queried Steam details: %+v", other)
}
repair.observe(other)
command("activate", "steam")
if target := repair.observe(readSteam()); target != 0 {
t.Fatal("Steam restoration grace sample was skipped")
}
return repair.observe(readSteam())
}
target := roundTrip()
if target != windows["browser"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("main browser keyboard focus was not repaired")
}
key("key", "Escape")
data, _ := json.Marshal(map[string]string{"action": "events"})
if _, err := stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
if !scanner.Scan() {
t.Fatal("fixture key delivery missing")
}
var events []struct {
Window uint64
Pressed bool
Escape bool
}
if err := json.Unmarshal(scanner.Bytes(), &events); err != nil {
t.Fatal(err)
}
if len(events) != 2 || !events[0].Escape || !events[0].Pressed || !events[1].Escape || events[1].Pressed ||
events[0].Window != target || events[1].Window != target {
t.Fatalf("Escape was not delivered to restored browser: %+v", events)
}
command("focus", "popup")
if target := repair.observe(readSteam()); target != 0 {
t.Fatal("valid popup focus was overridden")
}
target = roundTrip()
if target != windows["popup"] || !restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("popup keyboard focus was not repaired")
}
command("focus", "browser")
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("valid search/control browser focus was overridden")
}
command("activate", "steam")
key("keydown", "Control_L")
blocked := restore_steam_focus(reader.display, sample.window, sample.pid, target)
key("keyup", "Control_L")
if blocked {
t.Fatal("focus moved while a physical modifier was held")
}
command("activate", "other")
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("repair stole focus from another application")
}
command("activate", "steam")
if restore_steam_focus(reader.display, sample.window, sample.pid, windows["otherChild"]) {
t.Fatal("repair targeted an unrelated child")
}
command("unmap", "popupHost")
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("repair focused a hidden popup")
}
command("map", "popupHost")
command("pid", "popup")
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("repair focused a child from a different process")
}
command("destroy", "popup")
if restore_steam_focus(reader.display, sample.window, sample.pid, target) {
t.Fatal("repair focused a destroyed popup")
}
command("class", "steam")
if readSteam().steam || restore_steam_focus(reader.display, sample.window, sample.pid, windows["browser"]) {
t.Fatal("repair changed focus in an ordinary application")
}
command("xterm", "steam")
match, terminal := read_command_focus(reader.display, true)
if match != 1 || !terminal.known || terminal.steam || terminal.focus != 0 {
t.Fatalf("XTerm class change = %d, %+v", match, terminal)
}
command("unknown", "steam")
match, unknown := read_command_focus(reader.display, true)
if match != 0 || unknown != (steamFocusSample{}) {
t.Fatalf("unknown foreground = %d, %+v", match, unknown)
}
}
@@ -0,0 +1,70 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package xorg
import "testing"
func TestSteamFocusRepairActivation(t *testing.T) {
repair := steamFocusRepair{}
inner := steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true}
if target := repair.observe(inner); target != 0 {
t.Fatal("valid browser focus requested a repair")
}
repair.observe(steamFocusSample{known: true, window: 20, focus: 20})
outer := inner
outer.focus, outer.innerFocus = 10, false
if target := repair.observe(outer); target != 0 {
t.Fatal("repair did not allow Steam its first restoration sample")
}
if target := repair.observe(outer); target != 12 {
t.Fatalf("repair target = %d, want remembered browser child 12", target)
}
}
func TestSteamFocusRepairPreservesControlAndWindowBoundaries(t *testing.T) {
for _, test := range []struct {
name string
sample steamFocusSample
}{
{"other application", steamFocusSample{known: true, window: 20, focus: 20}},
{"other Steam window", steamFocusSample{known: true, steam: true, window: 30, focus: 30, pid: 123}},
{"reused window with different process", steamFocusSample{known: true, steam: true, window: 10, focus: 10, pid: 456}},
{"already focused browser popup", steamFocusSample{known: true, steam: true, window: 10, focus: 14, pid: 123, innerFocus: true}},
{"external keyboard focus", steamFocusSample{known: true, steam: true, window: 10, focus: 99, pid: 123}},
{"unknown foreground", steamFocusSample{}},
} {
t.Run(test.name, func(t *testing.T) {
repair := steamFocusRepair{}
repair.observe(steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true})
repair.observe(steamFocusSample{known: true, window: 20})
for i := 0; i < 12; i++ {
if target := repair.observe(test.sample); target != 0 {
t.Fatalf("unexpected focus repair to %d", target)
}
}
})
}
}
func TestSteamFocusRepairRemembersPopupAndExpires(t *testing.T) {
repair := steamFocusRepair{}
inner := steamFocusSample{known: true, steam: true, window: 10, focus: 12, pid: 123, innerFocus: true}
repair.observe(inner)
inner.focus = 14
repair.observe(inner)
repair.observe(steamFocusSample{known: true, window: 20})
outer := inner
outer.focus, outer.innerFocus = 10, false
repair.observe(outer)
if target := repair.observe(outer); target != 14 {
t.Fatalf("target = %d, want most recently focused popup 14", target)
}
for i := 0; i < 10; i++ {
repair.observe(inner)
}
if target := repair.observe(outer); target != 0 {
t.Fatalf("repair remained armed after activation window expired: %d", target)
}
}
+27
View File
@@ -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"
}
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"fmt"
"slices"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func calcColumnLabel(column int) string {
label := ""
for column++; column > 0; column = (column - 1) / 26 {
label = string(rune('A'+(column-1)%26)) + label
}
return label
}
func calcCellLabel(cell atspi.LibreOfficeCell) string {
return fmt.Sprintf("%s%d", calcColumnLabel(cell.Column), cell.Row+1)
}
// LibreOfficeSelectionText formats copied metadata only. A large selection's
// endpoints never imply a filled rectangle. Small disjoint selections retain
// their gaps; at most eight rectangles are spoken before using a count.
func LibreOfficeSelectionText(selection atspi.LibreOfficeSelection, action string) string {
if selection.Kind == "all" {
return "All cells " + action + "."
}
if selection.Count == 0 {
return ""
}
if !selection.Complete || len(selection.Cells) > 64 {
return libreOfficeSelectionCount(selection, action)
}
cells := slices.Clone(selection.Cells)
slices.SortFunc(cells, func(a, b atspi.LibreOfficeCell) int {
if a.Row != b.Row {
return a.Row - b.Row
}
return a.Column - b.Column
})
cells = slices.Compact(cells)
type rectangle struct{ first, last atspi.LibreOfficeCell }
var ranges []rectangle
for index := 0; index < len(cells); {
first, last := cells[index], cells[index]
index++
for index < len(cells) && (selection.Kind == "rows" && cells[index].Row == last.Row+1 ||
selection.Kind != "rows" && cells[index].Row == last.Row && cells[index].Column == last.Column+1) {
last = cells[index]
index++
}
merged := false
if selection.Kind == "cells" {
for i := range ranges {
previous := &ranges[i]
if previous.first.Column == first.Column && previous.last.Column == last.Column && previous.last.Row+1 == first.Row {
previous.last.Row = last.Row
merged = true
break
}
}
}
if !merged {
ranges = append(ranges, rectangle{first, last})
}
}
if len(ranges) > 8 {
return libreOfficeSelectionCount(selection, action)
}
var messages []string
for _, span := range ranges {
first, last := calcCellLabel(span.first), calcCellLabel(span.last)
singular, plural := "Cell", "Cells"
if selection.Kind == "rows" {
first, last = fmt.Sprint(span.first.Row+1), fmt.Sprint(span.last.Row+1)
singular, plural = "Row", "Rows"
} else if selection.Kind == "columns" {
first, last = calcColumnLabel(span.first.Column), calcColumnLabel(span.last.Column)
singular, plural = "Column", "Columns"
}
if first == last {
messages = append(messages, fmt.Sprintf("%s %s %s.", singular, first, action))
} else {
messages = append(messages, fmt.Sprintf("%s %s to %s %s.", plural, first, last, action))
}
}
return strings.Join(messages, " ")
}
func libreOfficeSelectionCount(selection atspi.LibreOfficeSelection, action string) string {
prefix := ""
if selection.CountLimited {
prefix = "At least "
}
return fmt.Sprintf("%s%d %s %s.", prefix, selection.Count, selection.Kind, action)
}
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestLibreOfficeSelectionPreservesGapsAndBoundsOutput(t *testing.T) {
for _, test := range []struct {
name string
selection atspi.LibreOfficeSelection
want string
}{
{"rectangle", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 1, Column: 0}, {Row: 1, Column: 1}}}, "Cells A1 to B2 selected."},
{"disjoint", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 0, Column: 3}, {Row: 1, Column: 3}}}, "Cells A1 to B1 selected. Cells D1 to D2 selected."},
{"row gaps", atspi.LibreOfficeSelection{Kind: "rows", Count: 3, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}, {Row: 3, Column: 0}}}, "Rows 1 to 2 selected. Row 4 selected."},
{"column labels", atspi.LibreOfficeSelection{Kind: "columns", Count: 2, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 25}, {Row: 0, Column: 26}}}, "Columns Z to AA selected."},
{"large", atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000000,
First: atspi.LibreOfficeCell{Row: 0, Column: 0}, Last: atspi.LibreOfficeCell{Row: 999999, Column: 999}}, "1000000000 cells selected."},
{"capped", atspi.LibreOfficeSelection{Kind: "cells", Count: 2147483647, CountLimited: true}, "At least 2147483647 cells selected."},
{"all", atspi.LibreOfficeSelection{Kind: "all", Complete: true}, "All cells selected."},
} {
t.Run(test.name, func(t *testing.T) {
if got := LibreOfficeSelectionText(test.selection, "selected"); got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
selection := atspi.LibreOfficeSelection{Kind: "cells", Count: 9, Complete: true}
for index := 0; index < 9; index++ {
selection.Cells = append(selection.Cells, atspi.LibreOfficeCell{Row: index, Column: index})
}
if got := LibreOfficeSelectionText(selection, "unselected"); got != "9 cells unselected." {
t.Fatalf("fragmented selection = %q", got)
}
}
+46
View File
@@ -44,3 +44,49 @@ func TestLibreOfficePresentationRequiresVerifiedCellContext(t *testing.T) {
}
}
}
func TestLibreOfficeFormulaPresentation(t *testing.T) {
for _, value := range []string{"43", ""} {
snapshot := atspi.Snapshot{Role: "table cell", ApplicationProcess: "soffice.bin",
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true,
Coordinates: "C1", CellText: value, Formula: "=B1+1"}}
want := value + ", C1, has formula"
if value == "" {
want = "blank, C1, has formula"
}
if got := FocusText(snapshot); got != want {
t.Fatalf("formula cell = %q, want %q", got, want)
}
if got := LibreOfficeFormulaText(snapshot); got != "=B1+1" {
t.Fatalf("formula command = %q", got)
}
options := DefaultOptions()
reading := config.DefaultSettings().Reading
reading.OnlySpeakDisplayedText = true
options.Reading = &reading
if got := FocusTextWithOptions(snapshot, options); got != value {
t.Fatalf("displayed text includes formula metadata: %q", got)
}
snapshot.LibreOffice.Formula = ""
want = value
if value == "" {
want = "empty"
}
if got := LibreOfficeFormulaText(snapshot); got != want {
t.Fatalf("non-formula command = %q, want %q", got, want)
}
}
for _, snapshot := range []atspi.Snapshot{
{},
{Role: "paragraph", ApplicationProcess: "soffice.bin"},
{Role: "table cell", ApplicationProcess: "chromium", LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, Formula: "wrong"}},
{Role: "table cell", Defunct: true, ApplicationProcess: "soffice.bin", LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, Formula: "wrong"}},
} {
if got := LibreOfficeFormulaText(snapshot); got != "Not in a table." {
t.Fatalf("unverified cell = %q", got)
}
if got := LibreOfficeCellFormula(snapshot); got != "" {
t.Fatalf("unverified formula = %q", got)
}
}
}
+3
View File
@@ -173,6 +173,9 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech {
if reading.SpeakMisspelledIndicator && snapshot.Misspelled {
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)
+18
View File
@@ -7,6 +7,7 @@ package webscripts
import (
"regexp"
"sort"
"strconv"
"strings"
"time"
)
@@ -15,6 +16,23 @@ const steamNotificationDelay = 500 * time.Millisecond
var steamRelativeTime = regexp.MustCompile(`(?i)^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$`)
var steamShaderProgress = regexp.MustCompile(`^Processing Vulkan shaders \((\d{1,3})%\)$`)
// ShaderProgress recognizes Steam's English shader status, which Chromium
// exposes as static text rather than an AT-SPI progress bar. Match the leaf
// only: containing sections repeat this text alongside dialog controls.
func ShaderProgress(processName, eventType, role, name string) (float64, bool) {
if MatchProcess(processName) != Steam || eventType != "object:property-change:accessible-name" || role != "static" {
return 0, false
}
match := steamShaderProgress.FindStringSubmatch(strings.TrimSpace(name))
if match == nil {
return 0, false
}
percentage, err := strconv.Atoi(match[1])
return float64(percentage), err == nil && percentage <= 100
}
// NotificationRoot mirrors Python's Steam root selection: notification/alert
// ancestors take precedence over inherited live-region attributes on a leaf.
// Candidates are ordered from the event source towards its application root.
+24
View File
@@ -10,6 +10,30 @@ import (
"time"
)
func TestSteamShaderProgress(t *testing.T) {
for _, step := range []struct {
process, eventType, role, name string
percentage float64
valid bool
}{
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (0%)", 0, true},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (37%)", 37, true},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (100%)", 100, true},
{"brave", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (37%)", 0, false},
{"Chromium", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (37%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "section", "Processing Vulkan shaders (37%) SKIP", 0, false},
{"steamwebhelper", "object:state-changed:focused", "static", "Processing Vulkan shaders (37%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Download (37%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (101%)", 0, false},
{"steamwebhelper", "object:property-change:accessible-name", "static", "Processing Vulkan shaders (-1%)", 0, false},
} {
percentage, valid := ShaderProgress(step.process, step.eventType, step.role, step.name)
if valid != step.valid || valid && percentage != step.percentage {
t.Fatalf("%+v: percentage=%g valid=%t", step, percentage, valid)
}
}
}
func TestSteamNotificationMatchingAndRoot(t *testing.T) {
message := &Notification{BusName: ":1.42", RootPath: "/toast", Text: "Username is now online"}
for _, process := range []string{"Chromium", "chromium", "brave", "", "steamwebhelper.exe"} {
+2 -1
View File
@@ -54,4 +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. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic headers full-row reading Writer workflows and full topology reporting for large selections 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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
54 web-browse-braille-line src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py braille Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor internal/atspi internal/review internal/app implemented go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending. Existing browse speech assertions pass in the private fixture. Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending. Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md.
55 web-explicit-link-names src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole web Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation Storm Dragon internal/atspi internal/review internal/app in-progress go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py Private Brave/Xorg named-link and key checks passed Fixture speech verified; real Steam listening pending Named-link browse braille uses the full accessible label; physical check pending docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates
56 steam-notifications src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py web Coalesce verified Steam background toasts and present once through speech braille and notification history Storm Dragon internal/webscripts internal/atspi internal/app in-progress go test -race ./...; python3 tests/native_webscripts.py Private GTK/AT-SPI process matching and alert-root capture passed Event-loop background delivery and Chromium isolation passed; real Steam toast pending Event-loop flash verified; physical Steam toast display pending docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging
57 libreoffice-calc-basic Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py libreoffice Present Calc cells and recover logical focus around editing and sheet changes Storm Dragon internal/app internal/atspi internal/presentation in-progress GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes. Recorded speech assertions pass; audible and physical-input acceptance pending. Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending. Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic headers full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md.
58 steam-shader-progress Steam shader dialog AT-SPI events web Report Steam Vulkan shader percentages through shared progress output internal/webscripts internal/app in-progress go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py Private GTK and Brave HTML/ARIA progress checks passed. Tone requests verified; live Steam listening pending. Unrelated Braille ownership covered by unit tests; physical check pending. English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Cthulhu Browser Probe</title>
<label>Probe start <input autofocus></label>
<button id="update">Update progress</button>
<label for="native">Native progress</label>
<progress id="native" max="100" value="0"></progress>
<div id="aria" role="progressbar" aria-label="ARIA progress"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="10"></div>
<span id="shader">Processing Vulkan shaders (0%)</span>
<script>
document.getElementById('update').addEventListener('click', () => {
document.getElementById('native').value = 20;
document.getElementById('aria').setAttribute('aria-valuenow', '30');
document.getElementById('shader').textContent = 'Processing Vulkan shaders (37%)';
});
</script>
</html>
+4
View File
@@ -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:=&quot;&quot;' office:value-type="string"><text:p/></table:table-cell>
<table:table-cell table:formula='of:=&quot;two spaces&quot;' office:value-type="string" office:string-value="two spaces"><text:p>two spaces</text:p></table:table-cell>
</table:table-row>
<table:table-row>
<table:table-cell/>
+1
View File
@@ -10,6 +10,7 @@ import time
BROWSER_PROOFS = {
"progress": ("./internal/app", "^TestNativeBrowserProgress$", 30, 35, "browser-progress.html"),
"fragments": ("./internal/review", "^TestNativeBrowserFragmentLines$", 30, 35, "browser-fragments.html"),
"positions": ("./internal/atspi", "^TestNativeBrowserSetPositions$", 25, 30, "browser-positions.html"),
"tabs": ("./internal/app", "^TestNativeBrowserTabs$", 30, 35),
+30
View File
@@ -100,7 +100,37 @@ def run_inside():
application.wait(timeout=5)
def select_disjoint():
"""Mutate only the disposable fixture, without walking sheet cells."""
if os.environ.get("CTHULHU_NATIVE_LIBREOFFICE") != "1":
raise RuntimeError("Selection helper requires the private Calc harness")
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
pending = [Atspi.get_desktop(0)]
remaining = 2500
while pending and remaining:
node = pending.pop()
remaining -= 1
if node.get_role_name() == "table":
if node.get_name() == "Sheet Second":
node.clear_selection()
node.select_child(0)
node.select_child(2)
return
continue
for index in range(min(node.get_child_count(), 200)):
child = node.get_child_at_index(index)
if child is not None:
pending.append(child)
raise RuntimeError("Disposable selection fixture missing")
def main():
if sys.argv[1:] == ["--select-disjoint"]:
select_disjoint()
return
if sys.argv[1:] == ["--inside"]:
run_inside()
return
+79
View File
@@ -0,0 +1,79 @@
"""Steam-shaped X11 browser and popup fixture for a private X server."""
import json
import os
import sys
from Xlib import X, Xatom, XK, display
from Xlib.xobject.drawable import Window
def main() -> None:
connection = display.Display()
root = connection.screen().root
activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW")
pidAtom = connection.intern_atom("_NET_WM_PID")
def make_window(parent: Window, width: int, height: int) -> Window:
window = parent.create_window(0, 0, width, height, 0,
connection.screen().root_depth,
event_mask=X.KeyPressMask | X.KeyReleaseMask)
window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()])
window.map()
return window
steam = make_window(root, 800, 600)
steam.set_wm_class("steamwebhelper", "steam")
mainHost = make_window(steam, 800, 600)
browser = make_window(mainHost, 800, 600)
popupHost = make_window(steam, 300, 600)
popup = make_window(popupHost, 300, 600)
other = make_window(root, 800, 600)
other.set_wm_class("example", "Example")
otherChild = make_window(other, 800, 600)
windows = dict(steam=steam, browser=browser, popup=popup,
popupHost=popupHost, other=other, otherChild=otherChild)
root.change_property(activeAtom, Xatom.WINDOW, 32, [steam.id])
browser.set_input_focus(X.RevertToParent, X.CurrentTime)
connection.sync()
print(json.dumps({name: window.id for name, window in windows.items()}), flush=True)
for line in sys.stdin:
command = json.loads(line)
action = command["action"]
window = windows[command.get("window", "steam")]
if action == "activate":
root.change_property(activeAtom, Xatom.WINDOW, 32, [window.id])
window.set_input_focus(X.RevertToParent, X.CurrentTime)
elif action == "focus":
window.set_input_focus(X.RevertToParent, X.CurrentTime)
elif action == "unmap":
window.unmap()
elif action == "map":
window.map()
elif action == "destroy":
window.destroy()
elif action == "class":
window.set_wm_class("example", "Example")
elif action == "xterm":
window.set_wm_class("xterm", "XTerm")
elif action == "unknown":
root.delete_property(activeAtom)
elif action == "pid":
window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid() + 1])
elif action == "events":
connection.sync()
events = []
while connection.pending_events():
event = connection.next_event()
if event.type in (X.KeyPress, X.KeyRelease):
events.append({"window": event.window.id, "pressed": event.type == X.KeyPress,
"escape": event.detail == connection.keysym_to_keycode(XK.string_to_keysym("Escape"))})
print(json.dumps(events), flush=True)
continue
connection.sync()
print("ready", flush=True)
connection.close()
if __name__ == "__main__":
main()
+16
View File
@@ -6,10 +6,18 @@ that window once, retain its pending announcement across Wine control focus
updates, and introduce it again after a foreground switch. Title changes
within the window still follow the application's title-change preference.
Fresh input cancels titles still waiting for their coalescing deadline or an
application override, as well as titles already in the speech queue. Held-key
repeats retain automatic titles; Control and Escape remain explicit stop keys.
Input that initiates a window switch precedes the destination's title and must
not cancel that new introduction. Later title changes remain enabled normally.
Run the focused checks from the repository root:
```sh
go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1
CTHULHU_NATIVE_BROWSER_TESTS=editing GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off python3 tests/native_browser.py
xvfb-run -a dbus-run-session -- env CTHULHU_NATIVE_WINDOW_TITLES=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/app -run '^TestNativeAutomaticTitleDispatchLatency$' -count=1 -v
dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v
```
@@ -33,6 +41,14 @@ replacement with its user. Review and listening are required before submission.
stay quiet. With the preference enabled, changed titles should speak once.
5. If used, repeat with a Wine virtual desktop and check title messages on a
physical Braille display.
6. In Brave, use Ctrl+L and immediately type several characters. Each character
should echo and interrupt previous speech without an older title following
it. Repeat while a long title is speaking, and use Control and Escape to stop
it. Switch away and back without typing; the window title must still speak.
7. With automatic title reading enabled, change a page's title and verify it
speaks once. The editing fixture's F8 key changes its title to
"Cthulhu Browser Probe updated". Check address suggestions, deletion, and
native caret movement still announce their usual output.
Automated queue assertions do not establish physical Alt+Tab, audible speech,
installed-binary behavior, or physical Braille acceptance.