Compare commits
1
Commits
deedra
..
a005be1d2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a005be1d2e |
No files matched your search
@@ -22,7 +22,7 @@ Use `gofmt`, exported PascalCase identifiers, private camelCase identifiers, and
|
||||
|
||||
## Behavior & Accessibility Safeguards
|
||||
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) for detailed safeguards. Compare accepted behavior with Python on `legacy`; use 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.
|
||||
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.
|
||||
|
||||
Fix regressions in the shared subsystem when appropriate. Identify state ownership, lifetime, reset, and cancellation; keep native callbacks and input/focus handling bounded. Preserve Xorg correctness while allowing capability-based Wayland degradation. Keep Tentacles external and responsive.
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ Inspect relevant settings, bindings, speech, Braille, and toolkit behavior befor
|
||||
git show legacy:src/cthulhu/settings.py
|
||||
```
|
||||
|
||||
Use the newer Orca implementation as the first reference for Firefox, 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 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 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.
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# LibreOffice integration
|
||||
|
||||
Current Orca is the primary behavioral reference for LibreOffice. Python
|
||||
Cthulhu remains available on the `legacy` branch for historical comparisons:
|
||||
`git show legacy:src/cthulhu/scripts/apps/soffice/script.py`.
|
||||
Do not switch the active Go checkout to inspect that reference.
|
||||
|
||||
## Architecture
|
||||
|
||||
Application handling runs after screen-lock and foreground validation, before
|
||||
generic focus and text filtering. `internal/app/application_behavior.go`
|
||||
routes copied accessibility events by verified provider process identity.
|
||||
The LibreOffice handler lives in `internal/app/libreoffice.go`.
|
||||
It can normalize an event, request focus adoption, or suppress presentation.
|
||||
The reader retains window ownership, input policy, pending-output cancellation,
|
||||
review, and speech/Braille delivery. Ordinary applications keep their existing
|
||||
event path. Steam's existing integration remains in place.
|
||||
|
||||
`internal/atspi/libreoffice_native.go` captures bounded Calc metadata on the
|
||||
native owner thread. It does not enumerate a sheet. The controller and
|
||||
presentation code never retain native objects or perform provider queries.
|
||||
`internal/presentation/libreoffice.go` owns the corresponding speech
|
||||
formatting; LibreOffice Braille formatting stays beside its controller.
|
||||
|
||||
The first implementation has no independent mutable application state:
|
||||
logical focus belongs to the existing event loop. Future selections and
|
||||
dynamic headers must be keyed by application bus, document, and sheet identity.
|
||||
Retire them on document/sheet destruction or application exit. Focus loss
|
||||
must cancel pending output while preserving document header choices; shutdown
|
||||
and screen lock must retire pending operations. Add command routing when the
|
||||
first real Office command requires it, using the existing input machinery.
|
||||
Application code must not bypass security/foreground gates or manage its own
|
||||
speech connection, key grabs, or background event loop.
|
||||
|
||||
Keep generic table operations and genuinely shared fixes in their existing
|
||||
subsystems. Keep verified LibreOffice exceptions in the dedicated files.
|
||||
Adding later Office features should extend these boundaries, without
|
||||
reorganizing unrelated reader code or building a speculative plugin framework.
|
||||
|
||||
## First slice
|
||||
|
||||
- Calc active-cell events establish logical focus, including after editing
|
||||
and when the sheet changes.
|
||||
- Cell contents and addresses are separate. Normal speech gives the value,
|
||||
then the address; an empty value gives “blank.” Only-displayed-text mode
|
||||
omits the address and the synthetic blank label.
|
||||
- Recorded Braille preserves value spacing followed by the address. A cell
|
||||
component is not an editable text route.
|
||||
- F2 resolves a temporary editor's focused paragraph when available.
|
||||
If the paragraph appears later, its first owned text/caret event establishes
|
||||
editor focus before generic filtering.
|
||||
- Post-editing sheet focus is adopted silently. Spreadsheet capacity and
|
||||
cell/container state chatter are omitted as in Orca.
|
||||
- Ancestor focus claims are suppressed when captured ancestry identifies the
|
||||
existing focused spreadsheet object.
|
||||
- Non-editor caret events do not displace a focused cell. Selection events do
|
||||
not promote the first selected cell of a range into logical focus.
|
||||
|
||||
References inspected: Orca commit `cd58a88ed` (2026-10-14),
|
||||
`scripts/apps/soffice/script.py`, `script_utilities.py`,
|
||||
`speech_generator.py`, `braille_generator.py`, and shared
|
||||
`speech_generator.py` / `ax_utilities_table.py`.
|
||||
|
||||
## Next slices
|
||||
|
||||
1. Formula presence and an on-demand formula command, using current Orca's
|
||||
`where_am_i_presenter.py:present_cell_formula` and cell attributes.
|
||||
2. Selected/unselected ranges, rows, columns, and Select All. Represent ranges
|
||||
compactly; do not expand every selected cell as Orca's current comparison
|
||||
does. Test very large and disjoint selections.
|
||||
3. Dynamic row/column headers, sheet-scoped state, preferences and bindings.
|
||||
Inspect current Orca's shared table navigator rather than copying only
|
||||
the soffice directory. Spreadsheet full-row reading also needs a bounded,
|
||||
explicitly defined extent; the generic setting is not full Calc support.
|
||||
4. Writer paragraph navigation, selection, Say All, spell-check presentation,
|
||||
and Braille panning. Compare each actual workflow with current Orca.
|
||||
|
||||
Merged cells, overflow announcements, large-sheet responsiveness, other
|
||||
LibreOffice backends, and independent-workbook acceptance remain separate
|
||||
checks. This first slice does not establish complete Office support.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1
|
||||
/usr/bin/python3 tests/native_libreoffice.py
|
||||
python3 tests/native_atspi.py --table-only
|
||||
python3 tests/native_x11.py
|
||||
```
|
||||
|
||||
The Calc harness launches installed LibreOffice with a disposable profile,
|
||||
document, virtual X11 display, and private session/accessibility buses.
|
||||
It exercises the production native adapter and event loop; recorded speech
|
||||
and Braille are asserted for cell movement and blanks, F2 typing, Escape
|
||||
cancellation, Enter commit, caret movement inside an existing numeric cell,
|
||||
and switching between two sheets.
|
||||
No active reader is replaced and no normal Office profile is changed.
|
||||
|
||||
These automated checks do not establish audible speech, physical input, or
|
||||
physical Braille. Before submission, review the diff and test a rebuilt binary:
|
||||
|
||||
1. In a disposable Calc workbook, populate text, numbers, formulas and blanks.
|
||||
Arrow between them and listen for the value followed by its address.
|
||||
2. Press F2, edit, cancel with Escape, then edit and commit with Enter. Check
|
||||
character/word navigation in an existing cell editor and continued arrows.
|
||||
3. Switch sheets and switch between two workbooks. Return from menus, dialogs,
|
||||
the formula bar, and another application; check logical focus recovery.
|
||||
4. Check the cell value/address, spacing, editor cursor and return to cell
|
||||
display on physical Braille. Text routing applies inside the editor.
|
||||
5. Repeat ordinary GTK table movement/multiselection, browser typing and table
|
||||
reading, and Steam navigation. Check lock/unlock and stale/background events.
|
||||
|
||||
Human review and testing remain required by `CONTRIBUTING.md`. Passing this
|
||||
fixture alone is not sufficient for submission or live accessibility acceptance.
|
||||
|
||||
Initial verification against LibreOffice 26.2.6.3 passed the Calc harness,
|
||||
affected-package race tests, vet/build, GTK table/multiselection checks, and
|
||||
Xorg input checks. Browser tab and table checks also passed. Browser editing
|
||||
failed on an extra title announcement both here and on untouched commit
|
||||
`8e76d23`. Browser modes initially failed to focus a heading after reload,
|
||||
then passed on the baseline and on a rerun here; the cause of that first
|
||||
failure was not established. Physical acceptance remains pending.
|
||||
|
||||
## Calc startup focus diagnosis
|
||||
|
||||
A normal-profile startup under i3 can leave Calc accepting arrow keys while
|
||||
omitting active-cell accessibility events. In the observed case, cell events
|
||||
were followed by focus claims from the floating Form Design and Form Controls
|
||||
toolbars; subsequent arrows produced formula-bar text updates but no new cell
|
||||
events. A separate AT-SPI observer reproduced the missing events without
|
||||
Cthulhu. The affected workbook worked with a fresh profile, and a generic
|
||||
workbook reproduced the failure with a disposable copy of the normal profile.
|
||||
This establishes a profile-dependent LibreOffice focus/event problem; the
|
||||
precise profile setting responsible has not been isolated.
|
||||
|
||||
Press **Ctrl+F6** to return focus to the document, then retry the arrows.
|
||||
This directly restored active-cell events in the isolated reproduction.
|
||||
LibreOffice documents this shortcut in its
|
||||
[Calc accessibility keyboard guide](https://help.libreoffice.org/latest/en-GB/text/scalc/guide/keyboard.html).
|
||||
Automatic focus stealing would interfere with intentional toolbar use and
|
||||
is not part of the reader's handling.
|
||||
|
||||
The native Calc test focuses its window once and uses ordinary key sequences
|
||||
afterward. It also checks F6 followed by Ctrl+F6 and resumed cell speech/Braille.
|
||||
Its fresh-profile virtual display does not cover this normal-profile startup
|
||||
failure. The same production test also passed under private i3 with the copied
|
||||
normal profile after Ctrl+F6 recovery, asserting recorded cell speech and
|
||||
Braille along with editing and sheet switching. Verify startup and recovery
|
||||
under the actual window manager, plus audible speech and physical Braille,
|
||||
separately.
|
||||
@@ -4,35 +4,5 @@ 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.
|
||||
|
||||
Wine typing feedback in standard Edit controls follows the current key echo
|
||||
settings, including the master switch, character echo, and alphabetic, numeric,
|
||||
punctuation, space and diacritical categories. Tabs and line breaks follow
|
||||
key echo's action-key setting. Applying preferences takes effect without
|
||||
restarting the helper. Navigation, selection and deletion feedback remain
|
||||
available when typing echo is disabled. Word and sentence typing echo are not
|
||||
currently supplied by the Wine helper.
|
||||
|
||||
For a live check, type repeated letters, numbers, punctuation and spaces in
|
||||
Wine Notepad, change the corresponding echo settings and apply them, then
|
||||
continue typing. Disable key echo and verify that typing stays quiet while
|
||||
arrows, selection and Backspace still report editing feedback. Re-enable it
|
||||
and check capital/lowercase voices and password exclusion. Automated tests
|
||||
cover queued speech and Braille; audible speech and a physical Braille display
|
||||
still require live verification.
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import "git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
|
||||
type applicationEventResult struct {
|
||||
event atspi.Event
|
||||
adoptFocus bool
|
||||
suppress bool
|
||||
resetPresentation bool
|
||||
}
|
||||
|
||||
// prepareApplicationEvent runs after security/foreground checks and before
|
||||
// generic focus and text filters. Application policy receives copied semantics;
|
||||
// the reader retains ownership, review, cancellation, and output services.
|
||||
// The first handler is stateless: document-specific state can be owned here
|
||||
// when a concrete feature needs it, rather than hidden in native callbacks.
|
||||
func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) {
|
||||
result := applicationEventResult{event: event}
|
||||
if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) {
|
||||
result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner)
|
||||
}
|
||||
if result.adoptFocus {
|
||||
if handler.focusGate.Suspended() {
|
||||
return event, false
|
||||
}
|
||||
state := handler.state
|
||||
if !state.owner.follow(result.event) {
|
||||
return event, true
|
||||
}
|
||||
state.pending.caret.clear()
|
||||
state.focusedID = result.event.Source.ID()
|
||||
state.current = result.event.Source
|
||||
state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{}
|
||||
if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" {
|
||||
// A late-created editor has no ordinary focus event to reset
|
||||
// row history. Returning to its old cell is a fresh focus.
|
||||
state.lastTable, state.lastTableCell = atspi.ObjectID{}, atspi.ObjectID{}
|
||||
}
|
||||
state.selection.reset(result.event.Source)
|
||||
if handler.objectReview != nil {
|
||||
handler.objectReview.SyncFocus(result.event.Source.ID())
|
||||
}
|
||||
}
|
||||
if result.resetPresentation {
|
||||
handler.state.pending.caret.clear()
|
||||
handler.state.lastSpokenPath, handler.state.lastSpokenText, handler.state.lastSound = "", "", ""
|
||||
}
|
||||
if result.suppress {
|
||||
handler.logger.Debug("skipped application-specific accessibility event", "type", event.Type,
|
||||
"application_process", event.Source.ApplicationProcess, "role", event.Source.Role)
|
||||
}
|
||||
return result.event, result.suppress
|
||||
}
|
||||
@@ -160,9 +160,6 @@ func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOption
|
||||
}
|
||||
|
||||
func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
|
||||
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
|
||||
return frame
|
||||
}
|
||||
name, role := strings.TrimSpace(s.Name), s.Role
|
||||
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
|
||||
label := name
|
||||
|
||||
@@ -135,11 +135,6 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
|
||||
}
|
||||
return
|
||||
}
|
||||
var applicationSuppressed bool
|
||||
event, applicationSuppressed = handler.prepareApplicationEvent(event)
|
||||
if applicationSuppressed {
|
||||
return
|
||||
}
|
||||
if handler.handleLifecycle(event) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
"git.stormux.org/storm/cthulhu/internal/wine"
|
||||
"github.com/godbus/dbus/v5"
|
||||
@@ -96,17 +95,10 @@ func (presenter remoteSpeech) presentWineEditor(event remote.AccessibleEvent) bo
|
||||
// character identity for navigation and Backspace as well as typing.
|
||||
kinds := strings.Fields(event.States)
|
||||
inserted := slices.Contains(kinds, "kind=8")
|
||||
settings := config.DefaultSettings()
|
||||
if presenter.live != nil {
|
||||
settings = presenter.live.Load()
|
||||
}
|
||||
if inserted && !wineTypingEchoEnabled(event.Name, settings) {
|
||||
return false
|
||||
}
|
||||
character := inserted || utf8.RuneCountInString(event.Name) == 1
|
||||
custom := false
|
||||
if character && presenter.live != nil && (inserted || slices.Contains(kinds, "kind=7")) {
|
||||
custom = customEcho(settings, "character")
|
||||
custom = customEcho(presenter.live.Load(), "character")
|
||||
}
|
||||
return presenter.presentRequest(speechRequest{
|
||||
Text: event.Name, Role: "system", Cause: "wine-accessible-event", Append: true,
|
||||
|
||||
@@ -7,11 +7,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
)
|
||||
|
||||
@@ -44,24 +40,6 @@ func newWineEventPresenter(present func(string) bool) *wineEventPresenter {
|
||||
return &wineEventPresenter{present: present}
|
||||
}
|
||||
|
||||
// wineTypingEchoEnabled applies the native insertion policy to the helper's
|
||||
// character labels. Navigation and deletion do not pass through this filter.
|
||||
func wineTypingEchoEnabled(text string, settings config.Settings) bool {
|
||||
switch text {
|
||||
case "tab", "new line":
|
||||
return settings.EnableKeyEcho && settings.EnableActionKeys
|
||||
case "space":
|
||||
text = " "
|
||||
}
|
||||
// The helper sends one accepted character per insertion. Do not treat an
|
||||
// unexpected label as a multi-character paste, which has a different policy.
|
||||
if utf8.RuneCountInString(text) != 1 {
|
||||
return false
|
||||
}
|
||||
event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: text}
|
||||
return presentation.EventTextWithOptions(event, presentationSettingsFromConfig(settings, false)) != ""
|
||||
}
|
||||
|
||||
func (owner *wineEventPresenter) Present(event remote.AccessibleEvent) bool {
|
||||
return owner.present_event(event, nil)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
@@ -33,10 +32,7 @@ func TestNativeSessionBusWineEvents(t *testing.T) {
|
||||
t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus")
|
||||
}
|
||||
output := &wineTestOutput{speech: make(chan speechRequest, 32), braille: make(chan string, 32)}
|
||||
settings := config.DefaultSettings()
|
||||
live := &liveSettings{}
|
||||
live.Store(settings)
|
||||
handler := remoteSpeech{speech: output, braille: output, live: live}
|
||||
handler := remoteSpeech{speech: output, braille: output}
|
||||
owner := newWineEventPresenter(func(message string) bool {
|
||||
return handler.presentMessage(message, "wine-accessible-event")
|
||||
})
|
||||
@@ -75,19 +71,6 @@ func TestNativeSessionBusWineEvents(t *testing.T) {
|
||||
for _, text := range []string{"A", "a", "É", "é"} {
|
||||
send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8"}, true)
|
||||
}
|
||||
settings.EnableKeyEcho = false
|
||||
live.Store(settings)
|
||||
for _, text := range []string{"A", "space", "tab", "new line"} {
|
||||
send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "kind=8"}, false)
|
||||
}
|
||||
send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "e", States: "kind=7"}, true)
|
||||
settings.EnableKeyEcho = true
|
||||
settings.EnableAlphabeticKeys = false
|
||||
live.Store(settings)
|
||||
send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "A", States: "kind=8"}, false)
|
||||
settings.EnableAlphabeticKeys = true
|
||||
live.Store(settings)
|
||||
send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "A", States: "kind=8"}, true)
|
||||
send(remote.AccessibleEvent{SourceID: "check", EventType: "focus", Name: "Option", Role: "check box", States: "not checked"}, true)
|
||||
owner.flush()
|
||||
send(remote.AccessibleEvent{SourceID: "check", EventType: "name", Name: "Renamed", Role: "check box", States: "not checked"}, true)
|
||||
@@ -104,7 +87,7 @@ func TestNativeSessionBusWineEvents(t *testing.T) {
|
||||
send(remote.AccessibleEvent{SourceID: "no", EventType: "focus", Name: "No", Role: "client"}, true)
|
||||
owner.flush()
|
||||
owner.Close()
|
||||
for _, want := range []string{"edit, hello", "l", "l", "e selected", "e deleted", "A", "a", "É", "é", "e", "A", "Option, check box, not checked", "Renamed, check box, not checked", "Renamed, check box, checked, unavailable", "Yes", "No"} {
|
||||
for _, want := range []string{"edit, hello", "l", "l", "e selected", "e deleted", "A", "a", "É", "é", "Option, check box, not checked", "Renamed, check box, not checked", "Renamed, check box, checked, unavailable", "Yes", "No"} {
|
||||
select {
|
||||
case request := <-output.speech:
|
||||
if request.Text != want || !request.Append {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/remote"
|
||||
)
|
||||
|
||||
func TestWineTypingEchoUsesLiveSettings(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
live := &liveSettings{}
|
||||
live.Store(settings)
|
||||
speech := &remoteSpeechQueue{}
|
||||
braille := &remoteBrailleQueue{running: true}
|
||||
presenter := remoteSpeech{speech: speech, braille: braille, live: live}
|
||||
owner := newWineEventPresenter(func(message string) bool {
|
||||
return presenter.presentMessage(message, "wine-accessible-event")
|
||||
})
|
||||
defer owner.Close()
|
||||
presenter.wine = owner
|
||||
presenter.PresentAccessibleEvent(remote.AccessibleEvent{SourceID: "edit", EventType: "focus", Role: "edit"})
|
||||
owner.flush()
|
||||
check := func(text, kind string, want bool) {
|
||||
t.Helper()
|
||||
beforeSpeech, beforeBraille := len(speech.requests), len(braille.text)
|
||||
accepted := presenter.PresentAccessibleEvent(remote.AccessibleEvent{
|
||||
SourceID: "edit", EventType: "text", Name: text, States: "start=1 end=1 kind=" + kind,
|
||||
})
|
||||
if accepted != want {
|
||||
t.Errorf("%q kind=%s accepted=%v, want %v", text, kind, accepted, want)
|
||||
}
|
||||
if !want {
|
||||
if len(speech.requests) != beforeSpeech || len(braille.text) != beforeBraille {
|
||||
t.Errorf("disabled typing %q reached output", text)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(speech.requests) != beforeSpeech+1 || speech.requests[beforeSpeech].Text != text ||
|
||||
len(braille.text) != beforeBraille+1 || braille.text[beforeBraille] != text {
|
||||
t.Errorf("enabled action %q missing from speech or braille", text)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{"A", "a", "a", "É", "𐐀", "7", "!", "😀", "space", "tab", "new line"} {
|
||||
check(text, "8", true)
|
||||
}
|
||||
settings.EnableKeyEcho = false
|
||||
settings.EnableEchoByCharacter = true
|
||||
live.Store(settings)
|
||||
for _, text := range []string{"a", "7", "!", "space", "tab", "new line"} {
|
||||
check(text, "8", false)
|
||||
}
|
||||
// Reading, deletion and selection remain available with key echo disabled.
|
||||
for _, action := range []struct{ text, kind string }{
|
||||
{"a", "5"}, {"a", "7"}, {"line of text", "1"}, {"a selected", "2"}, {"a deleted", "4"},
|
||||
} {
|
||||
check(action.text, action.kind, true)
|
||||
}
|
||||
settings.EnableKeyEcho = true
|
||||
settings.EnableEchoByCharacter = false
|
||||
settings.EnableAlphabeticKeys = false
|
||||
settings.EnableNumericKeys = false
|
||||
settings.EnablePunctuationKeys = false
|
||||
settings.EnableSpace = false
|
||||
settings.EnableActionKeys = false
|
||||
live.Store(settings)
|
||||
for _, text := range []string{"A", "é", "𐐀", "7", "!", "😀", "space", "tab", "new line", "́"} {
|
||||
check(text, "8", false)
|
||||
}
|
||||
settings.EnableEchoByCharacter = true
|
||||
live.Store(settings)
|
||||
for _, text := range []string{"A", "é", "7", "!", "😀", "space", "́"} {
|
||||
check(text, "8", true)
|
||||
}
|
||||
for _, text := range []string{"tab", "new line"} {
|
||||
check(text, "8", false)
|
||||
}
|
||||
settings.EnableEchoByCharacter = false
|
||||
settings.EnableDiacriticalKeys = true
|
||||
settings.EnableActionKeys = true
|
||||
live.Store(settings)
|
||||
for _, text := range []string{"́", "tab", "new line"} {
|
||||
check(text, "8", true)
|
||||
}
|
||||
settings = config.DefaultSettings()
|
||||
live.Store(settings)
|
||||
check("a", "8", true)
|
||||
}
|
||||
|
||||
func TestWineTypingEchoFiltersCategoriesIndependently(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, blocked, allowed string
|
||||
disable func(*config.Settings)
|
||||
}{
|
||||
{"alphabetic", "É", "7", func(s *config.Settings) { s.EnableAlphabeticKeys = false }},
|
||||
{"numeric", "7", "a", func(s *config.Settings) { s.EnableNumericKeys = false }},
|
||||
{"punctuation", "!", "a", func(s *config.Settings) { s.EnablePunctuationKeys = false }},
|
||||
{"symbol", "😀", "a", func(s *config.Settings) { s.EnablePunctuationKeys = false }},
|
||||
{"space", "space", "a", func(s *config.Settings) { s.EnableSpace = false }},
|
||||
{"action", "tab", "space", func(s *config.Settings) { s.EnableActionKeys = false }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
settings := config.DefaultSettings()
|
||||
tc.disable(&settings)
|
||||
if wineTypingEchoEnabled(tc.blocked, settings) {
|
||||
t.Errorf("disabled category echoed %q", tc.blocked)
|
||||
}
|
||||
if !wineTypingEchoEnabled(tc.allowed, settings) {
|
||||
t.Errorf("unrelated category suppressed %q", tc.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -301,8 +301,6 @@ 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 {
|
||||
@@ -417,11 +415,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
return
|
||||
}
|
||||
snapshotStarted := time.Now()
|
||||
target := adapter.libreOfficeFocusTarget(event, eventType)
|
||||
ownedTarget := target != nil
|
||||
if target == nil {
|
||||
target, ownedTarget = selectNativeEventTarget(event, eventType)
|
||||
}
|
||||
target, ownedTarget := selectNativeEventTarget(event, eventType)
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
/*
|
||||
#include "adapter_native.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// libreOfficeFocusTarget resolves only a Calc editor's direct text children.
|
||||
// F2 focuses its temporary panel; the paragraph can appear later, on typing.
|
||||
// The callback owns and releases the returned reference. No sheet is walked.
|
||||
func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible {
|
||||
if eventType != FocusEvent && eventType != FocusedStateEvent ||
|
||||
!IsLibreOfficeProcess(adapter.applicationProcess(event.source)) {
|
||||
return nil
|
||||
}
|
||||
role := C.atspi_accessible_get_role(event.source, nil)
|
||||
if role == C.ATSPI_ROLE_TABLE {
|
||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||
if parent != nil {
|
||||
if C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET {
|
||||
// Orca clears stale table state around Calc focus transitions.
|
||||
C.atspi_accessible_clear_cache_single(event.source)
|
||||
}
|
||||
C.g_object_unref(C.gpointer(parent))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if role != C.ATSPI_ROLE_PANEL && role != C.ATSPI_ROLE_EXTENDED {
|
||||
return nil
|
||||
}
|
||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
|
||||
C.g_object_unref(C.gpointer(parent))
|
||||
if !spreadsheet {
|
||||
return nil
|
||||
}
|
||||
count := min(int(C.atspi_accessible_get_child_count(event.source, nil)), 4)
|
||||
for index := 0; index < count; index++ {
|
||||
child := C.atspi_accessible_get_child_at_index(event.source, C.gint(index), nil)
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
states := C.atspi_accessible_get_state_set(child)
|
||||
focused := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 &&
|
||||
C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0
|
||||
if states != nil {
|
||||
C.g_object_unref(C.gpointer(states))
|
||||
}
|
||||
if focused && C.atspi_accessible_get_role(child, nil) == C.ATSPI_ROLE_PARAGRAPH {
|
||||
return child
|
||||
}
|
||||
C.g_object_unref(C.gpointer(child))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) {
|
||||
if !IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Defunct {
|
||||
return
|
||||
}
|
||||
switch snapshot.Role {
|
||||
case "table", "table cell", "paragraph", "panel", "extended":
|
||||
default:
|
||||
return
|
||||
}
|
||||
context := LibreOfficeContext{}
|
||||
parentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID)
|
||||
grandparentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID)
|
||||
current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
|
||||
for depth := 0; current != nil && depth < 32; depth++ {
|
||||
role := C.atspi_accessible_get_role(current, nil)
|
||||
if depth == 1 {
|
||||
parentRole = role
|
||||
context.EditorParent = nativeObjectID(current)
|
||||
}
|
||||
if depth == 2 {
|
||||
grandparentRole = role
|
||||
}
|
||||
if role == C.ATSPI_ROLE_TABLE {
|
||||
states := C.atspi_accessible_get_state_set(current)
|
||||
if states != nil {
|
||||
context.ContainerFocused = C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0
|
||||
C.g_object_unref(C.gpointer(states))
|
||||
}
|
||||
}
|
||||
if role == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET {
|
||||
context.InSpreadsheet = true
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = nil
|
||||
break
|
||||
}
|
||||
if role == C.ATSPI_ROLE_FRAME || role == C.ATSPI_ROLE_DIALOG || role == C.ATSPI_ROLE_APPLICATION {
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = nil
|
||||
break
|
||||
}
|
||||
parent := C.atspi_accessible_get_parent(current, nil)
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
current = parent
|
||||
}
|
||||
if current != nil {
|
||||
C.g_object_unref(C.gpointer(current))
|
||||
}
|
||||
if !context.InSpreadsheet {
|
||||
return
|
||||
}
|
||||
context.Cell = snapshot.Role == "table cell"
|
||||
context.CellEditor = (snapshot.Role == "panel" || snapshot.Role == "extended") &&
|
||||
parentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET ||
|
||||
snapshot.Role == "paragraph" && (parentRole == C.ATSPI_ROLE_PANEL || parentRole == C.ATSPI_ROLE_EXTENDED) &&
|
||||
grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
|
||||
if context.Cell {
|
||||
context.Coordinates = snapshot.Name
|
||||
attributes := C.atspi_accessible_get_attributes(source, nil)
|
||||
if attributes != nil {
|
||||
columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext")
|
||||
column := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(columnKey))))
|
||||
row := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(rowKey))))
|
||||
C.free(unsafe.Pointer(columnKey))
|
||||
C.free(unsafe.Pointer(rowKey))
|
||||
if column != "" && row != "" {
|
||||
context.Coordinates = sanitizeSnapshotField(column + row)
|
||||
}
|
||||
C.g_hash_table_unref(attributes)
|
||||
}
|
||||
text := C.atspi_accessible_get_text_iface(source)
|
||||
if text != nil {
|
||||
// Calc's value is Text, while Name is the coordinate. Cell caret
|
||||
// offsets can be invalid, so capture contents independently.
|
||||
length := max(0, min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength))
|
||||
content := C.atspi_text_get_text(text, 0, C.gint(length), nil)
|
||||
context.CellText = sanitizeTextContext(C.GoString(content))
|
||||
C.g_free(C.gpointer(content))
|
||||
C.g_object_unref(C.gpointer(text))
|
||||
}
|
||||
}
|
||||
snapshot.Spreadsheet = true
|
||||
snapshot.LibreOffice = &context
|
||||
}
|
||||
@@ -36,7 +36,6 @@ type Snapshot struct {
|
||||
SpeechContext []Snapshot // Bounded copied ancestor metadata, without native handles.
|
||||
BrailleContext []Snapshot // Nearest ancestor first, including window/application.
|
||||
Spreadsheet bool
|
||||
LibreOffice *LibreOfficeContext
|
||||
// BrowserTab identifies browser chrome, excluding page-authored tab widgets.
|
||||
BrowserTab bool
|
||||
// ExplicitName distinguishes an author-supplied link label from a name
|
||||
|
||||
@@ -104,8 +104,7 @@ 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], "wine-access.exe") ||
|
||||
!strcasecmp(identifiers[i], "cthulhu-wine-access.exe") ||
|
||||
if (identifiers[i] && (!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 "wine-access.exe", "cthulhu-wine-access.exe", "hidden.exe":
|
||||
case "cthulhu-wine-access.exe", "hidden.exe":
|
||||
t.Fatalf("infrastructure/hidden Wine window listed: %+v", application)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
func TestLibreOfficeCellValueCoordinatesAndDisplayedText(t *testing.T) {
|
||||
for _, value := range []string{"42", "", "é 猫"} {
|
||||
snapshot := atspi.Snapshot{Role: "table cell", Name: "B3", Text: value, ApplicationProcess: "soffice.bin",
|
||||
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: "B3"}}
|
||||
want := value + ", B3"
|
||||
if value == "" {
|
||||
want = "blank, B3"
|
||||
}
|
||||
if got := FocusText(snapshot); got != want {
|
||||
t.Fatalf("cell %q = %q, want %q", value, got, want)
|
||||
}
|
||||
reading := config.DefaultSettings().Reading
|
||||
reading.OnlySpeakDisplayedText = true
|
||||
options := DefaultOptions()
|
||||
options.Reading = &reading
|
||||
if got := FocusTextWithOptions(snapshot, options); got != value {
|
||||
t.Fatalf("displayed cell %q = %q", value, got)
|
||||
}
|
||||
if snapshot.Name != "B3" || snapshot.Text != value {
|
||||
t.Fatal("presentation mutated native semantics")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePresentationRequiresVerifiedCellContext(t *testing.T) {
|
||||
for _, process := range []string{"", "chromium", "steamwebhelper"} {
|
||||
snapshot := atspi.Snapshot{Role: "table cell", Name: "B3", Text: "42", ApplicationProcess: process,
|
||||
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: "wrong", Coordinates: "wrong"}}
|
||||
if got := FocusText(snapshot); got != "B3, 42" {
|
||||
t.Fatalf("other process %q got LibreOffice policy: %q", process, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ 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) != "" {
|
||||
@@ -215,8 +214,7 @@ 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, ApplicationProcess: event.Source.ApplicationProcess,
|
||||
LibreOffice: event.Source.LibreOffice}
|
||||
TableColumnCount: event.Source.TableColumnCount}
|
||||
speech = speech.Prepend(FocusTextWithOptions(table, options), ", ")
|
||||
speech = speech.Prepend(event.Source.TableName, ", ")
|
||||
focus = speech.Text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package wine supervises the optional installed Wine Access accessibility
|
||||
// Package wine supervises the optional installed Cthulhu Wine accessibility
|
||||
// helper. Native Wine objects stay in that process; Go receives D-Bus messages.
|
||||
package wine
|
||||
|
||||
@@ -47,28 +47,17 @@ 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
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if executable(path) {
|
||||
if path := filepath.Join(os.Getenv("HOME"), ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe"); executable(path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"wine-access.exe", "cthulhu-wine-access.exe"} {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
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
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -119,18 +119,7 @@ func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) {
|
||||
t.Setenv("HOME", root)
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
|
||||
|
||||
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")
|
||||
local := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(local), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -151,50 +140,6 @@ func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindHelperPrecedenceAndExecutableFiltering(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
|
||||
t.Setenv("PATH", root)
|
||||
candidates := []string{
|
||||
filepath.Join(root, "user", "wine-access.exe"),
|
||||
filepath.Join(root, "usr-local", "wine-access.exe"),
|
||||
filepath.Join(root, "usr", "wine-access.exe"),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := findHelper(candidates); got != "" {
|
||||
t.Fatalf("missing helpers = %q, want empty", got)
|
||||
}
|
||||
legacy := filepath.Join(root, "cthulhu-wine-access.exe")
|
||||
pathHelper := filepath.Join(root, "wine-access.exe")
|
||||
for _, path := range []string{legacy, pathHelper, candidates[2], candidates[1], candidates[0]} {
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findHelper(candidates); got != path {
|
||||
t.Fatalf("findHelper() = %q, want %q", got, path)
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(candidates[0], 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", candidates[0])
|
||||
if got := findHelper(candidates); got != candidates[1] {
|
||||
t.Fatalf("non-executable override/user helper = %q, want %q", got, candidates[1])
|
||||
}
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", root)
|
||||
if got := findHelper(candidates); got != candidates[1] {
|
||||
t.Fatalf("directory override = %q, want %q", got, candidates[1])
|
||||
}
|
||||
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", pathHelper)
|
||||
if got := findHelper(candidates); got != pathHelper {
|
||||
t.Fatalf("executable override = %q, want %q", got, pathHelper)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLaunchesAndStopsHelperForActivePrefix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
procRoot := filepath.Join(root, "proc")
|
||||
|
||||
Vendored
-1
@@ -54,4 +54,3 @@ 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,27 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real Calc through the production reader, in a private X11/AT-SPI session."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def wait_for(check, message, timeout=15):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
result = check()
|
||||
if result:
|
||||
return result
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def run_inside():
|
||||
import gi
|
||||
gi.require_version("Atspi", "2.0")
|
||||
from gi.repository import Atspi
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-") as temporary:
|
||||
root = Path(temporary)
|
||||
profile = root / "profile" / "user"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / "registrymodifications.xcu").write_text(
|
||||
'<oor:items xmlns:oor="http://openoffice.org/2001/registry">'
|
||||
'<item oor:path="/org.openoffice.Office.Common/Misc">'
|
||||
'<prop oor:name="FirstRun" oor:op="fuse"><value>false</value></prop>'
|
||||
'<prop oor:name="ShowTipOfTheDay" oor:op="fuse"><value>false</value></prop>'
|
||||
'</item></oor:items>', encoding="utf-8",
|
||||
)
|
||||
document = root / "Example.fods"
|
||||
shutil.copyfile(Path(__file__).with_name("libreoffice.fods"), document)
|
||||
environment = dict(os.environ, SAL_USE_VCLPLUGIN="gtk3",
|
||||
LC_ALL="C.UTF-8",
|
||||
GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0",
|
||||
XDG_CONFIG_HOME=str(root / "config"),
|
||||
XDG_CACHE_HOME=str(root / "cache"),
|
||||
XDG_DATA_HOME=str(root / "data"))
|
||||
application = subprocess.Popen(
|
||||
["libreoffice", "-env:UserInstallation=" + (root / "profile").as_uri(),
|
||||
"--norestore", "--nodefault", "--nofirststartwizard", "--calc", str(document)],
|
||||
env=environment, start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
def find_window():
|
||||
result = subprocess.run(
|
||||
["xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
return result.stdout.splitlines()[-1] if result.stdout.strip() else None
|
||||
|
||||
window = wait_for(find_window, "Calc window missing")
|
||||
# Wait for the actual accessible sheet, without enumerating its
|
||||
# millions of cells. Dismiss disposable first-run UI if present.
|
||||
def ready():
|
||||
pending = [Atspi.get_desktop(0)]
|
||||
remaining = 2500
|
||||
found = False
|
||||
while pending and remaining:
|
||||
node = pending.pop()
|
||||
remaining -= 1
|
||||
role = node.get_role_name()
|
||||
if role == "table":
|
||||
parent = node.get_parent()
|
||||
found |= parent is not None and parent.get_role_name() == "document spreadsheet"
|
||||
continue
|
||||
if role == "dialog" and node.get_name() == "Welcome to LibreOffice!":
|
||||
subprocess.run(["xdotool", "key", "Escape"], check=True)
|
||||
return False
|
||||
if role == "table cell":
|
||||
continue
|
||||
for index in range(min(node.get_child_count(), 200)):
|
||||
child = node.get_child_at_index(index)
|
||||
if child is not None:
|
||||
pending.append(child)
|
||||
return found
|
||||
|
||||
wait_for(ready, "Calc accessible sheet missing")
|
||||
environment.update(CTHULHU_NATIVE_LIBREOFFICE="1", CTHULHU_CALC_WINDOW=window)
|
||||
environment.setdefault("GOCACHE", "/tmp/cthulhu-go-cache")
|
||||
subprocess.run(
|
||||
["go", "test", "./internal/app", "-run", "^TestNativeLibreOfficeCalc$",
|
||||
"-count=1", "-timeout=50s", "-v"],
|
||||
cwd=Path(__file__).resolve().parents[1], env=environment,
|
||||
check=True, timeout=60,
|
||||
)
|
||||
finally:
|
||||
if application.poll() is None:
|
||||
os.killpg(application.pid, signal.SIGTERM)
|
||||
application.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["--inside"]:
|
||||
run_inside()
|
||||
return
|
||||
with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-session-") as temporary:
|
||||
environment = dict(os.environ, XDG_SESSION_TYPE="x11",
|
||||
XDG_CONFIG_HOME=temporary, XDG_CACHE_HOME=temporary,
|
||||
XDG_DATA_HOME=temporary)
|
||||
for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"):
|
||||
environment.pop(name, None)
|
||||
subprocess.run(
|
||||
["xvfb-run", "-a", "dbus-run-session", "--", sys.executable,
|
||||
str(Path(__file__).resolve()), "--inside"],
|
||||
env=environment, check=True, timeout=90,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,8 +25,7 @@ def main():
|
||||
|
||||
normal = make_window(root, "Example title")
|
||||
desktop = make_window(root, "Wine Desktop")
|
||||
helper = make_window(desktop, "NVDA", "wine-access.exe")
|
||||
oldHelper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe")
|
||||
helper = make_window(desktop, "NVDA", "cthulhu-wine-access.exe")
|
||||
ime = make_window(desktop, "Default IME")
|
||||
hidden = make_window(desktop, "Hidden game", "hidden.exe")
|
||||
hidden.unmap()
|
||||
@@ -34,7 +33,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, oldHelper=oldHelper, ime=ime,
|
||||
windows = dict(normal=normal, desktop=desktop, helper=helper, ime=ime,
|
||||
hidden=hidden, game=game, control=control, second=second,
|
||||
unrelated=unrelated)
|
||||
root.change_property(activeAtom, Xatom.WINDOW, 32, [normal.id])
|
||||
|
||||
@@ -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/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL))
|
||||
processes.append(subprocess.Popen([str(build/'cthulhu-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/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
processes[0]=subprocess.Popen([str(build/'cthulhu-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/'wine-access.exe'),'--no-controller'],env=secondary_env,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(['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/'wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
processes[0]=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env,stdout=subprocess.DEVNULL)
|
||||
processes[1]=subprocess.Popen(['wine',str(fixture)],env=env,stdout=subprocess.DEVNULL)
|
||||
window = wait_for(activate,'virtual-desktop app missing',10)
|
||||
result = subprocess.run(['xdotool','search','--onlyvisible','--name','Wine Desktop'],capture_output=True,text=True,check=True)
|
||||
|
||||
Reference in new issue
Block a user