More LibreOffice work.

This commit is contained in:
Storm Dragon committed 2026-10-15 01:10:43 -04:00
1 parent 4ac59d4ced
commit b5a5b86dda
15 files changed
+763 -23

No files matched your search

+73 -13
View File
@@ -22,13 +22,15 @@ presentation code never retain native objects or perform provider queries.
`internal/presentation/libreoffice.go` owns the corresponding speech `internal/presentation/libreoffice.go` owns the corresponding speech
formatting; LibreOffice Braille formatting stays beside its controller. 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. Selection comparison keeps
logical focus belongs to the existing event loop. Future selections and one bounded snapshot for the focused sheet, keyed by its application bus and
dynamic headers must be keyed by application bus, document, and sheet identity. accessible sheet path and admitted only within the owned document window.
Retire them on document/sheet destruction or application exit. Focus loss It is retired on sheet/application focus changes and ordinary owned-focus
must cancel pending output while preserving document header choices; shutdown reset, including destruction, foreground loss, and screen lock. There are no
and screen lock must retire pending operations. Add command routing when the selection workers, timers, or retained native references. Future dynamic
first real Office command requires it, using the existing input machinery. headers must be keyed by application bus, document, and sheet identity;
focus loss must cancel output while preserving document header choices.
Use the existing input machinery for additional Office commands.
Application code must not bypass security/foreground gates or manage its own Application code must not bypass security/foreground gates or manage its own
speech connection, key grabs, or background event loop. speech connection, key grabs, or background event loop.
@@ -87,16 +89,52 @@ References inspected: Orca commit `cd58a88ed` (2026-10-14),
routes or formula suffix. The explicit formula command uses the existing routes or formula suffix. The explicit formula command uses the existing
system-message flash path and its Braille preferences, as in Orca. system-message flash path and its Braille preferences, as in Orca.
## Selection slice
- Shift+Arrow selection growth and shrinkage announce selected/unselected
coordinates. Adjacent cells form compact rectangles; disjoint cells retain
their gaps. The active anchor is left to ordinary cell presentation.
- Full rows and columns use row/column labels. Select All says “All cells
selected”; leaving a selection says “Selection cleared.” Ctrl+A can first
select the used area; press it again to select the entire sheet.
These announcements require a provider event. An independent AT-SPI observer
on LibreOffice 26.2.6.3 confirmed that Shift+Up after extending whole rows,
and Shift+Left after extending whole columns, change the selection without
emitting a selection event. Ctrl+Home can also clear those selections without
an event. Immediate announcements for those cases remain unsupported; the
next selection event refreshes the snapshot. The reader does not poll or
infer selection changes from keystrokes.
- Small selections capture at most 64 cells, rows, or columns. Messages with
more than eight rectangles use a count. Larger selections use a count and
two boundary samples for duplicate suppression, without treating endpoints
as proof of a filled rectangle. Overlapping count-only selections report
the new total rather than falsely announcing the entire old range unselected.
- The GTK bridge caps cell counts at 2147483647; partial-sheet selections at
that limit say “At least … cells selected.” Whole-sheet selection is checked
separately. Large selections with identical counts and boundary samples
cannot be distinguished by this bounded snapshot; interior-only changes may
therefore be suppressed. Full topology/delta reporting remains future work.
- Native capture uses a fixed number of count queries and at most 64 selected
positions. It requests no selected-row/column arrays. Ordinary small cell
selections skip full-row/column counts; LibreOffice itself scans its row
marks when larger selections need a selected-row count. Fixed RPC counts
do not guarantee a provider's response time.
- Selection events keep both logical and retained native focus on the active
cell. Formula commands, object review, cell Braille frames, and routing
continue to use that cell. Selection messages use the existing system-message
Braille flash path and preferences. Only-displayed-text mode suppresses them.
- Current Orca's selection callbacks and utilities guide the behavior. The
[LibreOffice spreadsheet provider](https://github.com/LibreOffice/core/blob/libreoffice-26-2/sc/source/ui/Accessibility/AccessibleSpreadsheet.cxx)
and [GTK selection bridge](https://github.com/LibreOffice/core/blob/libreoffice-26-2/vcl/unx/gtk3/a11y/atkselection.cxx)
were also inspected for selection ordering, row-count cost, and count limits.
## Next slices ## Next slices
1. Selected/unselected ranges, rows, columns, and Select All. Represent ranges 1. Dynamic row/column headers, sheet-scoped state, preferences and bindings.
compactly; do not expand every selected cell as Orca's current comparison
does. Test very large and disjoint selections.
2. Dynamic row/column headers, sheet-scoped state, preferences and bindings.
Inspect current Orca's shared table navigator rather than copying only Inspect current Orca's shared table navigator rather than copying only
the soffice directory. Spreadsheet full-row reading also needs a bounded, the soffice directory. Spreadsheet full-row reading also needs a bounded,
explicitly defined extent; the generic setting is not full Calc support. explicitly defined extent; the generic setting is not full Calc support.
3. Writer paragraph navigation, selection, Say All, spell-check presentation, 2. Writer paragraph navigation, selection, Say All, spell-check presentation,
and Braille panning. Compare each actual workflow with current Orca. and Braille panning. Compare each actual workflow with current Orca.
Merged cells, overflow announcements, large-sheet responsiveness, other Merged cells, overflow announcements, large-sheet responsiveness, other
@@ -123,6 +161,10 @@ cancellation, Enter commit, caret movement inside an existing numeric cell,
and switching between two sheets. Formula assertions cover a numeric result, and switching between two sheets. Formula assertions cover a numeric result,
an empty string result, literal spaces, replacement with a number, formula an empty string result, literal spaces, replacement with a number, formula
and basic Where Am I requests, and non-formula cells on a second sheet. and basic Where Am I requests, and non-formula cells on a second sheet.
Selection checks cover cell-range growth/shrinkage, clearing, row/column extension,
Select All, disjoint cells made through the provider selection
API, a 2097150-cell selection, and a partial-sheet selection above GTK's count
limit. Formula commands during selection verify preservation of the active cell.
The native listener tests check the desktop formula shortcut before key release; The native listener tests check the desktop formula shortcut before key release;
the Calc harness sends semantic requests through the production event loop. the Calc harness sends semantic requests through the production event loop.
No active reader is replaced and no normal Office profile is changed. No active reader is replaced and no normal Office profile is changed.
@@ -144,7 +186,15 @@ physical Braille. Before submission, review the diff and test a rebuilt binary:
the formula bar, and another application; check logical focus recovery. the formula bar, and another application; check logical focus recovery.
5. Check the cell value/address, spacing, editor cursor and return to cell 5. Check the cell value/address, spacing, editor cursor and return to cell
display on physical Braille. Text routing applies inside the editor. display on physical Braille. Text routing applies inside the editor.
6. Repeat ordinary GTK table movement/multiselection, browser typing and table 6. Use Shift+Arrows to grow and shrink a range; check selected/unselected
addresses and continued active-cell/formula output. Try Ctrl+Space,
Shift+Space, their Shift+Arrow extensions, and Ctrl+A twice. Clear each
selection with an arrow. Select disjoint areas with Ctrl+click and a large
area with Ctrl+Shift+Arrows; check gaps in small selections and count summaries
for large ones. Enable Braille flash messages and verify selection messages
restore the cell display. Enable only-displayed-text and verify selection
chatter is omitted. Repeat on another sheet and in a second workbook.
7. Repeat ordinary GTK table movement/multiselection, browser typing and table
reading, and Steam navigation. Check lock/unlock and stale/background events. reading, and Steam navigation. Check lock/unlock and stale/background events.
Human review and testing remain required by `CONTRIBUTING.md`. Passing this Human review and testing remain required by `CONTRIBUTING.md`. Passing this
@@ -172,6 +222,16 @@ for missing xkbcommon symbols; the formula shortcut was tested directly using
the command above. Audible formula speech, physical input/Braille, and real the command above. Audible formula speech, physical input/Braille, and real
workbook acceptance remain pending. workbook acceptance remain pending.
Selection-slice verification passed the isolated real Calc harness (including
disjoint and capped selections), focused race tests, vet/build, parity-ledger
validation, and ordinary GTK table/multiselection checks. The full affected-package
race run again hit the startup timing test above; all remaining affected-package
race tests passed with
`go test -race ./internal/atspi ./internal/app ./internal/presentation -skip '^TestRunReachesSoundStopAfterEarlierServiceStopError$' -count=1`.
The whole-row/column contraction event gaps described above were independently
observed without the reader. Audible output, physical input/Braille, and
independent-workbook acceptance remain pending.
## Calc startup focus diagnosis ## Calc startup focus diagnosis
A normal-profile startup under i3 can leave Calc accepting arrow keys while A normal-profile startup under i3 can leave Calc accepting arrow keys while
+5 -2
View File
@@ -16,13 +16,16 @@ type applicationEventResult struct {
// prepareApplicationEvent runs after security/foreground checks and before // prepareApplicationEvent runs after security/foreground checks and before
// generic focus and text filters. Application policy receives copied semantics; // generic focus and text filters. Application policy receives copied semantics;
// the reader retains ownership, review, cancellation, and output services. // the reader retains ownership, review, cancellation, and output services.
// The first handler is stateless: document-specific state can be owned here // Application-specific state stays on the event loop with an explicit focus
// when a concrete feature needs it, rather than hidden in native callbacks. // lifetime, rather than hidden in native callbacks.
func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) { func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) {
result := applicationEventResult{event: event} result := applicationEventResult{event: event}
if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) { if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) {
result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner) result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner)
} }
if !result.suppress || event.Type == atspi.SelectionChangedEvent {
handler.handleLibreOfficeSelection(result.event)
}
if result.adoptFocus { if result.adoptFocus {
if handler.focusGate.Suspended() { if handler.focusGate.Suspended() {
return event, false return event, false
+2
View File
@@ -45,6 +45,7 @@ type eventForegroundState struct {
} }
type eventFocusState struct { type eventFocusState struct {
libreOfficeSelection libreOfficeSelectionState
current atspi.Snapshot current atspi.Snapshot
brailleSnapshot atspi.Snapshot brailleSnapshot atspi.Snapshot
focusedID atspi.ObjectID focusedID atspi.ObjectID
@@ -137,6 +138,7 @@ func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer,
// Review metadata can change without new input or a foreground switch. Reset // Review metadata can change without new input or a foreground switch. Reset
// its navigation state independently so title announcements keep playing. // its navigation state independently so title announcements keep playing.
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) { func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
state.libreOfficeSelection = libreOfficeSelectionState{}
state.selection = textSelection{} state.selection = textSelection{}
state.documentSelection = textSelection{} state.documentSelection = textSelection{}
state.lastClipboardSequence = 0 state.lastClipboardSequence = 0
+2 -2
View File
@@ -46,8 +46,8 @@ func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner wi
} }
if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent { if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent {
// Selection can name the first cell of a range rather than the // Selection can name the first cell of a range rather than the
// active cell. Movement follows active-descendant events; range // active cell. Dedicated selection presentation consumes copied
// presentation belongs to the later selection-support slice. // sheet metadata without changing logical focus or review.
result.suppress = true result.suppress = true
} }
if context != nil && context.InSpreadsheet && event.Source.Role == "table" && if context != nil && context.InSpreadsheet && event.Source.Role == "table" &&
+49 -1
View File
@@ -83,7 +83,7 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") { if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
t.Fatalf("Calc capacity/state chatter: %q", got) t.Fatalf("Calc capacity/state chatter: %q", got)
} }
if got == want { if got == want || strings.HasSuffix(got, ". "+want) {
return return
} }
case <-timeout.C: case <-timeout.C:
@@ -198,4 +198,52 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
waitSpeech("blank, C1") waitSpeech("blank, C1")
commands <- input.PresentCellFormula commands <- input.PresentCellFormula
waitSpeech("empty") waitSpeech("empty")
press("ctrl+Home")
waitSpeech("Gamma, A1")
press("shift+Right")
waitSpeech("Cell B1 selected.")
press("shift+Down")
waitSpeech("Cells A2 to B2 selected.")
press("shift+Up")
waitSpeech("Cells A2 to B2 unselected.")
// Formula commands must still use the active cell, not the range's
// first selected child or the sheet that emitted the selection event.
commands <- input.PresentCellFormula
waitSpeech("Gamma")
press("Right")
waitSpeech("Selection cleared.")
press("ctrl+Home", "shift+space")
waitSpeech("Row 1 selected.")
press("shift+Down")
waitSpeech("Row 2 selected.")
press("ctrl+Home", "ctrl+space")
waitSpeech("Column A selected.")
press("shift+Right")
waitSpeech("Column B selected.")
press("ctrl+Home", "ctrl+a", "ctrl+a")
waitSpeech("All cells selected.")
// Calc omits events while shrinking full-column ranges. Growing the
// shrunken selection again supplies an event for a capped partial sheet.
press("shift+Left", "shift+Left", "shift+Right")
waitSpeech("At least 2147483647 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
press("Down")
waitSpeech("blank, B2")
// Use the provider's selection API to make actual disjoint selections;
// these are distinct from the synthetic event/presentation unit checks.
if output, err := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/native_libreoffice.py", "--select-disjoint").CombinedOutput(); err != nil {
t.Fatalf("disjoint Calc selection: %v: %s", err, output)
}
waitSpeech("Cell C1 selected.")
commands <- input.PresentCellFormula
waitSpeech("empty")
press("Right")
waitSpeech("Selection cleared.")
// A large non-column selection has an exact count, without enumerating
// its cells or inferring a rectangle from boundary samples.
press("ctrl+Home", "shift+Right", "ctrl+shift+Down", "shift+Up")
waitSpeech("2097150 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
} }
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"slices"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
// Owned by the event loop, with one bounded snapshot for the focused sheet.
// Retired on sheet/application focus changes and the ordinary focus reset
// (including lock, destruction, foreground loss, and shutdown). No timers or
// provider references survive a selection event.
type libreOfficeSelectionState struct {
sheet atspi.ObjectID
previous *atspi.LibreOfficeSelection
}
func calcSheet(snapshot atspi.Snapshot) atspi.ObjectID {
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.LibreOffice == nil || !snapshot.LibreOffice.InSpreadsheet {
return atspi.ObjectID{}
}
if snapshot.Role == "table" {
return snapshot.ID()
}
if snapshot.LibreOffice.Cell {
return atspi.NewObjectID(snapshot.BusName, snapshot.TablePath)
}
return atspi.ObjectID{}
}
func (handler *accessibilityEventHandler) handleLibreOfficeSelection(event atspi.Event) {
state := handler.state
if isFocusGainEvent(event) && state.libreOfficeSelection.sheet != calcSheet(event.Source) {
state.libreOfficeSelection = libreOfficeSelectionState{}
}
context := event.Source.LibreOffice
if event.Type != atspi.SelectionChangedEvent || context == nil || context.Selection == nil ||
event.Source.Role != "table" || event.Source.Defunct || !context.InSpreadsheet ||
!atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) ||
handler.focusGate.Suspended() || !state.owner.window.Valid() ||
state.owner.window != eventWindow(event) || state.owner.applicationBus != event.Source.BusName ||
calcSheet(state.current) != event.Source.ID() {
return
}
history := &state.libreOfficeSelection
if history.sheet != event.Source.ID() {
*history = libreOfficeSelectionState{sheet: event.Source.ID()}
}
selection := context.Selection
handler.logger.Debug("received Calc selection snapshot", "kind", selection.Kind,
"count", selection.Count, "count_limited", selection.CountLimited,
"complete", selection.Complete, "cells", selection.Cells,
"first", selection.First, "last", selection.Last)
previous := history.previous
history.previous = selection
if previous != nil && previous.Kind == selection.Kind && previous.Count == selection.Count &&
previous.CountLimited == selection.CountLimited && previous.Complete == selection.Complete &&
previous.First == selection.First && previous.Last == selection.Last && slices.Equal(previous.Cells, selection.Cells) {
return
}
reading := state.presentationOptions.Reading
if handler.live != nil {
settings := handler.live.Load()
reading = &settings.Reading
}
if reading != nil && reading.OnlySpeakDisplayedText {
return
}
var messages []string
if previous != nil && (previous.Count != 0 || previous.Kind == "all") {
if selection.Count == 0 && selection.Kind != "all" {
messages = append(messages, "Selection cleared.")
} else if previous.Kind == selection.Kind && previous.Kind != "all" && previous.Complete && selection.Complete {
removed := *previous
removed.Cells = calcSelectionDifference(previous.Cells, selection.Cells)
removed.Count = len(removed.Cells)
if text := presentation.LibreOfficeSelectionText(removed, "unselected"); text != "" {
messages = append(messages, text)
}
}
}
added := *selection
if selection.Complete && (selection.Kind == "cells" || previous != nil && previous.Kind == selection.Kind) {
if previous != nil && previous.Complete && previous.Kind == selection.Kind {
added.Cells = calcSelectionDifference(selection.Cells, previous.Cells)
}
if selection.Kind == "cells" && state.current.TablePositionKnown {
// Orca leaves the anchor to ordinary active-cell presentation.
anchor := atspi.LibreOfficeCell{Row: state.current.TableRowIndex, Column: state.current.TableColumnIndex}
added.Cells = calcSelectionDifference(added.Cells, []atspi.LibreOfficeCell{anchor})
}
added.Count = len(added.Cells)
}
if text := presentation.LibreOfficeSelectionText(added, "selected"); text != "" {
messages = append(messages, text)
}
if text := strings.Join(messages, " "); text != "" {
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-selection"})
}
}
func calcSelectionDifference(first, second []atspi.LibreOfficeCell) []atspi.LibreOfficeCell {
var result []atspi.LibreOfficeCell
for _, cell := range first {
if !slices.Contains(second, cell) {
result = append(result, cell)
}
}
return result
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"log/slog"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestLibreOfficeSelectionChangesKeepActiveCellAndBraille(t *testing.T) {
current := calcTestEvent(atspi.FocusEvent, "/cell", "43", "C1")
current.Source.TableColumnIndex = 2
current.Source.LibreOffice.Formula = "B1+1"
state := eventLoopState{}
state.current, state.focusedID = current.Source, current.Source.ID()
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
state.presentationOptions = presentation.DefaultOptions()
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
display := &flashRecorder{}
live := &liveSettings{}
live.Store(config.DefaultSettings())
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
adapter: &fakeAdapter{}, speech: outputQueue{speech: speech, braille: display, live: live}, syncForeground: func() {}, webScripts: &webScriptController{}}
event := current
event.Type = atspi.SelectionChangedEvent
event.Source.Path, event.Source.Role = "/sheet", "table"
selection := func(cells ...atspi.LibreOfficeCell) {
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true,
Selection: &atspi.LibreOfficeSelection{Kind: "cells", Count: len(cells), Complete: true, Cells: cells}}
handler.handle(event)
}
expect := func(want string) {
t.Helper()
select {
case got := <-speech.spoken:
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
default:
t.Fatalf("missing %q", want)
}
if state.current.ID() != current.Source.ID() || state.focusedID != current.Source.ID() || presentation.LibreOfficeCellFormula(state.current) != "B1+1" {
t.Fatal("selection displaced active cell/formula")
}
if display.messages[len(display.messages)-1] != want {
t.Fatal("selection did not use system Braille flash")
}
}
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2})
expect("Cell A1 selected.")
handler.handle(event)
if len(speech.spoken) != 0 {
t.Fatal("duplicate selection repeated")
}
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
expect("Cell E1 selected.")
selection(atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
expect("Cell A1 unselected.")
selection()
expect("Selection cleared.")
// A count-only change reports the new total, never a fabricated
// unselection of the whole old range when selections overlap.
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000}
handler.handle(event)
expect("1000000 cells selected.")
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001}
handler.handle(event)
expect("1000001 cells selected.")
// The same total with different boundary samples is a changed selection.
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001, First: atspi.LibreOfficeCell{Row: 2, Column: 2}}
handler.handle(event)
expect("1000001 cells selected.")
// Whole-row/column shrinkage has the same delta policy when the
// provider supplies an event (the real Calc harness documents gaps).
for _, kind := range []string{"rows", "columns"} {
state.libreOfficeSelection = libreOfficeSelectionState{}
positions := []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}}
label := "Row 2 unselected."
selectedLabel := "Rows 1 to 2 selected."
if kind == "columns" {
positions[1] = atspi.LibreOfficeCell{Row: 0, Column: 1}
label = "Column B unselected."
selectedLabel = "Columns A to B selected."
}
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 2, Complete: true, Cells: positions}
handler.handle(event)
expect(selectedLabel)
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 1, Complete: true, Cells: positions[:1]}
handler.handle(event)
expect(label)
}
state.clearObjectFocus(nil, nil, nil)
if state.libreOfficeSelection.previous != nil {
t.Fatal("selection survived owned-focus reset")
}
}
func TestLibreOfficeSelectionRejectsUnownedAndSuppressedOutput(t *testing.T) {
for _, name := range []string{"other sheet", "other bus", "other window", "wrong process", "defunct", "locked", "suspended", "stale foreground", "displayed text"} {
t.Run(name, func(t *testing.T) {
current := calcTestEvent(atspi.FocusEvent, "/cell", "42", "B1")
state := eventLoopState{}
state.current, state.focusedID = current.Source, current.Source.ID()
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
state.foregroundState = atspi.ForegroundState{Epoch: 2}
state.presentationOptions = presentation.DefaultOptions()
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
gate := newFocusGate(nil)
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{}, speech: speech, focusGate: gate,
foreground: &ocrTestForeground{state: state.foregroundState}, syncForeground: func() {}, webScripts: &webScriptController{}}
event := current
event.Type = atspi.SelectionChangedEvent
event.Foreground.Epoch = 2
event.Source.Path, event.Source.Role = "/sheet", "table"
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, Selection: &atspi.LibreOfficeSelection{Kind: "all", Complete: true}}
switch name {
case "other sheet":
event.Source.Path = "/other_sheet"
case "other bus":
event.Source.BusName = ":1.99"
case "other window":
event.Window.Path = "/other_window"
case "wrong process":
event.Source.ApplicationProcess = "example"
case "defunct":
event.Source.Defunct = true
case "locked":
gate.secure.Store(true)
case "suspended":
gate.suspended.Store(true)
case "stale foreground":
event.Foreground.Epoch = 1
case "displayed text":
reading := config.DefaultSettings().Reading
reading.OnlySpeakDisplayedText = true
state.presentationOptions.Reading = &reading
}
// Test the application hook after the ordinary foreground/security
// gates; wrong-process events belong to the generic application path.
if name == "wrong process" {
handler.handleLibreOfficeSelection(event)
} else {
handler.handle(event)
}
if len(speech.spoken) != 0 || state.current.ID() != current.Source.ID() {
t.Fatal("unowned/suppressed selection changed output or focus")
}
})
}
}
@@ -302,6 +302,10 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
} }
if !focusEvent.ForegroundMismatch { if !focusEvent.ForegroundMismatch {
captureLibreOfficeContext(target, &snapshot) captureLibreOfficeContext(target, &snapshot)
if eventType == SelectionChangedEvent && snapshot.Role == "table" &&
snapshot.LibreOffice != nil && snapshot.LibreOffice.InSpreadsheet {
snapshot.LibreOffice.Selection = captureLibreOfficeSelection(target)
}
focusEvent.Source = snapshot focusEvent.Source = snapshot
if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent { if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent {
snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target) snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target)
@@ -477,7 +481,8 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
focusEvent.Window = originID focusEvent.Window = originID
} }
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" { if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) { if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) &&
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
adapter.rememberNativeFocus(target, snapshot.ID()) adapter.rememberNativeFocus(target, snapshot.ID())
} }
} }
+20
View File
@@ -21,8 +21,28 @@ type LibreOfficeContext struct {
CellText string CellText string
Coordinates string Coordinates string
Formula string Formula string
Selection *LibreOfficeSelection
} }
// LibreOfficeSelection never expands a sheet. Complete small selections carry
// at most 64 positions; larger selections carry counts and boundary samples.
// Kind is cells, rows, columns, or all. Count is a lower bound when
// CountLimited is true.
type LibreOfficeSelection struct {
Kind string
Count int
CountLimited bool
Complete bool
Cells []LibreOfficeCell
// Boundary samples distinguish equal-sized large selections. They do
// not prove that every cell between the endpoints is selected.
First, Last LibreOfficeCell
}
type LibreOfficeCell struct{ Row, Column int }
const libreOfficeSelectionLimit = 64
// IsLibreOfficeProcess uses /proc identity verified by the adapter. An // IsLibreOfficeProcess uses /proc identity verified by the adapter. An
// accessible name or document title must never enable application policy. // accessible name or document title must never enable application policy.
func IsLibreOfficeProcess(process string) bool { func IsLibreOfficeProcess(process string) bool {
+19 -3
View File
@@ -11,15 +11,31 @@ import "C"
import "unsafe" import "unsafe"
// libreOfficeFocusTarget resolves only a Calc editor's direct text children. // libreOfficeFocusTarget resolves a Calc editor's direct text children and
// F2 focuses its temporary panel; the paragraph can appear later, on typing. // preserves the sheet source for selection events. F2 focuses its temporary
// panel; the paragraph can appear later, on typing.
// The callback owns and releases the returned reference. No sheet is walked. // The callback owns and releases the returned reference. No sheet is walked.
func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible { func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible {
if eventType != FocusEvent && eventType != FocusedStateEvent || if eventType != FocusEvent && eventType != FocusedStateEvent && eventType != SelectionChangedEvent ||
!IsLibreOfficeProcess(adapter.applicationProcess(event.source)) { !IsLibreOfficeProcess(adapter.applicationProcess(event.source)) {
return nil return nil
} }
role := C.atspi_accessible_get_role(event.source, nil) role := C.atspi_accessible_get_role(event.source, nil)
if eventType == SelectionChangedEvent {
// Keep the sheet as the selection source. Its first selected cell
// is not necessarily the active cell (and can be far offscreen).
if role == C.ATSPI_ROLE_TABLE {
parent := C.atspi_accessible_get_parent(event.source, nil)
if parent != nil {
spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
C.g_object_unref(C.gpointer(parent))
if spreadsheet {
return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(event.source)))
}
}
}
return nil
}
if role == C.ATSPI_ROLE_TABLE { if role == C.ATSPI_ROLE_TABLE {
parent := C.atspi_accessible_get_parent(event.source, nil) parent := C.atspi_accessible_get_parent(event.source, nil)
if parent != nil { if parent != nil {
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
// Only the native owner calls this, after foreground validation. Counts use a
// fixed number of RPCs; no selected-row/column arrays or sheet walks are used.
// LibreOffice itself scans its row marks when answering selected-row counts.
func captureLibreOfficeSelection(source *C.AtspiAccessible) *LibreOfficeSelection {
selection := C.atspi_accessible_get_selection_iface(source)
table := C.atspi_accessible_get_table_iface(source)
if selection == nil || table == nil {
if selection != nil {
C.g_object_unref(C.gpointer(selection))
}
if table != nil {
C.g_object_unref(C.gpointer(table))
}
return nil
}
defer C.g_object_unref(C.gpointer(selection))
defer C.g_object_unref(C.gpointer(table))
var queryError *C.GError
count := int(C.atspi_selection_get_n_selected_children(selection, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
if count < 0 {
return nil
}
result := &LibreOfficeSelection{Kind: "cells", Count: count, CountLimited: count == 1<<31-1}
if count == 0 {
result.Complete = true
return result
}
rows := int(C.atspi_table_get_n_rows(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
columns := int(C.atspi_table_get_n_columns(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
selectedRows, selectedColumns := 0, 0
// A small selection cannot contain a whole row or column. Avoid making
// LibreOffice scan all its row marks for ordinary Shift+Arrow movement.
if count >= columns {
selectedRows = int(C.atspi_table_get_n_selected_rows(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
}
if count >= rows {
selectedColumns = int(C.atspi_table_get_n_selected_columns(table, &queryError))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
}
if rows <= 0 || columns <= 0 || selectedRows < 0 || selectedColumns < 0 {
return nil
}
if selectedRows == rows || selectedColumns == columns {
return &LibreOfficeSelection{Kind: "all", Complete: true}
}
stride := 1
// Only classify pure row/column selections. Mixed selections retain an
// honest cell count. GTK caps counts above INT_MAX, rather than wrapping.
if !result.CountLimited && selectedRows > 0 && int64(selectedRows)*int64(columns) == int64(count) {
result.Kind, result.Count, result.CountLimited = "rows", selectedRows, false
stride = columns
} else if !result.CountLimited && selectedColumns > 0 && int64(selectedColumns)*int64(rows) == int64(count) {
result.Kind, result.Count, result.CountLimited = "columns", selectedColumns, false
}
indices := make([]int, 0, libreOfficeSelectionLimit)
if result.Count > libreOfficeSelectionLimit {
indices = append(indices, 0, count-1)
} else {
for index := 0; index < result.Count; index++ {
indices = append(indices, index*stride)
}
}
for _, index := range indices {
child := C.atspi_selection_get_selected_child(selection, C.gint(index), &queryError)
if queryError != nil {
C.g_error_free(queryError)
if child != nil {
C.g_object_unref(C.gpointer(child))
}
return nil
}
if child == nil {
return nil
}
cell := C.atspi_accessible_get_table_cell(child)
var row, column C.gint
valid := false
if cell != nil {
valid = C.atspi_table_cell_get_position(cell, &row, &column, &queryError) != 0
C.g_object_unref(C.gpointer(cell))
}
C.g_object_unref(C.gpointer(child))
if queryError != nil {
C.g_error_free(queryError)
return nil
}
if !valid || row < 0 || column < 0 || int(row) >= rows || int(column) >= columns {
return nil
}
position := LibreOfficeCell{Row: int(row), Column: int(column)}
if result.Kind == "rows" {
position.Column = 0
}
if result.Kind == "columns" {
position.Row = 0
}
result.Cells = append(result.Cells, position)
}
result.First, result.Last = result.Cells[0], result.Cells[len(result.Cells)-1]
result.Complete = result.Count <= libreOfficeSelectionLimit
if !result.Complete {
result.Cells = nil
}
return result
}
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"fmt"
"slices"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func calcColumnLabel(column int) string {
label := ""
for column++; column > 0; column = (column - 1) / 26 {
label = string(rune('A'+(column-1)%26)) + label
}
return label
}
func calcCellLabel(cell atspi.LibreOfficeCell) string {
return fmt.Sprintf("%s%d", calcColumnLabel(cell.Column), cell.Row+1)
}
// LibreOfficeSelectionText formats copied metadata only. A large selection's
// endpoints never imply a filled rectangle. Small disjoint selections retain
// their gaps; at most eight rectangles are spoken before using a count.
func LibreOfficeSelectionText(selection atspi.LibreOfficeSelection, action string) string {
if selection.Kind == "all" {
return "All cells " + action + "."
}
if selection.Count == 0 {
return ""
}
if !selection.Complete || len(selection.Cells) > 64 {
return libreOfficeSelectionCount(selection, action)
}
cells := slices.Clone(selection.Cells)
slices.SortFunc(cells, func(a, b atspi.LibreOfficeCell) int {
if a.Row != b.Row {
return a.Row - b.Row
}
return a.Column - b.Column
})
cells = slices.Compact(cells)
type rectangle struct{ first, last atspi.LibreOfficeCell }
var ranges []rectangle
for index := 0; index < len(cells); {
first, last := cells[index], cells[index]
index++
for index < len(cells) && (selection.Kind == "rows" && cells[index].Row == last.Row+1 ||
selection.Kind != "rows" && cells[index].Row == last.Row && cells[index].Column == last.Column+1) {
last = cells[index]
index++
}
merged := false
if selection.Kind == "cells" {
for i := range ranges {
previous := &ranges[i]
if previous.first.Column == first.Column && previous.last.Column == last.Column && previous.last.Row+1 == first.Row {
previous.last.Row = last.Row
merged = true
break
}
}
}
if !merged {
ranges = append(ranges, rectangle{first, last})
}
}
if len(ranges) > 8 {
return libreOfficeSelectionCount(selection, action)
}
var messages []string
for _, span := range ranges {
first, last := calcCellLabel(span.first), calcCellLabel(span.last)
singular, plural := "Cell", "Cells"
if selection.Kind == "rows" {
first, last = fmt.Sprint(span.first.Row+1), fmt.Sprint(span.last.Row+1)
singular, plural = "Row", "Rows"
} else if selection.Kind == "columns" {
first, last = calcColumnLabel(span.first.Column), calcColumnLabel(span.last.Column)
singular, plural = "Column", "Columns"
}
if first == last {
messages = append(messages, fmt.Sprintf("%s %s %s.", singular, first, action))
} else {
messages = append(messages, fmt.Sprintf("%s %s to %s %s.", plural, first, last, action))
}
}
return strings.Join(messages, " ")
}
func libreOfficeSelectionCount(selection atspi.LibreOfficeSelection, action string) string {
prefix := ""
if selection.CountLimited {
prefix = "At least "
}
return fmt.Sprintf("%s%d %s %s.", prefix, selection.Count, selection.Kind, action)
}
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestLibreOfficeSelectionPreservesGapsAndBoundsOutput(t *testing.T) {
for _, test := range []struct {
name string
selection atspi.LibreOfficeSelection
want string
}{
{"rectangle", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 1, Column: 0}, {Row: 1, Column: 1}}}, "Cells A1 to B2 selected."},
{"disjoint", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 0, Column: 3}, {Row: 1, Column: 3}}}, "Cells A1 to B1 selected. Cells D1 to D2 selected."},
{"row gaps", atspi.LibreOfficeSelection{Kind: "rows", Count: 3, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}, {Row: 3, Column: 0}}}, "Rows 1 to 2 selected. Row 4 selected."},
{"column labels", atspi.LibreOfficeSelection{Kind: "columns", Count: 2, Complete: true,
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 25}, {Row: 0, Column: 26}}}, "Columns Z to AA selected."},
{"large", atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000000,
First: atspi.LibreOfficeCell{Row: 0, Column: 0}, Last: atspi.LibreOfficeCell{Row: 999999, Column: 999}}, "1000000000 cells selected."},
{"capped", atspi.LibreOfficeSelection{Kind: "cells", Count: 2147483647, CountLimited: true}, "At least 2147483647 cells selected."},
{"all", atspi.LibreOfficeSelection{Kind: "all", Complete: true}, "All cells selected."},
} {
t.Run(test.name, func(t *testing.T) {
if got := LibreOfficeSelectionText(test.selection, "selected"); got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
selection := atspi.LibreOfficeSelection{Kind: "cells", Count: 9, Complete: true}
for index := 0; index < 9; index++ {
selection.Cells = append(selection.Cells, atspi.LibreOfficeCell{Row: index, Column: index})
}
if got := LibreOfficeSelectionText(selection, "unselected"); got != "9 cells unselected." {
t.Fatalf("fragmented selection = %q", got)
}
}
+1 -1
View File
@@ -54,5 +54,5 @@ web-browse-selection,src/cthulhu/scripts/web/script.py;src/cthulhu/script_utilit
web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass; physical HumanWare NLS eReader acceptance pending.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md. web-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 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 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. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic headers full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md.
steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md. steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md.
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. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic headers full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md.
58 steam-shader-progress Steam shader dialog AT-SPI events web Report Steam Vulkan shader percentages through shared progress output internal/webscripts internal/app in-progress go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py Private GTK and Brave HTML/ARIA progress checks passed. Tone requests verified; live Steam listening pending. Unrelated Braille ownership covered by unit tests; physical check pending. English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md.
+30
View File
@@ -100,7 +100,37 @@ def run_inside():
application.wait(timeout=5) application.wait(timeout=5)
def select_disjoint():
"""Mutate only the disposable fixture, without walking sheet cells."""
if os.environ.get("CTHULHU_NATIVE_LIBREOFFICE") != "1":
raise RuntimeError("Selection helper requires the private Calc harness")
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
pending = [Atspi.get_desktop(0)]
remaining = 2500
while pending and remaining:
node = pending.pop()
remaining -= 1
if node.get_role_name() == "table":
if node.get_name() == "Sheet Second":
node.clear_selection()
node.select_child(0)
node.select_child(2)
return
continue
for index in range(min(node.get_child_count(), 200)):
child = node.get_child_at_index(index)
if child is not None:
pending.append(child)
raise RuntimeError("Disposable selection fixture missing")
def main(): def main():
if sys.argv[1:] == ["--select-disjoint"]:
select_disjoint()
return
if sys.argv[1:] == ["--inside"]: if sys.argv[1:] == ["--inside"]:
run_inside() run_inside()
return return