Compare commits
6
Commits
deedra
...
b5a5b86dda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a5b86dda | ||
|
|
4ac59d4ced | ||
|
|
e0c8c6198c | ||
|
|
a121c26d63 | ||
|
|
12c514aab0 | ||
|
|
9205e7280a |
No files matched your search
+133
-22
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
|
||||
state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1}
|
||||
a.state.Store(&state)
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
||||
where := make(chan struct{})
|
||||
where := make(chan input.Command)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -180,12 +180,12 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
|
||||
// atomic epoch even if the channel notification has not been selected.
|
||||
event.Source.Name = "Stale"
|
||||
a.events <- event
|
||||
where <- struct{}{}
|
||||
where <- input.WhereAmI
|
||||
expectActivationSpeech(t, queue.spoken, "Could not find current location.")
|
||||
event.Foreground = next
|
||||
event.ForegroundMismatch = true
|
||||
a.events <- event
|
||||
where <- struct{}{}
|
||||
where <- input.WhereAmI
|
||||
expectActivationSpeech(t, queue.spoken, "Could not find current location.")
|
||||
if window != 0 {
|
||||
event.ForegroundMismatch = false
|
||||
@@ -195,7 +195,7 @@ func TestForegroundRetiresQueuedEventsAndClearsLocation(t *testing.T) {
|
||||
deactivated := event
|
||||
deactivated.Type = atspi.WindowDeactivatedEvent
|
||||
a.events <- deactivated
|
||||
where <- struct{}{}
|
||||
where <- input.WhereAmI
|
||||
expectActivationSpeech(t, queue.spoken, "New, push button")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -171,6 +171,8 @@ func learnCommandDescription(command input.Command) string {
|
||||
return "Choose an application and open its preferences."
|
||||
case input.WhereAmI:
|
||||
return "Speak details of the current item."
|
||||
case input.PresentCellFormula:
|
||||
return "Present the formula for the current cell."
|
||||
case input.PresentCharacterAttributes:
|
||||
return "Read the current character's font and formatting attributes."
|
||||
case input.ReadAll:
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
// prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy.
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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." {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -28,6 +28,38 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
commands := &fakeCommands{commands: make(chan input.CommandEvent, 1)}
|
||||
requests := make(chan input.Command, 1)
|
||||
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands,
|
||||
reviewRequests: requests, stopRun: cancel}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.run(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("command worker did not stop")
|
||||
}
|
||||
}()
|
||||
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula} {
|
||||
commands.commands <- input.CommandEvent{Command: command}
|
||||
select {
|
||||
case got := <-requests:
|
||||
if got != command {
|
||||
t.Fatalf("focus request = %q, want %q", got, command)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing focus request %q", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoutesPreferencesWithoutStoppingCommandHandling(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
settings.EnableSpeech = false
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
// Each done channel closes after its worker exits. commandError is read after
|
||||
// commandDone closes, so no additional synchronization is needed.
|
||||
type runtimeWorkers struct {
|
||||
reviewRequests <-chan struct{}
|
||||
reviewRequests <-chan input.Command
|
||||
attributeRequests <-chan struct{}
|
||||
commandDone <-chan struct{}
|
||||
clipboardDone <-chan struct{}
|
||||
@@ -53,7 +53,7 @@ func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListe
|
||||
clipboardRequests := make(chan struct{}, 1)
|
||||
clipboardDone := runClipboardWorker(ctx, clipboardRequests, speech, readClipboardMessage)
|
||||
command.clipboardRequests = clipboardRequests
|
||||
reviewRequests := make(chan struct{}, 1)
|
||||
reviewRequests := make(chan input.Command, 1)
|
||||
command.reviewRequests = reviewRequests
|
||||
attributeRequests := make(chan struct{}, 1)
|
||||
command.attributeRequests = attributeRequests
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestLibreOfficeFormulaPreservesLiteralSpacesAndBoundedPrefix(t *testing.T) {
|
||||
formula := `="two spaces"`
|
||||
if got := sanitizeLibreOfficeFormula(formula); got != formula {
|
||||
t.Fatalf("formula literal changed: %q", got)
|
||||
}
|
||||
formula += strings.Repeat("猫", maxSnapshotFieldLength)
|
||||
got := sanitizeLibreOfficeFormula(formula)
|
||||
if len(got) > maxSnapshotFieldLength || !utf8.ValidString(got) || !strings.HasPrefix(formula, got) {
|
||||
t.Fatalf("invalid bounded formula prefix: bytes=%d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings {
|
||||
|
||||
func CommandLabel(command Command) string {
|
||||
switch command {
|
||||
case PresentCellFormula:
|
||||
return "Present the formula for the current cell"
|
||||
case OCRObject:
|
||||
return "Recognize object of interest"
|
||||
case OCRWindow:
|
||||
@@ -164,7 +166,7 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo
|
||||
groups[command] = append(groups[command], FormatCommandChord(chord))
|
||||
}
|
||||
if scope == "global" {
|
||||
for _, command := range []Command{PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
|
||||
for _, command := range []Command{PresentCellFormula, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
|
||||
if _, exists := groups[command]; !exists {
|
||||
groups[command] = nil
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ const (
|
||||
Preferences Command = "preferences"
|
||||
ApplicationPreferences Command = "application-preferences"
|
||||
ChooseApplicationPreferences Command = "choose-application-preferences"
|
||||
PresentCellFormula Command = "present-cell-formula"
|
||||
WhereAmI Command = "where-am-i"
|
||||
PresentCharacterAttributes Command = "present-character-attributes"
|
||||
PresentLastNotification Command = "present-last-notification"
|
||||
@@ -228,6 +229,7 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings {
|
||||
bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] = OCRWindow
|
||||
bindings[CommandChord{Symbol: 0xffab}] = ReadAll // KP_Add
|
||||
bindings[CommandChord{Symbol: keypadEnterKeySymbol}] = WhereAmI
|
||||
bindings[CommandChord{Symbol: '=', CthulhuModifier: true}] = PresentCellFormula
|
||||
bindings[CommandChord{Symbol: 0xff95, CthulhuModifier: true}] = ReviewWindowStart
|
||||
bindings[CommandChord{Symbol: 0xff9a, CthulhuModifier: true}] = ReviewWindowEnd
|
||||
bindings[CommandChord{Symbol: 0xff96, CthulhuModifier: true}] = ReviewWordAbove
|
||||
|
||||
@@ -115,6 +115,25 @@ func TestLaptopBindingRoutesWhereAmI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellFormulaBindingAndLaptopOverride(t *testing.T) {
|
||||
chord := CommandChord{Symbol: '=', CthulhuModifier: true}
|
||||
if got := DefaultCommandBindings(DesktopKeyboardLayout)[chord]; got != PresentCellFormula {
|
||||
t.Fatalf("desktop formula binding = %q", got)
|
||||
}
|
||||
if got := DefaultCommandBindings(LaptopKeyboardLayout)[chord]; got != "" {
|
||||
t.Fatalf("laptop formula should be unbound by default: %q", got)
|
||||
}
|
||||
overrides := []BindingOverride{{Layout: LaptopKeyboardLayout, Scope: "global",
|
||||
Command: PresentCellFormula, Keys: []string{"Cthulhu+="}}}
|
||||
if err := ValidateBindingOverrides(overrides); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindings, err := ResolveCommandBindings(LaptopKeyboardLayout, false, overrides)
|
||||
if err != nil || bindings[chord] != PresentCellFormula {
|
||||
t.Fatalf("laptop formula override = %q, %v", bindings[chord], err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) {
|
||||
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
|
||||
bindings := DefaultCommandBindings(layout)
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,49 @@ func TestLibreOfficePresentationRequiresVerifiedCellContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeFormulaPresentation(t *testing.T) {
|
||||
for _, value := range []string{"43", ""} {
|
||||
snapshot := atspi.Snapshot{Role: "table cell", ApplicationProcess: "soffice.bin",
|
||||
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true,
|
||||
Coordinates: "C1", CellText: value, Formula: "=B1+1"}}
|
||||
want := value + ", C1, has formula"
|
||||
if value == "" {
|
||||
want = "blank, C1, has formula"
|
||||
}
|
||||
if got := FocusText(snapshot); got != want {
|
||||
t.Fatalf("formula cell = %q, want %q", got, want)
|
||||
}
|
||||
if got := LibreOfficeFormulaText(snapshot); got != "=B1+1" {
|
||||
t.Fatalf("formula command = %q", got)
|
||||
}
|
||||
options := DefaultOptions()
|
||||
reading := config.DefaultSettings().Reading
|
||||
reading.OnlySpeakDisplayedText = true
|
||||
options.Reading = &reading
|
||||
if got := FocusTextWithOptions(snapshot, options); got != value {
|
||||
t.Fatalf("displayed text includes formula metadata: %q", got)
|
||||
}
|
||||
snapshot.LibreOffice.Formula = ""
|
||||
want = value
|
||||
if value == "" {
|
||||
want = "empty"
|
||||
}
|
||||
if got := LibreOfficeFormulaText(snapshot); got != want {
|
||||
t.Fatalf("non-formula command = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
for _, snapshot := range []atspi.Snapshot{
|
||||
{},
|
||||
{Role: "paragraph", ApplicationProcess: "soffice.bin"},
|
||||
{Role: "table cell", ApplicationProcess: "chromium", LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, Formula: "wrong"}},
|
||||
{Role: "table cell", Defunct: true, ApplicationProcess: "soffice.bin", LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, Formula: "wrong"}},
|
||||
} {
|
||||
if got := LibreOfficeFormulaText(snapshot); got != "Not in a table." {
|
||||
t.Fatalf("unverified cell = %q", got)
|
||||
}
|
||||
if got := LibreOfficeCellFormula(snapshot); got != "" {
|
||||
t.Fatalf("unverified formula = %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,9 @@ func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech {
|
||||
if reading.SpeakMisspelledIndicator && snapshot.Misspelled {
|
||||
fields = append(fields, "misspelled")
|
||||
}
|
||||
if LibreOfficeCellFormula(snapshot) != "" {
|
||||
fields = append(fields, "has formula")
|
||||
}
|
||||
for _, part := range fields {
|
||||
if part != "" && !contains(parts, part) {
|
||||
parts = append(parts, part)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
Vendored
+2
-1
@@ -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.
|
||||
|
@@ -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>
|
||||
@@ -2,6 +2,7 @@
|
||||
<office:document
|
||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
office:version="1.2"
|
||||
office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
|
||||
@@ -11,6 +12,9 @@
|
||||
<table:table-row>
|
||||
<table:table-cell office:value-type="string"><text:p>Alpha</text:p></table:table-cell>
|
||||
<table:table-cell office:value-type="float" office:value="42"><text:p>42</text:p></table:table-cell>
|
||||
<table:table-cell table:formula="of:=[.B1]+1" office:value-type="float" office:value="43"><text:p>43</text:p></table:table-cell>
|
||||
<table:table-cell table:formula='of:=""' office:value-type="string"><text:p/></table:table-cell>
|
||||
<table:table-cell table:formula='of:="two spaces"' office:value-type="string" office:string-value="two spaces"><text:p>two spaces</text:p></table:table-cell>
|
||||
</table:table-row>
|
||||
<table:table-row>
|
||||
<table:table-cell/>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user