More LibreOffice changes.

This commit is contained in:
Storm Dragon committed 2026-10-15 21:00:52 -04:00
1 parent 3e0bdc578f
commit f885333520
23 files changed
+852 -23

No files matched your search

+79 -7
View File
@@ -27,9 +27,13 @@ one bounded snapshot for the focused sheet, keyed by its application bus and
accessible sheet path and admitted only within the owned document window.
It is retired on sheet/application focus changes and ordinary owned-focus
reset, including destruction, foreground loss, and screen lock. There are no
selection workers, timers, or retained native references. Future dynamic
headers must be keyed by application bus, document, and sheet identity;
focus loss must cancel output while preserving document header choices.
selection workers, timers, or retained native references. Dynamic header
choices live in the adapter's bounded, synchronized store of copied identities
and coordinates. Commands update that store without provider queries; the
native owner captures header text. Choices use application bus, document/view,
window and the provider's sheet name. Calc recreates table objects on tab
switches, so a table path cannot identify a sheet across those switches.
Focus loss cancels 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
speech connection, key grabs, or background event loop.
@@ -128,11 +132,48 @@ References inspected: Orca commit `cd58a88ed` (2026-10-14),
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.
## Dynamic headers slice
- In **Preferences → Key Bindings**, assign shortcuts to “Use the current row
as Calc column headers,” “Use the current column as Calc row headers,” and
the corresponding clear commands. All four are available in both layouts
and Learn Mode, and are unbound by default. Orca's Cthulhu+Shift+C equivalent
conflicts with Cthulhu's existing clipboard command; it is preserved.
Semantic commands are `set-column-headers-row`, `set-row-headers-column`,
`clear-column-headers-row` and `clear-row-headers-column`.
- Set column headers from any cell in the heading row, and row headers from
any cell in the label column. Each axis can be changed or cleared separately.
This follows Orca's shared `table_navigator.py` and `ax_utilities_table.py`,
rather than just its soffice script.
- Automatic speech prefixes the row heading when the row changes and the
column heading when the column changes. A cell does not repeat itself as
its own heading. Blank headings add no label. Basic Where Am I includes both
headings; normal Braille includes both through its existing context path.
Only-displayed-text suppresses automatic speech metadata. Formula commands,
active-cell focus, selections and editor routing retain their existing paths.
- Header text is read afresh with at most two direct cell lookups per snapshot,
bounded to the normal field limit. There is no row/sheet enumeration, polling,
worker or native reference retained by the header feature. Provider response
times still apply. Commands consume the accepted cell snapshot; a provider
mutation without a new event requires revisiting the cell.
- Choices survive sheet/workbook switches and lock/unlock for this reader
session. They are separate per document/view and sheet; they are not saved to
the workbook or settings. A reader restart or document/window destruction
clears them. The store accepts up to 64 sheets; clearing both axes frees a slot.
- Sheet names come from the verified provider's uncached `page-name` attribute,
preserving exact whitespace. Missing or oversized identity metadata disables
these commands. This metadata is exposed by LibreOffice's
[spreadsheet document provider](https://github.com/LibreOffice/core/blob/libreoffice-26-2/sc/source/ui/Accessibility/AccessibleDocument.cxx).
Tab-list child changes and tab-name events conservatively clear choices for
that document window. Reassign headers
after adding, removing or renaming sheets. This depends on provider events;
silent structural mutations cannot be detected. Tab selection alone preserves
choices. Merged headings and structural row/column edits remain unverified;
header choices are coordinates, not references that follow moved content.
## Next slices
1. 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,
1. Spreadsheet full-row reading needs a bounded,
explicitly defined extent; the generic setting is not full Calc support.
2. Writer paragraph navigation, selection, Say All, spell-check presentation,
and Braille panning. Compare each actual workflow with current Orca.
@@ -167,6 +208,12 @@ 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 Calc harness sends semantic requests through the production event loop.
Dynamic-header checks exercise both axes, automatic axis-change speech,
Where Am I, complete Braille context, heading edits, independent clearing,
sheet round trips, and a second workbook with matching sheet names. Returning
to the original workbook is a separate pending acceptance check below.
Header command bindings are checked through
the shared binding resolver in both layouts.
No active reader is replaced and no normal Office profile is changed.
These automated checks do not establish audible speech, physical input, or
@@ -194,7 +241,20 @@ physical Braille. Before submission, review the diff and test a rebuilt binary:
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
7. Assign the four header commands in Key Bindings. In a workbook with headings
across row 1 and labels down column A, set both axes. Move horizontally and
vertically; listen for the changed axis's heading, value and coordinate.
Use Where Am I to hear both headings. Try a blank heading and a formula
heading, edit a heading and revisit a data cell, then clear each axis
separately. Check complete Braille context and that command flashes restore
the updated cell frame. Verify assignment/persistence of shortcuts after
reopening preferences; header choices themselves last only for this session.
Switch sheets, switch to a second workbook with the same sheet names, leave
Calc and return, and lock/unlock. Check independent choices and resumed
headings. Add, remove and rename a disposable sheet; reassign headers after
the conservative reset. Test structural row/column edits and merged headings
separately; these cases have no acceptance claim yet.
8. 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
@@ -232,6 +292,18 @@ 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.
Dynamic-header verification passed focused race checks, the full affected-package
race run in a private D-Bus session, vet/build, parity-ledger validation and
ordinary GTK table/multiselection checks. Recorded Calc checks cover both axes,
heading edits, independent clearing, sheet round trips and isolation in a second
workbook with identical sheet names. A synthetic return to the original workbook
produced stale/timed-out accessibility objects after window activation, even
with Ctrl+F6 and explicit window raising. Its cause was not established, and
baseline behavior was not compared. This is not evidence of working workbook
return; verify that workflow separately. Tab rename/add/remove event delivery,
physical speech, physical input/Braille and independent real-workbook acceptance
remain pending.
## Calc startup focus diagnosis
A normal-profile startup under i3 can leave Calc accepting arrow keys while
+6 -2
View File
@@ -217,6 +217,7 @@ func processEventsWithAttributes(
focusGate: focusGate, syncForeground: syncForeground, speak: speak,
wineReview: wineReview,
}
reviewHandler.calcHeaders, _ = adapter.(libreOfficeHeaderConfigurer)
eventHandler := accessibilityEventHandler{
state: &state, titles: titles, logger: logger, speech: speech, adapter: adapter,
objectReview: objectReview, browser: browser, inputSource: inputSource,
@@ -317,9 +318,12 @@ func processEventsWithAttributes(
event, _ := state.pending.terminalDeletion.take()
speak(event)
case command := <-reviewRequests:
if command == input.PresentCellFormula {
switch command {
case input.PresentCellFormula:
reviewHandler.presentCellFormula()
} else {
case input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn:
reviewHandler.configureCalcHeader(command)
default:
reviewHandler.whereAmI()
}
case <-attributeRequests:
+1 -1
View File
@@ -65,7 +65,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
if table.skip {
return
}
spoken := presentation.EventSpeechWithOptions(event, table.options)
spoken := presentation.EventSpeechWithOptions(state.calcHeaderSpeechEvent(event), table.options)
spoken, appendTableFocus := state.decorateGUITable(event, spoken, table)
text := spoken.Text
prompt := newPromptPresentation(state, event)
+1
View File
@@ -29,6 +29,7 @@ type eventReviewHandler struct {
syncForeground func()
speak func(atspi.Event, ...input.Intent)
wineReview *wineReviewFocusController
calcHeaders libreOfficeHeaderConfigurer
}
func (handler *eventReviewHandler) whereAmI() {
+4
View File
@@ -139,6 +139,10 @@ func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer,
// its navigation state independently so title announcements keep playing.
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
state.libreOfficeSelection = libreOfficeSelectionState{}
if calcSheet(state.current).Valid() {
state.lastTable = atspi.ObjectID{}
state.lastTableCell = atspi.ObjectID{}
}
state.selection = textSelection{}
state.documentSelection = textSelection{}
state.lastClipboardSequence = 0
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"fmt"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
)
type libreOfficeHeaderConfigurer interface {
ConfigureLibreOfficeHeader(atspi.Snapshot, atspi.ObjectID, bool, bool) error
}
func (handler *eventReviewHandler) configureCalcHeader(command input.Command) {
if handler.focusGate.Locked() || handler.focusGate.Suspended() {
return
}
handler.syncForeground()
state := handler.state
snapshot := state.current
context := snapshot.LibreOffice
text := "Not in a Calc cell."
if handler.calcHeaders != nil && context != nil && context.Cell && context.InSpreadsheet &&
!snapshot.Defunct && atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) &&
state.owner.window.Valid() && state.owner.applicationBus == snapshot.BusName &&
state.owner.focus == snapshot.ID() && snapshot.TablePositionKnown {
columnHeaders := command == input.SetColumnHeadersRow || command == input.ClearColumnHeadersRow
clear := command == input.ClearColumnHeadersRow || command == input.ClearRowHeadersColumn
if err := handler.calcHeaders.ConfigureLibreOfficeHeader(snapshot, state.owner.window, columnHeaders, clear); err != nil {
text = err.Error()
} else if columnHeaders {
// The command changes this axis immediately. Its text is captured
// again on the next cell event, never queried by the event loop.
state.current.TableColumnHeaders = ""
text = fmt.Sprintf("Row %d set as column headers.", snapshot.TableRowIndex+1)
if clear {
text = "Column headers cleared."
}
} else {
state.current.TableRowHeaders = ""
text = fmt.Sprintf("Column %s set as row headers.", calcColumnLabel(snapshot.TableColumnIndex))
if clear {
text = "Row headers cleared."
}
}
if handler.brailleOutput != nil {
options := config.DefaultSettings().Braille
if handler.live != nil {
options = handler.live.Load().Braille
}
state.brailleSnapshot = state.current
handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(state.current, options))
}
}
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-headers"})
}
func calcColumnLabel(column int) string {
label := ""
for column >= 0 {
label = string(rune('A'+column%26)) + label
column = column/26 - 1
}
return label
}
// Use existing table presentation history, which belongs to the event loop.
// Keep the full snapshot for Where Am I and Braille; only automatic speech
// omits headings for an unchanged axis, following current Orca.
func (state *eventLoopState) calcHeaderSpeechEvent(event atspi.Event) atspi.Event {
snapshot := event.Source
if snapshot.LibreOffice == nil || !snapshot.LibreOffice.Cell ||
!atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || !snapshot.TablePositionKnown ||
!state.lastTableCell.Valid() || state.lastTable != calcSheet(snapshot) {
return event
}
if snapshot.TableRowIndex == state.lastTableRow {
event.Source.TableRowHeaders = ""
}
if snapshot.TableColumnIndex == state.lastTableColumn {
event.Source.TableColumnHeaders = ""
}
return event
}
+119
View File
@@ -0,0 +1,119 @@
// 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/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestLibreOfficeHeaderCommandsRespectOwnershipAndGates(t *testing.T) {
event := calcTestEvent(atspi.FocusEvent, "/cell", "10", "B3")
event.Source.TableRowIndex, event.Source.TableColumnIndex = 2, 1
event.Source.LibreOffice.Document = atspi.NewObjectID(event.Source.BusName, "/document")
event.Source.LibreOffice.SheetName = "Example"
state := eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
state.current = event.Source
state.owner.follow(event)
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
gate := &focusGate{}
frames := make(chan braille.Frame, 8)
handler := eventReviewHandler{state: &state, speech: speech, focusGate: gate,
calcHeaders: atspi.New(), syncForeground: func() {}, brailleOutput: calcFrameQueue{frames}}
for _, test := range []struct {
command input.Command
want string
}{
{input.SetColumnHeadersRow, "Row 3 set as column headers."},
{input.SetRowHeadersColumn, "Column B set as row headers."},
{input.ClearColumnHeadersRow, "Column headers cleared."},
{input.ClearRowHeadersColumn, "Row headers cleared."},
} {
handler.configureCalcHeader(test.command)
if got := <-speech.spoken; got != test.want {
t.Fatalf("header command = %q", got)
}
<-frames
}
// Clearing immediately refreshes the Braille base frame as well as the
// copied Where Am I snapshot, without waiting for another cell event.
state.current.TableColumnHeaders, state.current.TableRowHeaders = "Total", "Monday"
handler.configureCalcHeader(input.ClearColumnHeadersRow)
<-speech.spoken
if frame := <-frames; frame.Text != "Monday 10 B3" {
t.Fatalf("cleared frame = %+v", frame)
}
if state.current.TableColumnHeaders != "" || state.brailleSnapshot.TableColumnHeaders != "" {
t.Fatal("clear retained column heading")
}
gate.secure.Store(true)
handler.configureCalcHeader(input.SetColumnHeadersRow)
gate.secure.Store(false)
gate.suspended.Store(true)
handler.configureCalcHeader(input.SetColumnHeadersRow)
gate.suspended.Store(false)
if len(speech.spoken) != 0 || len(frames) != 0 {
t.Fatal("header command bypassed lock/suspension")
}
state.owner.focus = atspi.NewObjectID(event.Source.BusName, "/other")
handler.configureCalcHeader(input.SetColumnHeadersRow)
if got := <-speech.spoken; got != "Not in a Calc cell." {
t.Fatal(got)
}
handler.syncForeground = func() { state.current = atspi.Snapshot{} }
handler.configureCalcHeader(input.SetColumnHeadersRow)
if got := <-speech.spoken; got != "Not in a Calc cell." {
t.Fatal(got)
}
}
func TestLibreOfficeHeaderSpeechHistoryAndBraille(t *testing.T) {
event := calcTestEvent(atspi.FocusEvent, "/cell", "10", "B3")
event.Source.TableRowIndex, event.Source.TableColumnIndex = 2, 1
event.Source.TableRowHeaders, event.Source.TableColumnHeaders = "Monday", "Total"
state := eventLoopState{}
state.lastTable, state.lastTableCell = calcSheet(event.Source), event.Source.ID()
state.lastTableRow, state.lastTableColumn = 2, 0
filtered := state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders != "" || filtered.Source.TableColumnHeaders != "Total" {
t.Fatalf("horizontal: %+v", filtered.Source)
}
state.lastTableRow, state.lastTableColumn = 1, 1
filtered = state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders != "Monday" || filtered.Source.TableColumnHeaders != "" {
t.Fatalf("vertical: %+v", filtered.Source)
}
// The accepted snapshot retains both for Where Am I and Braille.
if got := presentation.FocusText(event.Source); got != "Monday row header, Total column header, 10, B3" {
t.Fatal(got)
}
if frame := brailleFocusFrame(event.Source); frame.Text != "Monday Total 10 B3" {
t.Fatalf("frame = %+v", frame)
}
reading := config.DefaultSettings().Reading
reading.OnlySpeakDisplayedText = true
options := presentation.DefaultOptions()
options.Reading = &reading
if got := presentation.FocusTextWithOptions(event.Source, options); got != "10" {
t.Fatal(got)
}
state.current = event.Source
state.clearObjectFocus(nil, nil, nil)
filtered = state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders == "" || filtered.Source.TableColumnHeaders == "" {
t.Fatal("focus return suppressed headers")
}
state.lastTable = atspi.NewObjectID(event.Source.BusName, "/other-sheet")
filtered = state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders == "" || filtered.Source.TableColumnHeaders == "" {
t.Fatal("sheet return suppressed headers")
}
}
+93 -1
View File
@@ -34,7 +34,7 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
if window == "" {
t.Fatal("Calc window missing")
}
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
@@ -246,4 +246,96 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
waitSpeech("2097150 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
// Dynamic headers are real provider cell reads, isolated per sheet.
press("ctrl+Page_Up", "ctrl+Home")
waitSpeech("Alpha, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
commands <- input.SetRowHeadersColumn
waitSpeech("Column A set as row headers.")
press("Right")
waitSpeech("42, B1")
press("Down")
waitSpeech("y row header, Beta, B2")
waitFrame("y 42 Beta B2")
commands <- input.WhereAmI
waitSpeech("y row header, 42 column header, Beta, B2")
press("Right")
waitSpeech("43 column header, blank, C2")
press("Down")
waitSpeech("blank, C3")
press("Up")
waitSpeech("y row header, blank, C2")
press("ctrl+Page_Down", "ctrl+Home")
waitSpeech("Gamma, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
press("Down")
waitSpeech("blank, A2") // Same column: only Where Am I repeats its header.
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
press("ctrl+Page_Up", "ctrl+Home", "Right", "Down")
waitSpeech("y row header, Beta, B2")
// A heading edit is visible on the next cell snapshot, without a cache.
press("Up", "8", "Return")
waitSpeech("y row header, Beta, B2")
waitFrame("y 8 Beta B2")
commands <- input.WhereAmI
waitSpeech("y row header, 8 column header, Beta, B2")
commands <- input.ClearColumnHeadersRow
waitSpeech("Column headers cleared.")
commands <- input.WhereAmI
waitSpeech("y row header, Beta, B2")
commands <- input.ClearRowHeadersColumn
waitSpeech("Row headers cleared.")
commands <- input.WhereAmI
waitSpeech("Beta, B2")
press("Up", "Down")
waitSpeech("Beta, B2")
press("ctrl+Page_Down", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
// A second workbook in the same provider bus must not inherit choices.
profile, document := os.Getenv("CTHULHU_CALC_PROFILE"), os.Getenv("CTHULHU_CALC_SECOND_DOCUMENT")
if profile == "" || document == "" {
t.Fatal("private second-workbook fixture missing")
}
if output, err := exec.CommandContext(ctx, "libreoffice", "-env:UserInstallation="+profile, "--calc", document).CombinedOutput(); err != nil {
t.Fatalf("open second private workbook: %v: %s", err, output)
}
secondWindow := ""
deadline := time.Now().Add(5 * time.Second)
for secondWindow == "" && time.Now().Before(deadline) {
output, _ := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc").Output()
for _, candidate := range strings.Fields(string(output)) {
if candidate != window {
secondWindow = candidate
}
}
if secondWindow == "" {
time.Sleep(20 * time.Millisecond)
}
}
if secondWindow == "" {
t.Fatal("second private workbook window missing")
}
// A virtual display without a window manager does not focus new windows.
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", secondWindow).Run(); err != nil {
t.Fatal(err)
}
press("ctrl+F6", "ctrl+Page_Down", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("blank, A2")
press("Up", "d", "Return")
waitSpeech("blank, A2")
press("Up")
waitSpeech("d, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
press("Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("d column header, blank, A2")
}
+4 -2
View File
@@ -228,11 +228,13 @@ func (worker commandRuntime) run(ctx context.Context) error {
if onNotificationList != nil {
onNotificationList()
}
case input.WhereAmI, input.PresentCellFormula:
case input.WhereAmI, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn:
if command == input.WhereAmI {
logger.Info("where am I command received")
} else {
} else if command == input.PresentCellFormula {
logger.Info("cell formula command received")
} else {
logger.Info("Calc header command received", "command", command)
}
select {
case reviewRequests <- command:
+1 -1
View File
@@ -47,7 +47,7 @@ func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
t.Error("command worker did not stop")
}
}()
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula} {
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn} {
commands.commands <- input.CommandEvent{Command: command}
select {
case got := <-requests:
+1
View File
@@ -107,6 +107,7 @@ var eventTypes = []string{
}
type Adapter struct {
libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop
applicationProcesses map[string]string // GLib owner; bounded provider identity cache
webNotificationRoots map[ObjectID]bool // copied identities only; no native references
foregroundReader *xorg.ForegroundReader // GLib thread only
@@ -302,6 +302,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
}
if !focusEvent.ForegroundMismatch {
captureLibreOfficeContext(target, &snapshot)
adapter.captureLibreOfficeHeaders(target, &snapshot, focusEvent.Window)
if eventType == SelectionChangedEvent && snapshot.Role == "table" &&
snapshot.LibreOffice != nil && snapshot.LibreOffice.InSpreadsheet {
snapshot.LibreOffice.Selection = captureLibreOfficeSelection(target)
@@ -383,15 +384,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
return
}
secureEpoch := adapter.secureEpoch.Load()
// Retire copied header identities even while locked. Tab lifecycle only
// checks roles; it never captures text or presents output.
nativeEventType := C.GoString(C.cthulhu_event_type(event))
eventType := canonicalTextChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
}
if adapter.secure.Load() {
return
}
adapter.applySecureReset()
adapter.refreshForeground()
nativeEventType := C.GoString(C.cthulhu_event_type(event))
eventType := canonicalTextChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
adapter.forgetDocumentWindow(originID)
} else if eventType == WindowDestroyedEvent {
@@ -56,6 +56,7 @@ func (adapter *Adapter) Start(ctx context.Context) error {
C.g_object_unref(C.gpointer(focus.source))
}
adapter.nativeFocus = nil
adapter.libreOfficeHeaders.reset()
for _, known := range adapter.documentWindows {
C.g_object_unref(C.gpointer(known.source))
}
+2
View File
@@ -21,6 +21,8 @@ type LibreOfficeContext struct {
CellText string
Coordinates string
Formula string
Document ObjectID
SheetName string // provider's page-name; table objects are recreated on tab switches
Selection *LibreOfficeSelection
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"errors"
"sync"
)
// Choices contain only identities and coordinates, never provider objects or
// cell text. They survive focus/lock/tab changes and expire on document/window
// destruction, sheet topology/name changes and shutdown. A fixed limit bounds
// missed deaths. Calc recreates its table objects on every sheet switch.
type libreOfficeHeaderKey struct {
window, document ObjectID
sheet string
}
type libreOfficeHeaderChoice struct {
row, column int
rowSet, columnSet bool
}
type libreOfficeHeaderStore struct {
mutex sync.Mutex
choices map[libreOfficeHeaderKey]libreOfficeHeaderChoice
}
func libreOfficeHeaderIdentity(snapshot Snapshot, window ObjectID) (libreOfficeHeaderKey, bool) {
context := snapshot.LibreOffice
key := libreOfficeHeaderKey{window: window}
if context != nil {
key.document = context.Document
key.sheet = context.SheetName
}
valid := IsLibreOfficeProcess(snapshot.ApplicationProcess) && !snapshot.Defunct &&
snapshot.Role == "table cell" && context != nil && context.InSpreadsheet && context.Cell &&
snapshot.TablePositionKnown && snapshot.TableRowIndex >= 0 && snapshot.TableColumnIndex >= 0 &&
key.window.Valid() && key.document.Valid() && key.sheet != "" && snapshot.TablePath != "" &&
key.window.BusName == snapshot.BusName && key.document.BusName == snapshot.BusName
return key, valid
}
// ConfigureLibreOfficeHeader is called only for the event loop's owned cell.
// It copies a choice without querying AT-SPI or waiting for the GLib thread.
// columnHeaders selects the row that supplies column headings; otherwise the
// current column supplies row headings. clear removes only that axis.
func (adapter *Adapter) ConfigureLibreOfficeHeader(snapshot Snapshot, window ObjectID, columnHeaders, clear bool) error {
key, valid := libreOfficeHeaderIdentity(snapshot, window)
if !valid || adapter.secure.Load() {
return errors.New("Not in a Calc cell.")
}
store := &adapter.libreOfficeHeaders
store.mutex.Lock()
defer store.mutex.Unlock()
choice, exists := store.choices[key]
if !exists && !clear && len(store.choices) >= 64 {
return errors.New("Header choices are full. Clear a sheet's headers first.")
}
if columnHeaders {
choice.row, choice.rowSet = snapshot.TableRowIndex, !clear
} else {
choice.column, choice.columnSet = snapshot.TableColumnIndex, !clear
}
if !choice.rowSet && !choice.columnSet {
delete(store.choices, key)
return nil
}
if store.choices == nil {
store.choices = make(map[libreOfficeHeaderKey]libreOfficeHeaderChoice)
}
store.choices[key] = choice
return nil
}
func (store *libreOfficeHeaderStore) get(key libreOfficeHeaderKey) libreOfficeHeaderChoice {
store.mutex.Lock()
defer store.mutex.Unlock()
return store.choices[key]
}
func (store *libreOfficeHeaderStore) forget(id ObjectID) {
store.mutex.Lock()
defer store.mutex.Unlock()
for key := range store.choices {
if id == key.window || id == key.document {
delete(store.choices, key)
}
}
}
func (store *libreOfficeHeaderStore) hasBus(bus string) bool {
store.mutex.Lock()
defer store.mutex.Unlock()
for key := range store.choices {
if key.window.BusName == bus {
return true
}
}
return false
}
func (store *libreOfficeHeaderStore) reset() {
store.mutex.Lock()
defer store.mutex.Unlock()
store.choices = nil
}
@@ -0,0 +1,103 @@
// 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 "strings"
// Sheet names are unique within a Calc document. Conservatively retire choices
// for this window when sheet tabs are added, removed or renamed, so a
// deleted/recreated name cannot inherit an old sheet's choice. Switching tabs
// only changes selected state and does not retire choices.
func (adapter *Adapter) retireLibreOfficeHeaders(source *C.AtspiAccessible, eventType string, origin ObjectID) {
if eventType == WindowDestroyedEvent || eventType == DefunctStateEvent {
adapter.libreOfficeHeaders.forget(origin)
}
if (eventType != AccessibleNameEvent && !strings.HasPrefix(eventType, ChildrenChangedEvent)) ||
!adapter.libreOfficeHeaders.hasBus(origin.BusName) {
return
}
role := C.atspi_accessible_get_role(source, nil)
if eventType == AccessibleNameEvent && role == C.ATSPI_ROLE_PAGE_TAB ||
strings.HasPrefix(eventType, ChildrenChangedEvent) && role == C.ATSPI_ROLE_PAGE_TAB_LIST {
window := C.cthulhu_window_ancestor(source)
if window != nil {
adapter.libreOfficeHeaders.forget(nativeObjectID(window))
C.g_object_unref(C.gpointer(window))
}
}
}
// GLib owner only: at most two direct cell lookups, with no sheet enumeration
// or retained native references. Read fresh text on each accepted snapshot so
// edits to a heading are reflected when the user next visits a data cell.
func (adapter *Adapter) captureLibreOfficeHeaders(source *C.AtspiAccessible, snapshot *Snapshot, window ObjectID) {
key, valid := libreOfficeHeaderIdentity(*snapshot, window)
if !valid {
return
}
choice := adapter.libreOfficeHeaders.get(key)
if !choice.rowSet && !choice.columnSet {
return
}
cell := C.atspi_accessible_get_table_cell(source)
if cell == nil {
return
}
defer C.g_object_unref(C.gpointer(cell))
accessible := C.atspi_table_cell_get_table(cell, nil)
if accessible == nil {
return
}
defer C.g_object_unref(C.gpointer(accessible))
if nativeObjectID(accessible) != NewObjectID(snapshot.BusName, snapshot.TablePath) {
return
}
table := C.atspi_accessible_get_table_iface(accessible)
if table == nil {
return
}
defer C.g_object_unref(C.gpointer(table))
if choice.columnSet && choice.column != snapshot.TableColumnIndex {
snapshot.TableRowHeaders = libreOfficeHeaderText(table, snapshot.TableRowIndex, choice.column)
}
if choice.rowSet && choice.row != snapshot.TableRowIndex {
snapshot.TableColumnHeaders = libreOfficeHeaderText(table, choice.row, snapshot.TableColumnIndex)
}
}
func libreOfficeHeaderText(table *C.AtspiTable, row, column int) string {
cell := C.atspi_table_get_accessible_at(table, C.gint(row), C.gint(column), nil)
if cell == nil {
return ""
}
defer C.g_object_unref(C.gpointer(cell))
if C.atspi_accessible_get_role(cell, nil) != C.ATSPI_ROLE_TABLE_CELL {
return ""
}
C.atspi_accessible_clear_cache_single(cell)
states := C.atspi_accessible_get_state_set(cell)
if states == nil {
return ""
}
defunct := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0
C.g_object_unref(C.gpointer(states))
if defunct {
return ""
}
text := C.atspi_accessible_get_text_iface(cell)
if text == nil {
return ""
}
defer C.g_object_unref(C.gpointer(text))
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)
defer C.g_free(C.gpointer(content))
return sanitizeSnapshotField(C.GoString(content))
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"fmt"
"sync"
"testing"
)
func headerTestSnapshot() (Snapshot, ObjectID) {
return Snapshot{BusName: ":1.42", Path: "/cell", TablePath: "/sheet", Role: "table cell",
ApplicationProcess: "soffice.bin", TablePositionKnown: true, TableRowIndex: 2, TableColumnIndex: 3,
LibreOffice: &LibreOfficeContext{Cell: true, InSpreadsheet: true, Document: NewObjectID(":1.42", "/document"), SheetName: "Example"}}, NewObjectID(":1.42", "/window")
}
func TestLibreOfficeHeadersScopeLifetimeAndIndependentAxes(t *testing.T) {
adapter := New()
snapshot, window := headerTestSnapshot()
key, _ := libreOfficeHeaderIdentity(snapshot, window)
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err != nil {
t.Fatal(err)
}
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, false, false); err != nil {
t.Fatal(err)
}
choice := adapter.libreOfficeHeaders.get(key)
if !choice.rowSet || choice.row != 2 || !choice.columnSet || choice.column != 3 {
t.Fatalf("choice = %+v", choice)
}
snapshot.TablePath = "/recreated-sheet-table"
recreated, _ := libreOfficeHeaderIdentity(snapshot, window)
if adapter.libreOfficeHeaders.get(recreated) != choice {
t.Fatal("sheet switch lost the document choice")
}
for _, other := range []libreOfficeHeaderKey{
{NewObjectID(":1.42", "/other-window"), key.document, key.sheet},
{key.window, NewObjectID(":1.42", "/other-document"), key.sheet},
{key.window, key.document, "Other"},
{NewObjectID(":1.43", "/window"), NewObjectID(":1.43", "/document"), "Example"},
} {
if got := adapter.libreOfficeHeaders.get(other); got.rowSet || got.columnSet {
t.Fatalf("headers crossed identity: %+v", other)
}
}
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, true); err != nil {
t.Fatal(err)
}
if got := adapter.libreOfficeHeaders.get(key); got.rowSet || !got.columnSet {
t.Fatalf("clearing one axis changed the other: %+v", got)
}
// Locking retires output/native focus, but document choices survive.
adapter.SetSecureInputActive(true)
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err == nil {
t.Fatal("configuration accepted while locked")
}
adapter.SetSecureInputActive(false)
if !adapter.libreOfficeHeaders.get(key).columnSet {
t.Fatal("lock discarded the document choice")
}
adapter.libreOfficeHeaders.forget(NewObjectID(":1.42", "/other-window"))
if !adapter.libreOfficeHeaders.get(key).columnSet {
t.Fatal("unrelated destruction discarded choice")
}
for _, id := range []ObjectID{key.document, key.window} {
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, false, false); err != nil {
t.Fatal(err)
}
adapter.libreOfficeHeaders.forget(id)
if got := adapter.libreOfficeHeaders.get(key); got.rowSet || got.columnSet {
t.Fatalf("destruction retained choice: %+v", id)
}
}
_ = adapter.ConfigureLibreOfficeHeader(snapshot, window, false, false)
adapter.libreOfficeHeaders.reset()
if len(adapter.libreOfficeHeaders.choices) != 0 {
t.Fatal("shutdown retained choices")
}
_ = adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false)
adapter.libreOfficeHeaders.forget(NewObjectID(":1.42", "/other-window"))
if !adapter.libreOfficeHeaders.get(key).rowSet {
t.Fatal("unrelated tab changes retired choice")
}
adapter.libreOfficeHeaders.forget(window)
if adapter.libreOfficeHeaders.get(key).rowSet {
t.Fatal("sheet topology change retained choice")
}
}
func TestLibreOfficeHeadersRejectInvalidContextAndBoundChoices(t *testing.T) {
for _, invalid := range []string{"process", "editor", "defunct", "position", "negative", "document", "sheet", "bus", "window"} {
snapshot, window := headerTestSnapshot()
switch invalid {
case "process":
snapshot.ApplicationProcess = "chromium"
case "editor":
snapshot.Role = "paragraph"
case "defunct":
snapshot.Defunct = true
case "position":
snapshot.TablePositionKnown = false
case "negative":
snapshot.TableColumnIndex = -1
case "document":
snapshot.LibreOffice.Document = ObjectID{}
case "sheet":
snapshot.LibreOffice.SheetName = ""
case "bus":
window.BusName = ":1.43"
case "window":
window = ObjectID{}
}
adapter := New()
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err == nil {
t.Fatalf("accepted invalid %s context", invalid)
}
}
adapter := New()
snapshot, window := headerTestSnapshot()
for index := 0; index < 65; index++ {
snapshot.LibreOffice.SheetName = fmt.Sprintf("Sheet%d", index)
err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false)
if (err != nil) != (index == 64) {
t.Fatalf("bounded choice %d: %v", index, err)
}
}
snapshot.LibreOffice.SheetName = "Sheet0"
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, true); err != nil {
t.Fatal(err)
}
snapshot.LibreOffice.SheetName = "Sheet64"
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err != nil {
t.Fatal("clear did not free capacity:", err)
}
}
func TestLibreOfficeHeadersConcurrentCaptureAndCommands(t *testing.T) {
adapter := New()
snapshot, window := headerTestSnapshot()
key, _ := libreOfficeHeaderIdentity(snapshot, window)
var workers sync.WaitGroup
for index := 0; index < 3; index++ {
workers.Add(1)
go func(index int) {
defer workers.Done()
for count := 0; count < 100; count++ {
switch index {
case 0:
_ = adapter.ConfigureLibreOfficeHeader(snapshot, window, true, count%2 == 0)
case 1:
_ = adapter.libreOfficeHeaders.get(key)
case 2:
adapter.libreOfficeHeaders.forget(window)
}
}
}(index)
}
workers.Wait()
}
+18
View File
@@ -110,6 +110,24 @@ func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) {
}
if role == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET {
context.InSpreadsheet = true
context.Document = nativeObjectID(current)
if snapshot.Role == "table cell" {
// The document/view survives sheet switches; its table does not.
// Read the provider's uncached page-name, never a localized title.
C.atspi_accessible_clear_cache_single(current)
attributes := C.atspi_accessible_get_attributes(current, nil)
if attributes != nil {
key := C.CString("page-name")
// Identity must preserve whitespace: distinct sheet names
// must not collapse onto the same key. Reject oversized names.
context.SheetName = C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(key))))
if len(context.SheetName) > maxSnapshotFieldLength {
context.SheetName = ""
}
C.free(unsafe.Pointer(key))
C.g_hash_table_unref(attributes)
}
}
C.g_object_unref(C.gpointer(current))
current = nil
break
+9 -1
View File
@@ -55,6 +55,14 @@ func CommandLabel(command Command) string {
switch command {
case PresentCellFormula:
return "Present the formula for the current cell"
case SetColumnHeadersRow:
return "Use the current row as Calc column headers"
case ClearColumnHeadersRow:
return "Clear Calc column headers"
case SetRowHeadersColumn:
return "Use the current column as Calc row headers"
case ClearRowHeadersColumn:
return "Clear Calc row headers"
case OCRObject:
return "Recognize object of interest"
case OCRWindow:
@@ -166,7 +174,7 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo
groups[command] = append(groups[command], FormatCommandChord(chord))
}
if scope == "global" {
for _, command := range []Command{PresentCellFormula, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
for _, command := range []Command{PresentCellFormula, SetColumnHeadersRow, ClearColumnHeadersRow, SetRowHeadersColumn, ClearRowHeadersColumn, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
if _, exists := groups[command]; !exists {
groups[command] = nil
}
+4
View File
@@ -108,6 +108,10 @@ const (
ApplicationPreferences Command = "application-preferences"
ChooseApplicationPreferences Command = "choose-application-preferences"
PresentCellFormula Command = "present-cell-formula"
SetColumnHeadersRow Command = "set-column-headers-row"
ClearColumnHeadersRow Command = "clear-column-headers-row"
SetRowHeadersColumn Command = "set-row-headers-column"
ClearRowHeadersColumn Command = "clear-row-headers-column"
WhereAmI Command = "where-am-i"
PresentCharacterAttributes Command = "present-character-attributes"
PresentLastNotification Command = "present-last-notification"
+27
View File
@@ -134,6 +134,33 @@ func TestCellFormulaBindingAndLaptopOverride(t *testing.T) {
}
}
func TestCalcHeaderCommandsCanBeBoundInBothLayouts(t *testing.T) {
commands := []Command{SetColumnHeadersRow, ClearColumnHeadersRow, SetRowHeadersColumn, ClearRowHeadersColumn}
keys := []string{"Cthulhu+Ctrl+Shift+r", "Cthulhu+Ctrl+Alt+r", "Cthulhu+Ctrl+Shift+c", "Cthulhu+Ctrl+Alt+c"}
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
var overrides []BindingOverride
for index, command := range commands {
overrides = append(overrides, BindingOverride{Layout: layout, Scope: "global", Command: command, Keys: []string{keys[index]}})
}
if err := ValidateBindingOverrides(overrides); err != nil {
t.Fatal(err)
}
bindings, err := ResolveCommandBindings(layout, false, overrides)
if err != nil {
t.Fatal(err)
}
for index, command := range commands {
chord, _ := ParseCommandChord(keys[index])
if bindings[chord] != command {
t.Fatalf("layout %v: %s did not bind", layout, command)
}
}
if got := bindings[CommandChord{Symbol: 'c', Modifiers: 1, CthulhuModifier: true}]; got != PresentClipboard {
t.Fatalf("header commands displaced clipboard: %s", got)
}
}
}
func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
bindings := DefaultCommandBindings(layout)
+1 -1
View File
@@ -54,6 +54,6 @@ 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. 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.
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 row and column headers now use session choices scoped by provider bus document view window and exact sheet name; native reads are bounded to two cells. Four assignable commands preserve existing shortcuts. Real Calc checks cover heading edits independent axis clearing and sheet round trips; physical speech/input/Braille acceptance remains pending. 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.
wayland-shared-keyboard-access,src/cthulhu/input_event_manager.py,input,Monitor keys and grab reader shortcuts through the shared accessibility keyboard interface,Storm Dragon,internal/platform/wayland internal/app internal/sessionbus,in-progress,Private dbus-run-session race tests cover wire protocol runtime dispatch configuration rollback secure transitions and cleanup; go test ./... -count=1; python3 tests/check_speech.py,Existing native_x11.py keyboard harness passed; browser tabs and editing passed; browser modes passed on isolated rerun after one failure whose cause was not established.,Shared D-Bus fixture passed without an X display. Live niri and physical key suppression remain unverified.,Pending live listening on Wayland,Pending physical Braille on Wayland,No compositor-specific code. See WAYLAND_SUPPORT.md for supported commands limitations and live acceptance. X11 production listeners are unchanged; the suspension probe now links its existing xkbcommon dependency.
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. 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. 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 row and column headers now use session choices scoped by provider bus document view window and exact sheet name; native reads are bounded to two cells. Four assignable commands preserve existing shortcuts. Real Calc checks cover heading edits independent axis clearing and sheet round trips; physical speech/input/Braille acceptance remains pending. 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.
59 wayland-shared-keyboard-access src/cthulhu/input_event_manager.py input Monitor keys and grab reader shortcuts through the shared accessibility keyboard interface Storm Dragon internal/platform/wayland internal/app internal/sessionbus in-progress Private dbus-run-session race tests cover wire protocol runtime dispatch configuration rollback secure transitions and cleanup; go test ./... -count=1; python3 tests/check_speech.py Existing native_x11.py keyboard harness passed; browser tabs and editing passed; browser modes passed on isolated rerun after one failure whose cause was not established. Shared D-Bus fixture passed without an X display. Live niri and physical key suppression remain unverified. Pending live listening on Wayland Pending physical Braille on Wayland No compositor-specific code. See WAYLAND_SUPPORT.md for supported commands limitations and live acceptance. X11 production listeners are unchanged; the suspension probe now links its existing xkbcommon dependency.
+8 -4
View File
@@ -39,6 +39,8 @@ def run_inside():
)
document = root / "Example.fods"
shutil.copyfile(Path(__file__).with_name("libreoffice.fods"), document)
secondDocument = root / "Other.fods"
shutil.copyfile(document, secondDocument)
environment = dict(os.environ, SAL_USE_VCLPLUGIN="gtk3",
LC_ALL="C.UTF-8",
GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0",
@@ -86,13 +88,15 @@ def run_inside():
return found
wait_for(ready, "Calc accessible sheet missing")
environment.update(CTHULHU_NATIVE_LIBREOFFICE="1", CTHULHU_CALC_WINDOW=window)
environment.update(CTHULHU_NATIVE_LIBREOFFICE="1", CTHULHU_CALC_WINDOW=window,
CTHULHU_CALC_PROFILE=(root / "profile").as_uri(),
CTHULHU_CALC_SECOND_DOCUMENT=str(secondDocument))
environment.setdefault("GOCACHE", "/tmp/cthulhu-go-cache")
subprocess.run(
["go", "test", "./internal/app", "-run", "^TestNativeLibreOfficeCalc$",
"-count=1", "-timeout=50s", "-v"],
"-count=1", "-timeout=80s", "-v"],
cwd=Path(__file__).resolve().parents[1], env=environment,
check=True, timeout=60,
check=True, timeout=90,
)
finally:
if application.poll() is None:
@@ -143,7 +147,7 @@ def main():
subprocess.run(
["xvfb-run", "-a", "dbus-run-session", "--", sys.executable,
str(Path(__file__).resolve()), "--inside"],
env=environment, check=True, timeout=90,
env=environment, check=True, timeout=120,
)