diff --git a/AGENTS.md b/AGENTS.md
index 13ce594..f30e925 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 64becb2..ae9077d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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.
diff --git a/LIBREOFFICE_SUPPORT.md b/LIBREOFFICE_SUPPORT.md
new file mode 100644
index 0000000..1ed22d3
--- /dev/null
+++ b/LIBREOFFICE_SUPPORT.md
@@ -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.
diff --git a/README.md b/README.md
index 38b7c08..b16803e 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/internal/app/application_behavior.go b/internal/app/application_behavior.go
new file mode 100644
index 0000000..029b004
--- /dev/null
+++ b/internal/app/application_behavior.go
@@ -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
+}
diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go
index bae52dc..195feee 100644
--- a/internal/app/braille_presentation.go
+++ b/internal/app/braille_presentation.go
@@ -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
diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go
index cc61efa..2f5367a 100644
--- a/internal/app/event_accessibility.go
+++ b/internal/app/event_accessibility.go
@@ -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
}
diff --git a/internal/app/libreoffice.go b/internal/app/libreoffice.go
new file mode 100644
index 0000000..3e3b94a
--- /dev/null
+++ b/internal/app/libreoffice.go
@@ -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
+}
diff --git a/internal/app/libreoffice_native_test.go b/internal/app/libreoffice_native_test.go
new file mode 100644
index 0000000..3c7626d
--- /dev/null
+++ b/internal/app/libreoffice_native_test.go
@@ -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")
+}
diff --git a/internal/app/libreoffice_test.go b/internal/app/libreoffice_test.go
new file mode 100644
index 0000000..b4ac603
--- /dev/null
+++ b/internal/app/libreoffice_test.go
@@ -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")
+ }
+ })
+ }
+}
diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go
index 086acb7..e682b2f 100644
--- a/internal/atspi/adapter_event_callback_native.go
+++ b/internal/atspi/adapter_event_callback_native.go
@@ -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
}
diff --git a/internal/atspi/libreoffice.go b/internal/atspi/libreoffice.go
new file mode 100644
index 0000000..fecaef1
--- /dev/null
+++ b/internal/atspi/libreoffice.go
@@ -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"
+}
diff --git a/internal/atspi/libreoffice_native.go b/internal/atspi/libreoffice_native.go
new file mode 100644
index 0000000..e5fe042
--- /dev/null
+++ b/internal/atspi/libreoffice_native.go
@@ -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
+}
diff --git a/internal/atspi/model.go b/internal/atspi/model.go
index c5c619a..cab012c 100644
--- a/internal/atspi/model.go
+++ b/internal/atspi/model.go
@@ -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
diff --git a/internal/platform/xorg/window_title.c b/internal/platform/xorg/window_title.c
index 6f66a98..fd100c9 100644
--- a/internal/platform/xorg/window_title.c
+++ b/internal/platform/xorg/window_title.c
@@ -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;
}
diff --git a/internal/platform/xorg/window_title_native_test.go b/internal/platform/xorg/window_title_native_test.go
index 643fa1d..330b461 100644
--- a/internal/platform/xorg/window_title_native_test.go
+++ b/internal/platform/xorg/window_title_native_test.go
@@ -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)
}
}
diff --git a/internal/presentation/libreoffice.go b/internal/presentation/libreoffice.go
new file mode 100644
index 0000000..a11e26b
--- /dev/null
+++ b/internal/presentation/libreoffice.go
@@ -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
+}
diff --git a/internal/presentation/libreoffice_test.go b/internal/presentation/libreoffice_test.go
new file mode 100644
index 0000000..5814120
--- /dev/null
+++ b/internal/presentation/libreoffice_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go
index 4489cf7..347a708 100644
--- a/internal/presentation/presentation.go
+++ b/internal/presentation/presentation.go
@@ -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
diff --git a/internal/wine/manager.go b/internal/wine/manager.go
index 52499ad..4bf930f 100644
--- a/internal/wine/manager.go
+++ b/internal/wine/manager.go
@@ -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 ""
}
diff --git a/internal/wine/manager_test.go b/internal/wine/manager_test.go
index d5681e9..06049b2 100644
--- a/internal/wine/manager_test.go
+++ b/internal/wine/manager_test.go
@@ -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")
diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv
index 932a0ab..e39bc1a 100644
--- a/testdata/parity-ledger.csv
+++ b/testdata/parity-ledger.csv
@@ -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.
diff --git a/tests/libreoffice.fods b/tests/libreoffice.fods
new file mode 100644
index 0000000..31c1afa
--- /dev/null
+++ b/tests/libreoffice.fods
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Alpha
+ 42
+
+
+
+ Beta
+
+
+
+
+ Gamma
+
+
+
+
+
diff --git a/tests/native_libreoffice.py b/tests/native_libreoffice.py
new file mode 100644
index 0000000..9bf4f79
--- /dev/null
+++ b/tests/native_libreoffice.py
@@ -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(
+ ''
+ '- '
+ 'false'
+ 'false'
+ '
', 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()
diff --git a/tests/window_title_fixture.py b/tests/window_title_fixture.py
index d60a4a3..e8c333d 100644
--- a/tests/window_title_fixture.py
+++ b/tests/window_title_fixture.py
@@ -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])
diff --git a/tests/wine_review_x11.py b/tests/wine_review_x11.py
index b3f8ad8..6ada957 100644
--- a/tests/wine_review_x11.py
+++ b/tests/wine_review_x11.py
@@ -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)