Compare commits
2
Commits
a005be1d2e
...
6754c8ef6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6754c8ef6c | ||
|
|
7f78177786 |
No files matched your search
@@ -22,7 +22,7 @@ Use `gofmt`, exported PascalCase identifiers, private camelCase identifiers, and
|
||||
|
||||
## Behavior & Accessibility Safeguards
|
||||
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) for detailed safeguards. Compare accepted behavior with Python on `legacy`; use newer Orca as a Mozilla reference. Preserve unrelated work and active diagnostic logs; obtain authorization before replacing a running reader.
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) for detailed safeguards. Compare accepted behavior with Python on `legacy`; use current Orca as the primary reference for Mozilla and LibreOffice support. Follow [LIBREOFFICE_SUPPORT.md](LIBREOFFICE_SUPPORT.md) for Office integration boundaries. Preserve unrelated work and active diagnostic logs; obtain authorization before replacing a running reader.
|
||||
|
||||
Fix regressions in the shared subsystem when appropriate. Identify state ownership, lifetime, reset, and cancellation; keep native callbacks and input/focus handling bounded. Preserve Xorg correctness while allowing capability-based Wayland degradation. Keep Tentacles external and responsive.
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ Inspect relevant settings, bindings, speech, Braille, and toolkit behavior befor
|
||||
git show legacy:src/cthulhu/settings.py
|
||||
```
|
||||
|
||||
Use the newer Orca implementation as the first reference for Firefox and Thunderbird when available. Reference implementations guide behavior; do not mechanically copy Python class or plugin boundaries. Preserve accepted behavior unless the user chooses a deliberate difference, and describe that difference. Do not modify reference checkouts or installed readers without authorization.
|
||||
Use the newer Orca implementation as the first reference for Firefox, Thunderbird, and LibreOffice when available. [LibreOffice support](LIBREOFFICE_SUPPORT.md) describes its integration boundaries and remaining acceptance checks. Reference implementations guide behavior; do not mechanically copy Python class or plugin boundaries. Preserve accepted behavior unless the user chooses a deliberate difference, and describe that difference. Do not modify reference checkouts or installed readers without authorization.
|
||||
|
||||
Use complete messages and self-contained labels with placeholders for variable content. Keep internal states, commands, and settings identifiers independent of user-facing wording. Preserve deliberate speech/Braille segmentation, verbosity, and separate role/state announcements. Apply these rules within the change; adding translation infrastructure or sweeping existing strings requires separate scope.
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# LibreOffice integration
|
||||
|
||||
Current Orca is the primary behavioral reference for LibreOffice. Python
|
||||
Cthulhu remains available on the `legacy` branch for historical comparisons:
|
||||
`git show legacy:src/cthulhu/scripts/apps/soffice/script.py`.
|
||||
Do not switch the active Go checkout to inspect that reference.
|
||||
|
||||
## Architecture
|
||||
|
||||
Application handling runs after screen-lock and foreground validation, before
|
||||
generic focus and text filtering. `internal/app/application_behavior.go`
|
||||
routes copied accessibility events by verified provider process identity.
|
||||
The LibreOffice handler lives in `internal/app/libreoffice.go`.
|
||||
It can normalize an event, request focus adoption, or suppress presentation.
|
||||
The reader retains window ownership, input policy, pending-output cancellation,
|
||||
review, and speech/Braille delivery. Ordinary applications keep their existing
|
||||
event path. Steam's existing integration remains in place.
|
||||
|
||||
`internal/atspi/libreoffice_native.go` captures bounded Calc metadata on the
|
||||
native owner thread. It does not enumerate a sheet. The controller and
|
||||
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.
|
||||
Application code must not bypass security/foreground gates or manage its own
|
||||
speech connection, key grabs, or background event loop.
|
||||
|
||||
Keep generic table operations and genuinely shared fixes in their existing
|
||||
subsystems. Keep verified LibreOffice exceptions in the dedicated files.
|
||||
Adding later Office features should extend these boundaries, without
|
||||
reorganizing unrelated reader code or building a speculative plugin framework.
|
||||
|
||||
## First slice
|
||||
|
||||
- Calc active-cell events establish logical focus, including after editing
|
||||
and when the sheet changes.
|
||||
- Cell contents and addresses are separate. Normal speech gives the value,
|
||||
then the address; an empty value gives “blank.” Only-displayed-text mode
|
||||
omits the address and the synthetic blank label.
|
||||
- Recorded Braille preserves value spacing followed by the address. A cell
|
||||
component is not an editable text route.
|
||||
- F2 resolves a temporary editor's focused paragraph when available.
|
||||
If the paragraph appears later, its first owned text/caret event establishes
|
||||
editor focus before generic filtering.
|
||||
- Post-editing sheet focus is adopted silently. Spreadsheet capacity and
|
||||
cell/container state chatter are omitted as in Orca.
|
||||
- Ancestor focus claims are suppressed when captured ancestry identifies the
|
||||
existing focused spreadsheet object.
|
||||
- Non-editor caret events do not displace a focused cell. Selection events do
|
||||
not promote the first selected cell of a range into logical focus.
|
||||
|
||||
References inspected: Orca commit `cd58a88ed` (2026-10-14),
|
||||
`scripts/apps/soffice/script.py`, `script_utilities.py`,
|
||||
`speech_generator.py`, `braille_generator.py`, and shared
|
||||
`speech_generator.py` / `ax_utilities_table.py`.
|
||||
|
||||
## 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.
|
||||
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,
|
||||
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.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1
|
||||
/usr/bin/python3 tests/native_libreoffice.py
|
||||
python3 tests/native_atspi.py --table-only
|
||||
python3 tests/native_x11.py
|
||||
```
|
||||
|
||||
The Calc harness launches installed LibreOffice with a disposable profile,
|
||||
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.
|
||||
No active reader is replaced and no normal Office profile is changed.
|
||||
|
||||
These automated checks do not establish audible speech, physical input, or
|
||||
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
|
||||
character/word navigation in an existing cell editor and continued arrows.
|
||||
3. 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
|
||||
display on physical Braille. Text routing applies inside the editor.
|
||||
5. 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
|
||||
fixture alone is not sufficient for submission or live accessibility acceptance.
|
||||
|
||||
Initial verification against LibreOffice 26.2.6.3 passed the Calc harness,
|
||||
affected-package race tests, vet/build, GTK table/multiselection checks, and
|
||||
Xorg input checks. Browser tab and table checks also passed. Browser editing
|
||||
failed on an extra title announcement both here and on untouched commit
|
||||
`8e76d23`. Browser modes initially failed to focus a heading after reload,
|
||||
then passed on the baseline and on a rerun here; the cause of that first
|
||||
failure was not established. Physical acceptance remains pending.
|
||||
|
||||
## Calc startup focus diagnosis
|
||||
|
||||
A normal-profile startup under i3 can leave Calc accepting arrow keys while
|
||||
omitting active-cell accessibility events. In the observed case, cell events
|
||||
were followed by focus claims from the floating Form Design and Form Controls
|
||||
toolbars; subsequent arrows produced formula-bar text updates but no new cell
|
||||
events. A separate AT-SPI observer reproduced the missing events without
|
||||
Cthulhu. The affected workbook worked with a fresh profile, and a generic
|
||||
workbook reproduced the failure with a disposable copy of the normal profile.
|
||||
This establishes a profile-dependent LibreOffice focus/event problem; the
|
||||
precise profile setting responsible has not been isolated.
|
||||
|
||||
Press **Ctrl+F6** to return focus to the document, then retry the arrows.
|
||||
This directly restored active-cell events in the isolated reproduction.
|
||||
LibreOffice documents this shortcut in its
|
||||
[Calc accessibility keyboard guide](https://help.libreoffice.org/latest/en-GB/text/scalc/guide/keyboard.html).
|
||||
Automatic focus stealing would interfere with intentional toolbar use and
|
||||
is not part of the reader's handling.
|
||||
|
||||
The native Calc test focuses its window once and uses ordinary key sequences
|
||||
afterward. It also checks F6 followed by Ctrl+F6 and resumed cell speech/Braille.
|
||||
Its fresh-profile virtual display does not cover this normal-profile startup
|
||||
failure. The same production test also passed under private i3 with the copied
|
||||
normal profile after Ctrl+F6 recovery, asserting recorded cell speech and
|
||||
Braille along with editing and sheet switching. Verify startup and recovery
|
||||
under the actual window manager, plus audible speech and physical Braille,
|
||||
separately.
|
||||
@@ -4,5 +4,19 @@ 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.
|
||||
|
||||
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
|
||||
|
||||
## Wine Access
|
||||
|
||||
Install the separate `wine-access` helper for Wine application accessibility.
|
||||
Cthulhu checks `CTHULHU_WINE_ACCESS_HELPER`, then
|
||||
`~/.local/libexec/wine-access/wine-access.exe`,
|
||||
`/usr/local/libexec/wine-access/wine-access.exe`,
|
||||
`/usr/libexec/wine-access/wine-access.exe`, and `wine-access.exe` on `PATH`.
|
||||
The existing `cthulhu-wine-access.exe` lookup remains supported: the old user
|
||||
location follows the new user location, the old `/usr` location follows the new
|
||||
system locations, and the old name on `PATH` follows the new name.
|
||||
Restart Cthulhu after changing the selected helper. Keep the launcher, its
|
||||
`.exe.so` sidecar, and the native hooks together in the installed directory.
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import "git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
|
||||
type applicationEventResult struct {
|
||||
event atspi.Event
|
||||
adoptFocus bool
|
||||
suppress bool
|
||||
resetPresentation bool
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.adoptFocus {
|
||||
if handler.focusGate.Suspended() {
|
||||
return event, false
|
||||
}
|
||||
state := handler.state
|
||||
if !state.owner.follow(result.event) {
|
||||
return event, true
|
||||
}
|
||||
state.pending.caret.clear()
|
||||
state.focusedID = result.event.Source.ID()
|
||||
state.current = result.event.Source
|
||||
state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{}
|
||||
if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" {
|
||||
// A late-created editor has no ordinary focus event to reset
|
||||
// row history. Returning to its old cell is a fresh focus.
|
||||
state.lastTable, state.lastTableCell = atspi.ObjectID{}, atspi.ObjectID{}
|
||||
}
|
||||
state.selection.reset(result.event.Source)
|
||||
if handler.objectReview != nil {
|
||||
handler.objectReview.SyncFocus(result.event.Source.ID())
|
||||
}
|
||||
}
|
||||
if result.resetPresentation {
|
||||
handler.state.pending.caret.clear()
|
||||
handler.state.lastSpokenPath, handler.state.lastSpokenText, handler.state.lastSound = "", "", ""
|
||||
}
|
||||
if result.suppress {
|
||||
handler.logger.Debug("skipped application-specific accessibility event", "type", event.Type,
|
||||
"application_process", event.Source.ApplicationProcess, "role", event.Source.Role)
|
||||
}
|
||||
return result.event, result.suppress
|
||||
}
|
||||
@@ -160,6 +160,9 @@ func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOption
|
||||
}
|
||||
|
||||
func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
|
||||
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
|
||||
return frame
|
||||
}
|
||||
name, role := strings.TrimSpace(s.Name), s.Role
|
||||
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
|
||||
label := name
|
||||
|
||||
@@ -135,6 +135,11 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
}
|
||||
return
|
||||
}
|
||||
var applicationSuppressed bool
|
||||
event, applicationSuppressed = handler.prepareApplicationEvent(event)
|
||||
if applicationSuppressed {
|
||||
return
|
||||
}
|
||||
if handler.handleLifecycle(event) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,15 +181,20 @@ func processEventsWithAttributes(
|
||||
event, _ := state.pending.terminalDeletion.take()
|
||||
speak(event)
|
||||
}
|
||||
clearOwnedFocus := func() {
|
||||
clearObjectFocus := func() {
|
||||
ocrSession.Retire()
|
||||
titles.reset()
|
||||
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
|
||||
}
|
||||
clearOwnedFocus := func() {
|
||||
titles.reset()
|
||||
clearObjectFocus()
|
||||
}
|
||||
var wineReview *wineReviewFocusController
|
||||
var wineReviewChanges <-chan struct{}
|
||||
if live != nil && live.wineReview != nil {
|
||||
wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearOwnedFocus}
|
||||
// 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}
|
||||
wineReviewChanges = live.wineReview.Changes()
|
||||
wineReview.changed()
|
||||
defer wineReview.retire()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
// prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy.
|
||||
// A Calc active cell is the logical focus even when editing destroyed the old
|
||||
// focus or a sheet switch changed its managed container (LO bug 161444).
|
||||
func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner windowOwner) applicationEventResult {
|
||||
result := applicationEventResult{event: event}
|
||||
context := event.Source.LibreOffice
|
||||
if !atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) ||
|
||||
event.Source.Defunct || !event.Source.ID().Valid() ||
|
||||
owner.window.Valid() && (eventWindow(event) != owner.window || event.Source.BusName != owner.applicationBus) {
|
||||
return result
|
||||
}
|
||||
if isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent &&
|
||||
current.LibreOffice != nil && current.LibreOffice.InSpreadsheet {
|
||||
// LibreOffice also claims focus for ancestors of the real focused
|
||||
// object. Orca ignores these; copied ancestry is enough to identify
|
||||
// them without another native tree query.
|
||||
for _, ancestor := range current.BrailleContext {
|
||||
if ancestor.ID().Valid() && ancestor.ID() == event.Source.ID() {
|
||||
result.suppress = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
if context != nil && context.InSpreadsheet && context.Cell && event.Source.Role == "table cell" &&
|
||||
event.Type == atspi.ActiveDescendantEvent &&
|
||||
(event.Source.Focused || context.ContainerFocused) {
|
||||
// Preserve NativeType/OriginPath for diagnostics. Ordinary focus
|
||||
// handling now owns review, duplicate suppression, and Braille.
|
||||
if result.event.NativeType == "" {
|
||||
result.event.NativeType = event.Type
|
||||
}
|
||||
result.event.Type = atspi.FocusEvent
|
||||
}
|
||||
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.
|
||||
result.suppress = true
|
||||
}
|
||||
if context != nil && context.InSpreadsheet && event.Source.Role == "table" &&
|
||||
isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent &&
|
||||
current.LibreOffice != nil &&
|
||||
(current.LibreOffice.CellEditor || current.LibreOffice.Cell && current.TablePath == event.Source.Path) {
|
||||
// Orca treats this as the sheet reclaiming focus after editing.
|
||||
// Own it silently; the following active cell supplies presentation.
|
||||
result.adoptFocus, result.suppress = true, true
|
||||
}
|
||||
if context != nil && context.Cell && isFocusGainEvent(result.event) && current.ID() != event.Source.ID() {
|
||||
// Returning to the cell after typing must speak it even if its
|
||||
// value is unchanged; typing uses a separate presentation history.
|
||||
result.resetPresentation = true
|
||||
}
|
||||
if context != nil && context.CellEditor && event.Source.Role == "paragraph" &&
|
||||
(isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent) &&
|
||||
owner.window.Valid() && event.Source.ID() != current.ID() &&
|
||||
(event.Source.Focused || context.EditorParent == current.ID() && current.LibreOffice != nil && current.LibreOffice.CellEditor) {
|
||||
// Empty-cell F2 can focus a panel before its paragraph exists.
|
||||
// Admit that editor's first mutation before generic text filtering.
|
||||
result.adoptFocus = true
|
||||
}
|
||||
if event.Type == atspi.CaretEvent && current.LibreOffice != nil && current.LibreOffice.Cell &&
|
||||
(context == nil || !context.CellEditor) {
|
||||
// Formula-bar/background caret events must not displace the cell.
|
||||
result.suppress = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func libreOfficeBrailleFrame(snapshot atspi.Snapshot, options config.BrailleOptions) (braille.Frame, bool) {
|
||||
context := snapshot.LibreOffice
|
||||
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Role != "table cell" ||
|
||||
context == nil || !context.InSpreadsheet || !context.Cell {
|
||||
return braille.Frame{}, false
|
||||
}
|
||||
// Orca presents the value followed by its cell address. This is a
|
||||
// component, not an editable-text route; cell focus has no text caret.
|
||||
return braille.Frame{Text: brailleJoin(context.CellText, context.Coordinates), Cursor: 0,
|
||||
WordWrap: options.WordWrap}, true
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
type calcFrameQueue struct{ frames chan braille.Frame }
|
||||
|
||||
func (queue calcFrameQueue) EnqueueFrame(frame braille.Frame) { queue.frames <- frame }
|
||||
|
||||
// This runs the production native adapter and event loop against real Calc.
|
||||
// Speech/Braille are recorded queues, not audible or physical display proof.
|
||||
func TestNativeLibreOfficeCalc(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_LIBREOFFICE") != "1" {
|
||||
t.Skip("requires tests/native_libreoffice.py")
|
||||
}
|
||||
window := os.Getenv("CTHULHU_CALC_WINDOW")
|
||||
if window == "" {
|
||||
t.Fatal("Calc window missing")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer stopCancel()
|
||||
if err := adapter.Stop(stopCtx); err != nil {
|
||||
t.Errorf("stop adapter: %v", err)
|
||||
}
|
||||
}()
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 256)}
|
||||
frames := make(chan braille.Frame, 256)
|
||||
done := make(chan error, 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)
|
||||
}()
|
||||
defer func() {
|
||||
cancel()
|
||||
<-done
|
||||
if t.Failed() {
|
||||
t.Log(logBuffer.String())
|
||||
}
|
||||
}()
|
||||
press := func(keys ...string) {
|
||||
t.Helper()
|
||||
args := append([]string{"key", "--clearmodifiers"}, keys...)
|
||||
if err := exec.CommandContext(ctx, "xdotool", args...).Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
waitSpeech := func(want string) {
|
||||
t.Helper()
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
for {
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
t.Logf("Calc speech: %q", got)
|
||||
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
|
||||
t.Fatalf("Calc capacity/state chatter: %q", got)
|
||||
}
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
t.Fatalf("missing Calc speech %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
waitFrame := func(suffix string) {
|
||||
t.Helper()
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
for {
|
||||
select {
|
||||
case frame := <-frames:
|
||||
if strings.HasSuffix(frame.Text, suffix) {
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
t.Fatalf("missing Calc Braille ending %q", suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
enterEditor := func(coordinate string) {
|
||||
t.Helper()
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case <-frames:
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
press("F2")
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
for {
|
||||
select {
|
||||
case frame := <-frames:
|
||||
if strings.Contains(frame.Text, "Cell "+coordinate) {
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
t.Fatal("Calc did not expose its temporary cell editor")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Focus once. Refocusing before every key sequence can hide failures
|
||||
// to retain or recover Calc's document focus during normal keyboard use.
|
||||
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
press("Escape", "ctrl+Home", "Right")
|
||||
waitSpeech("42, B1")
|
||||
waitFrame("42 B1")
|
||||
press("Down")
|
||||
waitSpeech("Beta, B2")
|
||||
press("Left")
|
||||
waitSpeech("blank, A2")
|
||||
waitFrame("A2")
|
||||
enterEditor("A2")
|
||||
press("x")
|
||||
waitSpeech("x")
|
||||
press("Escape")
|
||||
waitSpeech("blank, A2")
|
||||
enterEditor("A2")
|
||||
press("y")
|
||||
waitSpeech("y")
|
||||
press("Return")
|
||||
waitSpeech("blank, A3")
|
||||
press("Up")
|
||||
waitSpeech("y, A2")
|
||||
press("ctrl+Page_Down", "ctrl+Home")
|
||||
waitSpeech("Gamma, A1")
|
||||
press("ctrl+Page_Up", "ctrl+Home")
|
||||
waitSpeech("Alpha, A1")
|
||||
press("Right")
|
||||
waitSpeech("42, B1")
|
||||
enterEditor("B1")
|
||||
press("Left")
|
||||
waitSpeech("2")
|
||||
press("Escape")
|
||||
waitSpeech("42, B1")
|
||||
press("F6")
|
||||
press("ctrl+F6", "Down")
|
||||
waitSpeech("Beta, B2")
|
||||
waitFrame("Beta B2")
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
func calcTestEvent(eventType, path, value, coordinate string) atspi.Event {
|
||||
source := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "table cell", Name: coordinate,
|
||||
Text: value, ApplicationProcess: "soffice.bin", Focused: true,
|
||||
TablePath: "/sheet", TablePositionKnown: true,
|
||||
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: coordinate}}
|
||||
event := atspi.NewEvent(eventType, source)
|
||||
event.NativeType = eventType
|
||||
event.Window = atspi.NewObjectID(source.BusName, "/window")
|
||||
event.OriginPath = "/sheet"
|
||||
return event
|
||||
}
|
||||
|
||||
func TestLibreOfficeMovementEditingAndFocusReturn(t *testing.T) {
|
||||
state := eventLoopState{}
|
||||
state.presentationOptions = presentation.DefaultOptions()
|
||||
state.presentationOptions.EnableEchoByCharacter = true
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 32)}
|
||||
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
|
||||
speech: speech, adapter: &fakeAdapter{}, clearOwnedFocus: func() {}, syncForeground: func() {}}
|
||||
presenter := eventPresenter{state: &state, logger: handler.logger, speech: speech}
|
||||
handler.speak = presenter.speak
|
||||
handler.titles = &windowTitleController{state: &state, speech: speech, logger: handler.logger}
|
||||
handler.webScripts = &webScriptController{speech: speech}
|
||||
expectSpeech := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != want {
|
||||
t.Fatalf("Calc speech = %q, want %q", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing Calc speech %q", want)
|
||||
}
|
||||
}
|
||||
first := calcTestEvent(atspi.ActiveDescendantEvent, "/cell1", "42", "B1")
|
||||
first.Source.TableColumnIndex = 1
|
||||
handler.handle(first)
|
||||
expectSpeech("42, B1")
|
||||
ancestor := first
|
||||
ancestor.Type = atspi.FocusEvent
|
||||
ancestor.Source.Path, ancestor.Source.Role, ancestor.Source.Name = "/root_pane", "root pane", "Example"
|
||||
ancestor.Source.LibreOffice = nil
|
||||
state.current.BrailleContext = []atspi.Snapshot{ancestor.Source}
|
||||
handler.handle(ancestor)
|
||||
if state.current.ID() != first.Source.ID() || len(speech.spoken) != 0 {
|
||||
t.Fatal("ancestor focus displaced the active cell")
|
||||
}
|
||||
handler.handle(first)
|
||||
if len(speech.spoken) != 0 {
|
||||
t.Fatal("repeated cell descendant was spoken")
|
||||
}
|
||||
blank := calcTestEvent(atspi.ActiveDescendantEvent, "/cell2", "", "A2")
|
||||
blank.Source.TableRowIndex = 1
|
||||
handler.handle(blank)
|
||||
expectSpeech("blank, A2")
|
||||
backgroundCaret := blank
|
||||
backgroundCaret.Type = atspi.CaretEvent
|
||||
backgroundCaret.Source.Path = "/input_line"
|
||||
backgroundCaret.Source.Role = "paragraph"
|
||||
backgroundCaret.Source.Text = "unrelated"
|
||||
backgroundCaret.Source.LibreOffice = nil
|
||||
handler.handle(backgroundCaret)
|
||||
if state.current.ID() != blank.Source.ID() || len(speech.spoken) != 0 {
|
||||
t.Fatal("formula-bar caret displaced cell focus")
|
||||
}
|
||||
editor := blank
|
||||
editor.Type = atspi.TextInsertedEvent
|
||||
editor.Source.Path, editor.Source.Role, editor.Source.Name = "/editor", "paragraph", ""
|
||||
editor.Source.Editable = true
|
||||
editor.Source.Text, editor.Payload = "x", "x"
|
||||
editor.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, CellEditor: true}
|
||||
handler.handle(editor)
|
||||
if state.current.ID() != editor.Source.ID() || state.focusedID != editor.Source.ID() {
|
||||
t.Fatal("first editor mutation was filtered before focus adoption")
|
||||
}
|
||||
expectSpeech("x")
|
||||
table := editor
|
||||
table.Type = atspi.FocusEvent
|
||||
table.Source.Path, table.Source.Role, table.Source.Name = "/sheet", "table", "Sheet Example"
|
||||
table.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true}
|
||||
handler.handle(table)
|
||||
if state.current.ID() != table.Source.ID() || len(speech.spoken) != 0 {
|
||||
t.Fatal("post-edit table focus was not owned silently")
|
||||
}
|
||||
handler.handle(blank)
|
||||
expectSpeech("blank, A2")
|
||||
otherSheet := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "Gamma", "A1")
|
||||
otherSheet.OriginPath = "/other_sheet"
|
||||
otherSheet.Source.TablePath = "/other_sheet"
|
||||
handler.handle(otherSheet)
|
||||
expectSpeech("Gamma, A1")
|
||||
selection := blank
|
||||
selection.Type = atspi.SelectionChangedEvent
|
||||
handler.handle(selection)
|
||||
if state.current.ID() != otherSheet.Source.ID() || len(speech.spoken) != 0 {
|
||||
t.Fatal("range selection displaced the active cell")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePolicyCannotAdoptAnotherWindowOrProcess(t *testing.T) {
|
||||
current := calcTestEvent(atspi.FocusEvent, "/cell", "42", "B1")
|
||||
owner := windowOwner{window: current.Window, applicationBus: current.Source.BusName}
|
||||
for _, change := range []func(*atspi.Event){
|
||||
func(event *atspi.Event) { event.Window.Path = "/other_window" },
|
||||
func(event *atspi.Event) { event.Source.ApplicationProcess = "chromium" },
|
||||
func(event *atspi.Event) { event.Source.Focused = false },
|
||||
func(event *atspi.Event) { event.Source.Defunct = true },
|
||||
} {
|
||||
event := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "wrong", "C1")
|
||||
change(&event)
|
||||
result := prepareLibreOfficeEvent(event, current.Source, owner)
|
||||
if result.event.Type != atspi.ActiveDescendantEvent || result.adoptFocus {
|
||||
t.Fatalf("unowned cell became focus: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeBraillePreservesSpacingAndHasNoEditorRoute(t *testing.T) {
|
||||
event := calcTestEvent(atspi.FocusEvent, "/cell", "é 猫", "B3")
|
||||
frame := brailleFocusFrameWithOptions(event.Source, config.DefaultSettings().Braille)
|
||||
if frame.Text != "é 猫 B3" || frame.Cursor != 0 || len(frame.Routes) != 0 {
|
||||
t.Fatalf("Calc Braille = %+v", frame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeRoutingHonorsLockAndForegroundBeforePolicy(t *testing.T) {
|
||||
for _, locked := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "locked", false: "stale foreground"}[locked], func(t *testing.T) {
|
||||
current := calcTestEvent(atspi.FocusEvent, "/current", "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.lastSpokenText = "unchanged"
|
||||
gate := newFocusGate(nil)
|
||||
gate.secure.Store(locked)
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
handler := accessibilityEventHandler{state: &state, focusGate: gate, speech: speech,
|
||||
logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
|
||||
foreground: &ocrTestForeground{state: state.foregroundState},
|
||||
syncForeground: func() {}, webScripts: &webScriptController{}}
|
||||
event := calcTestEvent(atspi.ActiveDescendantEvent, "/new_cell", "wrong", "C1")
|
||||
event.Foreground.Epoch = 1
|
||||
handler.handle(event)
|
||||
if state.current.ID() != current.Source.ID() || state.lastSpokenText != "unchanged" || len(speech.spoken) != 0 {
|
||||
t.Fatal("LibreOffice policy ran before security/foreground rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
"git.stormux.org/storm/cthulhu/internal/wine"
|
||||
)
|
||||
|
||||
type windowTitleSource interface {
|
||||
@@ -151,10 +152,16 @@ func (titles *windowTitleController) nativeChanged() {
|
||||
}
|
||||
switched := latest.Epoch != previous.Epoch || latest.Title.Window != previous.Title.Window ||
|
||||
latest.Title.TitleWindow != previous.Title.TitleWindow
|
||||
// Wine review establishes navigation ownership without speaking an
|
||||
// activation. Its stored title is metadata, not an announced title.
|
||||
accessibleActivation := titles.state.owner.activated && !wine.IsReviewID(titles.state.owner.window)
|
||||
if switched {
|
||||
titles.clearPending()
|
||||
// An AT-SPI activation may already have introduced this client.
|
||||
titles.lastTitle = titles.state.owner.title
|
||||
if wine.IsReviewID(titles.state.owner.window) {
|
||||
titles.lastTitle = ""
|
||||
}
|
||||
if strings.EqualFold(latest.Title.Application, "xterm") {
|
||||
titles.lastTitle = ""
|
||||
}
|
||||
@@ -167,7 +174,7 @@ func (titles *windowTitleController) nativeChanged() {
|
||||
changed := !switched
|
||||
// AT-SPI can introduce a window before its first X11 title sample. A
|
||||
// later differing sample is still a title change, not a new activation.
|
||||
if switched && titles.state.owner.activated && titles.state.owner.title != "" &&
|
||||
if switched && accessibleActivation && titles.state.owner.title != "" &&
|
||||
latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") {
|
||||
changed = true
|
||||
}
|
||||
@@ -212,7 +219,7 @@ func (titles *windowTitleController) flush() {
|
||||
}
|
||||
pending.window = titles.state.owner.window
|
||||
pending.application = titles.application
|
||||
if !pending.window.Valid() || pending.native.TitleWindow != pending.native.Window {
|
||||
if !pending.window.Valid() || wine.IsReviewID(pending.window) || pending.native.TitleWindow != pending.native.Window {
|
||||
pending.application = pending.native.Application
|
||||
}
|
||||
} else if pending.window != titles.state.owner.window {
|
||||
|
||||
@@ -6,6 +6,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -395,3 +396,97 @@ func TestWineTitleReportsCannotClaimUnrelatedForeground(t *testing.T) {
|
||||
t.Fatal("background helper title was announced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineReviewFocusDoesNotSuppressTitleIntroduction(t *testing.T) {
|
||||
for _, desktop := range []bool{false, true} {
|
||||
for _, reviewFirst := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("desktop=%t/reviewFirst=%t", desktop, reviewFirst), func(t *testing.T) {
|
||||
c, registry, source, worker := reviewFocusSetup(t)
|
||||
titles, queue, _ := titleControllerFixture(t)
|
||||
titles.state = c.state
|
||||
titles.source, titles.foreground = source, source
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
if desktop {
|
||||
source.title.Title.TitleWindow = 21
|
||||
source.title.Title.Client.Window = 21
|
||||
}
|
||||
introduce := func() {
|
||||
if reviewFirst {
|
||||
c.changed()
|
||||
}
|
||||
titles.nativeChanged()
|
||||
c.changed() // The event loop syncs review focus again before flushing.
|
||||
titles.wineChanged(wineWindowTitle{text: "Same title", foreground: source.title})
|
||||
titles.flush()
|
||||
}
|
||||
introduce()
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != "Same title" {
|
||||
t.Fatalf("Wine introduction = %+v", queue.requests)
|
||||
}
|
||||
if worker.Focus() != registry.notices[0].ID() || c.state.owner.title != "Same title" {
|
||||
t.Fatal("title presentation changed Wine review ownership")
|
||||
}
|
||||
c.changed()
|
||||
titles.nativeChanged()
|
||||
titles.wineChanged(wineWindowTitle{text: "Same title", foreground: source.title})
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("duplicate Wine title was spoken")
|
||||
}
|
||||
registry.notices[0].Title = "Status changed"
|
||||
source.title.Title.Title = "Status changed"
|
||||
c.changed()
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("Wine title change bypassed disabled preference")
|
||||
}
|
||||
// Returning to the same Wine window must introduce it again.
|
||||
c.retire()
|
||||
source.state.Epoch++
|
||||
source.title.Epoch = source.state.Epoch
|
||||
registry.notices[0].Title = "Same title"
|
||||
source.title.Title.Title = "Same title"
|
||||
introduce()
|
||||
if len(queue.requests) != 2 || queue.requests[1].Text != "Same title" {
|
||||
t.Fatalf("return to Wine window = %+v", queue.requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWineReviewTitlesUseNativeApplicationPreferences(t *testing.T) {
|
||||
c, registry, source, _ := reviewFocusSetup(t)
|
||||
titles, queue, _ := titleControllerFixture(t)
|
||||
titles.state = c.state
|
||||
titles.source, titles.foreground = source, source
|
||||
var application string
|
||||
titles.live.onApplication = func(name string) { application = name }
|
||||
previousApplication := "Other Application"
|
||||
titles.live.application.Store(&previousApplication)
|
||||
c.changed()
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if application != "fixture.exe" || len(queue.requests) != 0 || titles.pending == nil {
|
||||
t.Fatalf("Wine title raced application settings: application=%q requests=%+v", application, queue.requests)
|
||||
}
|
||||
settings := titles.live.Load()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
titles.live.Store(settings)
|
||||
titles.live.application.Store(&application)
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 || queue.requests[0].Text != "Same title" {
|
||||
t.Fatalf("Wine introduction = %+v", queue.requests)
|
||||
}
|
||||
registry.notices[0].Title = "Changed status"
|
||||
source.title.Title.Title = "Changed status"
|
||||
c.changed()
|
||||
titles.nativeChanged()
|
||||
titles.flush()
|
||||
if len(queue.requests) != 1 {
|
||||
t.Fatal("Wine title ignored its application preference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/wine"
|
||||
)
|
||||
|
||||
type wineTitleLoopAdapter struct {
|
||||
fakeAdapter
|
||||
*reviewFocusMonitor
|
||||
}
|
||||
|
||||
func TestPrivateWineTitleSurvivesReviewFocusBurst(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, worker := 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 := &wineTitleLoopAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event)}, reviewFocusMonitor: monitor}
|
||||
live := &liveSettings{wineReview: registry}
|
||||
settings := config.DefaultSettings()
|
||||
settings.Reading.AutomaticallyReadTitleChanges = false
|
||||
live.Store(settings)
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- processEventsWithInput(ctx, logger, queue, adapter, nil, worker, 0, live, nil, nil, nil)
|
||||
}()
|
||||
defer func() {
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
for worker.Focus() != notice.ID() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("Wine review did not establish focus")
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
// Wine publishes the frame, then its focused edit control while the
|
||||
// native title introduction is waiting. Both belong to the same window.
|
||||
notice.Focus = "edit"
|
||||
notice.Revision++
|
||||
if !registry.PresentFocus(notice.Sender, notice) {
|
||||
t.Fatal("could not publish focused edit control")
|
||||
}
|
||||
select {
|
||||
case request := <-queue.requests:
|
||||
if request.Text != "Same title" || request.Cause != "window-title" || !request.Append {
|
||||
t.Fatalf("Wine window introduction = %+v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Wine review focus burst cancelled the title introduction")
|
||||
}
|
||||
notice.Revision++
|
||||
registry.PresentFocus(notice.Sender, notice)
|
||||
select {
|
||||
case request := <-queue.requests:
|
||||
t.Fatalf("Wine control revision repeated the title: %+v", request)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,8 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
|
||||
focusEvent.Source = snapshot
|
||||
}
|
||||
if !focusEvent.ForegroundMismatch {
|
||||
captureLibreOfficeContext(target, &snapshot)
|
||||
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)
|
||||
for _, ancestor := range snapshot.SpeechContext {
|
||||
@@ -415,7 +417,11 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
return
|
||||
}
|
||||
snapshotStarted := time.Now()
|
||||
target, ownedTarget := selectNativeEventTarget(event, eventType)
|
||||
target := adapter.libreOfficeFocusTarget(event, eventType)
|
||||
ownedTarget := target != nil
|
||||
if target == nil {
|
||||
target, ownedTarget = selectNativeEventTarget(event, eventType)
|
||||
}
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
// 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.
|
||||
type LibreOfficeContext struct {
|
||||
InSpreadsheet bool
|
||||
Cell bool
|
||||
CellEditor bool
|
||||
EditorParent ObjectID
|
||||
ContainerFocused bool
|
||||
CellText string
|
||||
Coordinates string
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// libreOfficeFocusTarget resolves only a Calc editor's direct text children.
|
||||
// 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 ||
|
||||
!IsLibreOfficeProcess(adapter.applicationProcess(event.source)) {
|
||||
return nil
|
||||
}
|
||||
role := C.atspi_accessible_get_role(event.source, nil)
|
||||
if role == C.ATSPI_ROLE_TABLE {
|
||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||
if parent != nil {
|
||||
if C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET {
|
||||
// Orca clears stale table state around Calc focus transitions.
|
||||
C.atspi_accessible_clear_cache_single(event.source)
|
||||
}
|
||||
C.g_object_unref(C.gpointer(parent))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if role != C.ATSPI_ROLE_PANEL && role != C.ATSPI_ROLE_EXTENDED {
|
||||
return nil
|
||||
}
|
||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
|
||||
C.g_object_unref(C.gpointer(parent))
|
||||
if !spreadsheet {
|
||||
return nil
|
||||
}
|
||||
count := min(int(C.atspi_accessible_get_child_count(event.source, nil)), 4)
|
||||
for index := 0; index < count; index++ {
|
||||
child := C.atspi_accessible_get_child_at_index(event.source, C.gint(index), nil)
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
states := C.atspi_accessible_get_state_set(child)
|
||||
focused := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 &&
|
||||
C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0
|
||||
if states != nil {
|
||||
C.g_object_unref(C.gpointer(states))
|
||||
}
|
||||
if focused && C.atspi_accessible_get_role(child, nil) == C.ATSPI_ROLE_PARAGRAPH {
|
||||
return child
|
||||
}
|
||||
C.g_object_unref(C.gpointer(child))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) {
|
||||
if !IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Defunct {
|
||||
return
|
||||
}
|
||||
switch snapshot.Role {
|
||||
case "table", "table cell", "paragraph", "panel", "extended":
|
||||
default:
|
||||
return
|
||||
}
|
||||
context := LibreOfficeContext{}
|
||||
parentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID)
|
||||
grandparentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID)
|
||||
current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
||||
for depth := 0; current != nil && depth < 32; depth++ {
|
||||
role := C.atspi_accessible_get_role(current, nil)
|
||||
if depth == 1 {
|
||||
parentRole = role
|
||||
context.EditorParent = nativeObjectID(current)
|
||||
}
|
||||
if depth == 2 {
|
||||
grandparentRole = role
|
||||
}
|
||||
if role == C.ATSPI_ROLE_TABLE {
|
||||
states := C.atspi_accessible_get_state_set(current)
|
||||
if states != nil {
|
||||
context.ContainerFocused = C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0
|
||||
C.g_object_unref(C.gpointer(states))
|
||||
}
|
||||
}
|
||||
if role == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET {
|
||||
context.InSpreadsheet = true
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = nil
|
||||
break
|
||||
}
|
||||
if role == C.ATSPI_ROLE_FRAME || role == C.ATSPI_ROLE_DIALOG || role == C.ATSPI_ROLE_APPLICATION {
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = nil
|
||||
break
|
||||
}
|
||||
parent := C.atspi_accessible_get_parent(current, nil)
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = parent
|
||||
}
|
||||
if current != nil {
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
}
|
||||
if !context.InSpreadsheet {
|
||||
return
|
||||
}
|
||||
context.Cell = snapshot.Role == "table cell"
|
||||
context.CellEditor = (snapshot.Role == "panel" || snapshot.Role == "extended") &&
|
||||
parentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET ||
|
||||
snapshot.Role == "paragraph" && (parentRole == C.ATSPI_ROLE_PANEL || parentRole == C.ATSPI_ROLE_EXTENDED) &&
|
||||
grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
|
||||
if context.Cell {
|
||||
context.Coordinates = snapshot.Name
|
||||
attributes := C.atspi_accessible_get_attributes(source, nil)
|
||||
if attributes != nil {
|
||||
columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext")
|
||||
column := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(columnKey))))
|
||||
row := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(rowKey))))
|
||||
C.free(unsafe.Pointer(columnKey))
|
||||
C.free(unsafe.Pointer(rowKey))
|
||||
if column != "" && row != "" {
|
||||
context.Coordinates = sanitizeSnapshotField(column + row)
|
||||
}
|
||||
C.g_hash_table_unref(attributes)
|
||||
}
|
||||
text := C.atspi_accessible_get_text_iface(source)
|
||||
if text != nil {
|
||||
// Calc's value is Text, while Name is the coordinate. Cell caret
|
||||
// offsets can be invalid, so capture contents independently.
|
||||
length := max(0, min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength))
|
||||
content := C.atspi_text_get_text(text, 0, C.gint(length), nil)
|
||||
context.CellText = sanitizeTextContext(C.GoString(content))
|
||||
C.g_free(C.gpointer(content))
|
||||
C.g_object_unref(C.gpointer(text))
|
||||
}
|
||||
}
|
||||
snapshot.Spreadsheet = true
|
||||
snapshot.LibreOffice = &context
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type Snapshot struct {
|
||||
SpeechContext []Snapshot // Bounded copied ancestor metadata, without native handles.
|
||||
BrailleContext []Snapshot // Nearest ancestor first, including window/application.
|
||||
Spreadsheet bool
|
||||
LibreOffice *LibreOfficeContext
|
||||
// BrowserTab identifies browser chrome, excluding page-authored tab widgets.
|
||||
BrowserTab bool
|
||||
// ExplicitName distinguishes an author-supplied link label from a name
|
||||
|
||||
@@ -104,7 +104,8 @@ static int usable_child(Display *display, Window window, WindowTitle *result) {
|
||||
const char *identifiers[] = {hint.res_name, hint.res_class};
|
||||
int infrastructure = 0;
|
||||
for (unsigned int i = 0; i < 2; i++) {
|
||||
if (identifiers[i] && (!strcasecmp(identifiers[i], "cthulhu-wine-access.exe") ||
|
||||
if (identifiers[i] && (!strcasecmp(identifiers[i], "wine-access.exe") ||
|
||||
!strcasecmp(identifiers[i], "cthulhu-wine-access.exe") ||
|
||||
(!strcasecmp(identifiers[i], "nvda.exe") && !strcasecmp(title, "NVDA"))))
|
||||
infrastructure = 1;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestNativeWindowTitleDiscovery(t *testing.T) {
|
||||
foundExample = true
|
||||
case "game.exe":
|
||||
foundGame = application.Wine
|
||||
case "cthulhu-wine-access.exe", "hidden.exe":
|
||||
case "wine-access.exe", "cthulhu-wine-access.exe", "hidden.exe":
|
||||
t.Fatalf("infrastructure/hidden Wine window listed: %+v", application)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// Normalize only the presentation copy. Native names remain coordinates for
|
||||
// ownership and diagnostics. Orca's soffice generator speaks contents first,
|
||||
// then the coordinate; an empty Text value is a blank cell, not a missing name.
|
||||
func libreOfficeFocusSnapshot(snapshot atspi.Snapshot, options Options) atspi.Snapshot {
|
||||
context := snapshot.LibreOffice
|
||||
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) ||
|
||||
context == nil || !context.InSpreadsheet {
|
||||
return snapshot
|
||||
}
|
||||
if snapshot.Role == "table" {
|
||||
// Orca omits table size for spreadsheets. These dimensions describe
|
||||
// the sheet capacity, not the populated data.
|
||||
snapshot.TableRowCount, snapshot.TableColumnCount = 0, 0
|
||||
snapshot.Multiselectable, snapshot.SensitiveKnown = false, false
|
||||
return snapshot
|
||||
}
|
||||
if context.CellEditor && snapshot.Role == "paragraph" {
|
||||
snapshot.Role = ""
|
||||
snapshot.SensitiveKnown = false
|
||||
return snapshot
|
||||
}
|
||||
if snapshot.Role != "table cell" || !context.Cell {
|
||||
return snapshot
|
||||
}
|
||||
snapshot.Name = strings.TrimSpace(context.CellText)
|
||||
snapshot.Text = context.Coordinates
|
||||
// Calc cell formatting in Orca omits container selection capabilities
|
||||
// and insensitive-state chatter; these bits are not cell availability.
|
||||
snapshot.Multiselectable = false
|
||||
snapshot.SensitiveKnown = false
|
||||
if reading_options(options).OnlySpeakDisplayedText {
|
||||
snapshot.Text = ""
|
||||
return snapshot
|
||||
}
|
||||
if snapshot.Name == "" {
|
||||
snapshot.Name = "blank"
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
func TestLibreOfficeCellValueCoordinatesAndDisplayedText(t *testing.T) {
|
||||
for _, value := range []string{"42", "", "é 猫"} {
|
||||
snapshot := atspi.Snapshot{Role: "table cell", Name: "B3", Text: value, ApplicationProcess: "soffice.bin",
|
||||
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: "B3"}}
|
||||
want := value + ", B3"
|
||||
if value == "" {
|
||||
want = "blank, B3"
|
||||
}
|
||||
if got := FocusText(snapshot); got != want {
|
||||
t.Fatalf("cell %q = %q, want %q", value, got, want)
|
||||
}
|
||||
reading := config.DefaultSettings().Reading
|
||||
reading.OnlySpeakDisplayedText = true
|
||||
options := DefaultOptions()
|
||||
options.Reading = &reading
|
||||
if got := FocusTextWithOptions(snapshot, options); got != value {
|
||||
t.Fatalf("displayed cell %q = %q", value, got)
|
||||
}
|
||||
if snapshot.Name != "B3" || snapshot.Text != value {
|
||||
t.Fatal("presentation mutated native semantics")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePresentationRequiresVerifiedCellContext(t *testing.T) {
|
||||
for _, process := range []string{"", "chromium", "steamwebhelper"} {
|
||||
snapshot := atspi.Snapshot{Role: "table cell", Name: "B3", Text: "42", ApplicationProcess: process,
|
||||
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: "wrong", Coordinates: "wrong"}}
|
||||
if got := FocusText(snapshot); got != "B3, 42" {
|
||||
t.Fatalf("other process %q got LibreOffice policy: %q", process, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ func FocusTextWithOptions(snapshot atspi.Snapshot, options Options) string {
|
||||
}
|
||||
|
||||
func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech {
|
||||
snapshot = libreOfficeFocusSnapshot(snapshot, options)
|
||||
// GTK sliders can expose the previous displayed number through Text
|
||||
// while Value already contains the current number. Present Value once.
|
||||
if snapshot.Role == "slider" && controlValue(snapshot) != "" {
|
||||
@@ -214,7 +215,8 @@ func EventSpeechWithOptions(event atspi.Event, options Options) Speech {
|
||||
focus := speech.Text
|
||||
if event.Source.Role == "table cell" && event.Source.TableName != "" {
|
||||
table := atspi.Snapshot{Role: "table", TableRowCount: event.Source.TableRowCount,
|
||||
TableColumnCount: event.Source.TableColumnCount}
|
||||
TableColumnCount: event.Source.TableColumnCount, ApplicationProcess: event.Source.ApplicationProcess,
|
||||
LibreOffice: event.Source.LibreOffice}
|
||||
speech = speech.Prepend(FocusTextWithOptions(table, options), ", ")
|
||||
speech = speech.Prepend(event.Source.TableName, ", ")
|
||||
focus = speech.Text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package wine supervises the optional installed Cthulhu Wine accessibility
|
||||
// Package wine supervises the optional installed Wine Access accessibility
|
||||
// helper. Native Wine objects stay in that process; Go receives D-Bus messages.
|
||||
package wine
|
||||
|
||||
@@ -47,17 +47,28 @@ func New(logger *slog.Logger) *Manager {
|
||||
}
|
||||
|
||||
func helperPath() string {
|
||||
return findHelper([]string{
|
||||
filepath.Join(os.Getenv("HOME"), ".local", "libexec", "wine-access", "wine-access.exe"),
|
||||
filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"),
|
||||
"/usr/local/libexec/wine-access/wine-access.exe",
|
||||
"/usr/libexec/wine-access/wine-access.exe",
|
||||
"/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe",
|
||||
})
|
||||
}
|
||||
|
||||
func findHelper(candidates []string) string {
|
||||
if path := os.Getenv("CTHULHU_WINE_ACCESS_HELPER"); executable(path) {
|
||||
return path
|
||||
}
|
||||
if path := filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"); executable(path) {
|
||||
return path
|
||||
for _, path := range candidates {
|
||||
if executable(path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
if path := "/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"; executable(path) {
|
||||
return path
|
||||
}
|
||||
if path, err := exec.LookPath("cthulhu-wine-access.exe"); err == nil {
|
||||
return path
|
||||
for _, name := range []string{"wine-access.exe", "cthulhu-wine-access.exe"} {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -119,7 +119,18 @@ func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) {
|
||||
t.Setenv("HOME", root)
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
|
||||
|
||||
local := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe")
|
||||
legacy := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacy, []byte("#!/bin/sh\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := helperPath(); got != legacy {
|
||||
t.Fatalf("helperPath() = %q, want existing user helper %q", got, legacy)
|
||||
}
|
||||
|
||||
local := filepath.Join(root, ".local", "libexec", "wine-access", "wine-access.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(local), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -140,6 +151,50 @@ func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindHelperPrecedenceAndExecutableFiltering(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
|
||||
t.Setenv("PATH", root)
|
||||
candidates := []string{
|
||||
filepath.Join(root, "user", "wine-access.exe"),
|
||||
filepath.Join(root, "usr-local", "wine-access.exe"),
|
||||
filepath.Join(root, "usr", "wine-access.exe"),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := findHelper(candidates); got != "" {
|
||||
t.Fatalf("missing helpers = %q, want empty", got)
|
||||
}
|
||||
legacy := filepath.Join(root, "cthulhu-wine-access.exe")
|
||||
pathHelper := filepath.Join(root, "wine-access.exe")
|
||||
for _, path := range []string{legacy, pathHelper, candidates[2], candidates[1], candidates[0]} {
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findHelper(candidates); got != path {
|
||||
t.Fatalf("findHelper() = %q, want %q", got, path)
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(candidates[0], 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", candidates[0])
|
||||
if got := findHelper(candidates); got != candidates[1] {
|
||||
t.Fatalf("non-executable override/user helper = %q, want %q", got, candidates[1])
|
||||
}
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", root)
|
||||
if got := findHelper(candidates); got != candidates[1] {
|
||||
t.Fatalf("directory override = %q, want %q", got, candidates[1])
|
||||
}
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", pathHelper)
|
||||
if got := findHelper(candidates); got != pathHelper {
|
||||
t.Fatalf("executable override = %q, want %q", got, pathHelper)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLaunchesAndStopsHelperForActivePrefix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
procRoot := filepath.Join(root, "proc")
|
||||
|
||||
Vendored
+1
@@ -54,3 +54,4 @@ 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.
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
office:version="1.2"
|
||||
office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
|
||||
<office:body>
|
||||
<office:spreadsheet>
|
||||
<table:table table:name="Example">
|
||||
<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-row>
|
||||
<table:table-row>
|
||||
<table:table-cell/>
|
||||
<table:table-cell office:value-type="string"><text:p>Beta</text:p></table:table-cell>
|
||||
</table:table-row>
|
||||
</table:table>
|
||||
<table:table table:name="Second">
|
||||
<table:table-row>
|
||||
<table:table-cell office:value-type="string"><text:p>Gamma</text:p></table:table-cell>
|
||||
</table:table-row>
|
||||
</table:table>
|
||||
</office:spreadsheet>
|
||||
</office:body>
|
||||
</office:document>
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real Calc through the production reader, in a private X11/AT-SPI session."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def wait_for(check, message, timeout=15):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = check()
|
||||
if result:
|
||||
return result
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def run_inside():
|
||||
import gi
|
||||
gi.require_version("Atspi", "2.0")
|
||||
from gi.repository import Atspi
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-") as temporary:
|
||||
root = Path(temporary)
|
||||
profile = root / "profile" / "user"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / "registrymodifications.xcu").write_text(
|
||||
'<oor:items xmlns:oor="http://openoffice.org/2001/registry">'
|
||||
'<item oor:path="/org.openoffice.Office.Common/Misc">'
|
||||
'<prop oor:name="FirstRun" oor:op="fuse"><value>false</value></prop>'
|
||||
'<prop oor:name="ShowTipOfTheDay" oor:op="fuse"><value>false</value></prop>'
|
||||
'</item></oor:items>', encoding="utf-8",
|
||||
)
|
||||
document = root / "Example.fods"
|
||||
shutil.copyfile(Path(__file__).with_name("libreoffice.fods"), document)
|
||||
environment = dict(os.environ, SAL_USE_VCLPLUGIN="gtk3",
|
||||
LC_ALL="C.UTF-8",
|
||||
GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0",
|
||||
XDG_CONFIG_HOME=str(root / "config"),
|
||||
XDG_CACHE_HOME=str(root / "cache"),
|
||||
XDG_DATA_HOME=str(root / "data"))
|
||||
application = subprocess.Popen(
|
||||
["libreoffice", "-env:UserInstallation=" + (root / "profile").as_uri(),
|
||||
"--norestore", "--nodefault", "--nofirststartwizard", "--calc", str(document)],
|
||||
env=environment, start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
def find_window():
|
||||
result = subprocess.run(
|
||||
["xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
return result.stdout.splitlines()[-1] if result.stdout.strip() else None
|
||||
|
||||
window = wait_for(find_window, "Calc window missing")
|
||||
# Wait for the actual accessible sheet, without enumerating its
|
||||
# millions of cells. Dismiss disposable first-run UI if present.
|
||||
def ready():
|
||||
pending = [Atspi.get_desktop(0)]
|
||||
remaining = 2500
|
||||
found = False
|
||||
while pending and remaining:
|
||||
node = pending.pop()
|
||||
remaining -= 1
|
||||
role = node.get_role_name()
|
||||
if role == "table":
|
||||
parent = node.get_parent()
|
||||
found |= parent is not None and parent.get_role_name() == "document spreadsheet"
|
||||
continue
|
||||
if role == "dialog" and node.get_name() == "Welcome to LibreOffice!":
|
||||
subprocess.run(["xdotool", "key", "Escape"], check=True)
|
||||
return False
|
||||
if role == "table cell":
|
||||
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)
|
||||
return found
|
||||
|
||||
wait_for(ready, "Calc accessible sheet missing")
|
||||
environment.update(CTHULHU_NATIVE_LIBREOFFICE="1", CTHULHU_CALC_WINDOW=window)
|
||||
environment.setdefault("GOCACHE", "/tmp/cthulhu-go-cache")
|
||||
subprocess.run(
|
||||
["go", "test", "./internal/app", "-run", "^TestNativeLibreOfficeCalc$",
|
||||
"-count=1", "-timeout=50s", "-v"],
|
||||
cwd=Path(__file__).resolve().parents[1], env=environment,
|
||||
check=True, timeout=60,
|
||||
)
|
||||
finally:
|
||||
if application.poll() is None:
|
||||
os.killpg(application.pid, signal.SIGTERM)
|
||||
application.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["--inside"]:
|
||||
run_inside()
|
||||
return
|
||||
with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-session-") as temporary:
|
||||
environment = dict(os.environ, XDG_SESSION_TYPE="x11",
|
||||
XDG_CONFIG_HOME=temporary, XDG_CACHE_HOME=temporary,
|
||||
XDG_DATA_HOME=temporary)
|
||||
for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"):
|
||||
environment.pop(name, None)
|
||||
subprocess.run(
|
||||
["xvfb-run", "-a", "dbus-run-session", "--", sys.executable,
|
||||
str(Path(__file__).resolve()), "--inside"],
|
||||
env=environment, check=True, timeout=90,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,7 +25,8 @@ def main():
|
||||
|
||||
normal = make_window(root, "Example title")
|
||||
desktop = make_window(root, "Wine Desktop")
|
||||
helper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe")
|
||||
helper = make_window(desktop, "NVDA", "wine-access.exe")
|
||||
oldHelper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe")
|
||||
ime = make_window(desktop, "Default IME")
|
||||
hidden = make_window(desktop, "Hidden game", "hidden.exe")
|
||||
hidden.unmap()
|
||||
@@ -33,7 +34,7 @@ def main():
|
||||
control = make_window(game, "")
|
||||
second = make_window(desktop, "Wine dialog", "game.exe")
|
||||
unrelated = make_window(root, "Unrelated title")
|
||||
windows = dict(normal=normal, desktop=desktop, helper=helper, ime=ime,
|
||||
windows = dict(normal=normal, desktop=desktop, helper=helper, oldHelper=oldHelper, ime=ime,
|
||||
hidden=hidden, game=game, control=control, second=second,
|
||||
unrelated=unrelated)
|
||||
root.change_property(activeAtom, Xatom.WINDOW, 32, [normal.id])
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Window title regression checks
|
||||
|
||||
Wine review focus provides navigation ownership and title metadata. It does
|
||||
not speak a window activation. The native title controller must introduce
|
||||
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.
|
||||
|
||||
Run the focused checks from the repository root:
|
||||
|
||||
```sh
|
||||
go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1
|
||||
dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v
|
||||
```
|
||||
|
||||
The private D-Bus test runs the event loop against copied foreground metadata
|
||||
and a real Wine review registry. It checks that a frame-to-control focus burst
|
||||
keeps the pending title and that later review revisions do not repeat it.
|
||||
Unit tests cover both arrival orders, Wine desktop children, returning to the
|
||||
same window, native application preferences, and XTerm handoff.
|
||||
|
||||
For live acceptance, use a rebuilt reader after arranging any active reader
|
||||
replacement with its user. Review and listening are required before submission.
|
||||
|
||||
1. Alt+Tab between a Wine application, an accessible Linux application, and
|
||||
xterm. Each destination should speak its title once on every return.
|
||||
2. Tab between controls inside Wine. Controls should speak normally without
|
||||
repeating the title. Check Wine flat review still follows the focused window.
|
||||
3. Check Fenrir retains xterm keyboard input and terminal reading. Return to
|
||||
Linux controls and confirm ordinary focus speech and reader commands work.
|
||||
4. Repeat with the application's automatic title changes disabled. Window
|
||||
introductions should still speak; subsequent status/title changes should
|
||||
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.
|
||||
|
||||
Automated queue assertions do not establish physical Alt+Tab, audible speech,
|
||||
installed-binary behavior, or physical Braille acceptance.
|
||||
@@ -84,7 +84,7 @@ def main():
|
||||
wait_for(focus,'Linux fixture missing',5)
|
||||
try:
|
||||
subprocess.run(['wineboot','-u'],env=env,check=True,timeout=45,stdout=subprocess.DEVNULL)
|
||||
processes.append(subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL))
|
||||
processes.append(subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL))
|
||||
processes.append(subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL))
|
||||
wait_for(activate,'native Wine window missing',10)
|
||||
window = activate()
|
||||
@@ -103,14 +103,14 @@ def main():
|
||||
subprocess.run(['wine',str(fixture),command.get('check','--check-strict')],env=env,check=True,timeout=5,stdout=subprocess.DEVNULL)
|
||||
elif action == 'restart-helper':
|
||||
processes[0].terminate();processes[0].wait(timeout=3)
|
||||
processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
processes[0]=subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
elif action == 'linux':activate_linux()
|
||||
elif action == 'wine':activate()
|
||||
elif action == 'second-prefix':
|
||||
known = set(subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True).stdout.splitlines())
|
||||
secondary_env = dict(env, WINEPREFIX=str(Path(directory)/'second-prefix'))
|
||||
subprocess.run(['wineboot','-u'],env=secondary_env,check=True,timeout=45,stdout=subprocess.DEVNULL)
|
||||
processes.append(subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=secondary_env,stdout=subprocess.DEVNULL))
|
||||
processes.append(subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=secondary_env,stdout=subprocess.DEVNULL))
|
||||
processes.append(subprocess.Popen(['wine',str(fixture)],env=secondary_env,stdout=subprocess.DEVNULL))
|
||||
def focus_second():
|
||||
found = subprocess.run(['xdotool','search','--onlyvisible','--name','^Wine review fixture$'],capture_output=True,text=True)
|
||||
@@ -127,7 +127,7 @@ def main():
|
||||
subprocess.run(['wine','reg','add',r'HKCU\Software\Wine\Explorer\Desktops','/v','Default','/d','900x700','/f'],env=env,check=True,stdout=subprocess.DEVNULL,timeout=5)
|
||||
subprocess.run(['wineserver','-k'],env=env,check=True,timeout=5)
|
||||
processes[0].wait(timeout=3);processes[1].wait(timeout=3)
|
||||
processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
processes[0]=subprocess.Popen([str(build/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
processes[1]=subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL)
|
||||
window = wait_for(activate,'virtual-desktop app missing',10)
|
||||
result = subprocess.run(['xdotool','search','--onlyvisible','--name','Wine Desktop'],capture_output=True,text=True,check=True)
|
||||
|
||||
Reference in new issue
Block a user