2 Commits
Author SHA1 Message Date
deedra 6754c8ef6c Fix Wine window title announcements on foreground switches
Wine review establishes navigation ownership and stores the window title
without speaking an activation. The native title controller treated that
metadata as an already announced title, suppressing the introduction when
review focus arrived first. Frame-to-control focus updates also reset the
window title controller, cancelling pending introductions and discarding
duplicate history.

Distinguish Wine review ownership from spoken AT-SPI activations when
classifying and deduplicating native introductions. Use the native Wine
application identity when waiting for committed application preferences,
so introductions remain audible with automatic title changes disabled
while subsequent title changes honor that application's settings.

Separate object focus cleanup from window title cleanup. Wine review
revisions now retain the pending title and its duplicate history; actual
foreground transitions, native window lifecycle cleanup, screen lock,
and shutdown continue to reset title state. Preserve XTerm/Fenrir handoff
and ordinary AT-SPI activation deduplication.

Add regressions for both native/review arrival orders, ordinary Wine
windows and virtual desktop children, duplicate reports, return to the
same window, and application preference handoff. Add a private D-Bus
event-loop test for a frame-to-control focus burst and document focused
verification and live acceptance procedures in tests/window_titles.md.

Validation:
- Confirmed the new title introduction, preference handoff, and private
  focus-burst regressions fail before the fix and pass afterward.
- go test ./... -count=1 passed with local socket access; the sandboxed
  attempt was blocked by Unix and loopback speech fixture sockets.
- Focused Title, WineReview, XTerm, and Foreground tests passed with
  the race detector.
- Private D-Bus TestPrivateWineTitleSurvivesReviewFocusBurst passed
  with the race detector.
- go vet ./internal/app, the reader build, and git diff --check passed.

Installed-reader behavior, real Wine/X11 switching, physical Alt+Tab,
audible speech, and physical Braille acceptance remain pending. The
private fixture uses copied foreground metadata and a real registry,
not a real Wine window. No installed reader was replaced.
2026-10-14 16:38:37 -04:00
Storm Dragon 7f78177786 libreoffice support started, still broken. Some updates to how wine-access gets called. 2026-10-14 16:24:49 -04:00
31 changed files with 1409 additions and 26 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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.
+150
View File
@@ -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.
+14
View File
@@ -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.
+57
View File
@@ -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
}
+3
View File
@@ -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
+5
View File
@@ -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
}
+8 -3
View File
@@ -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()
+91
View File
@@ -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
}
+171
View File
@@ -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")
}
+166
View File
@@ -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")
}
})
}
}
+9 -2
View File
@@ -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 {
+95
View File
@@ -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
}
+24
View File
@@ -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"
}
+148
View File
@@ -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
}
+1
View File
@@ -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
+2 -1
View File
@@ -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)
}
}
+51
View File
@@ -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
}
+46
View File
@@ -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)
}
}
}
+3 -1
View File
@@ -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
+19 -8
View File
@@ -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 ""
}
+56 -1
View File
@@ -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")
+1
View File
@@ -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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
54 web-browse-braille-line src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py braille Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor internal/atspi internal/review internal/app implemented go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending. Existing browse speech assertions pass in the private fixture. Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending. Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md.
55 web-explicit-link-names src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole web Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation Storm Dragon internal/atspi internal/review internal/app in-progress go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py Private Brave/Xorg named-link and key checks passed Fixture speech verified; real Steam listening pending Named-link browse braille uses the full accessible label; physical check pending docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates
56 steam-notifications src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py web Coalesce verified Steam background toasts and present once through speech braille and notification history Storm Dragon internal/webscripts internal/atspi internal/app in-progress go test -race ./...; python3 tests/native_webscripts.py Private GTK/AT-SPI process matching and alert-root capture passed Event-loop background delivery and Chromium isolation passed; real Steam toast pending Event-loop flash verified; physical Steam toast display pending docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging
57 libreoffice-calc-basic Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py libreoffice Present Calc cells and recover logical focus around editing and sheet changes Storm Dragon internal/app internal/atspi internal/presentation in-progress GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes. Recorded speech assertions pass; audible and physical-input acceptance pending. Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending. Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md.
+27
View File
@@ -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>
+121
View File
@@ -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()
+3 -2
View File
@@ -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])
+38
View File
@@ -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.
+4 -4
View File
@@ -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)