diff --git a/LIBREOFFICE_SUPPORT.md b/LIBREOFFICE_SUPPORT.md index 97ec8c9..9d8563d 100644 --- a/LIBREOFFICE_SUPPORT.md +++ b/LIBREOFFICE_SUPPORT.md @@ -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 diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index ac2d200..16782d2 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -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: diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index 70202d8..52e815d 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -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) diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index d74b5fa..363478a 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -29,6 +29,7 @@ type eventReviewHandler struct { syncForeground func() speak func(atspi.Event, ...input.Intent) wineReview *wineReviewFocusController + calcHeaders libreOfficeHeaderConfigurer } func (handler *eventReviewHandler) whereAmI() { diff --git a/internal/app/event_state.go b/internal/app/event_state.go index 9264ef7..845384e 100644 --- a/internal/app/event_state.go +++ b/internal/app/event_state.go @@ -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 diff --git a/internal/app/libreoffice_headers.go b/internal/app/libreoffice_headers.go new file mode 100644 index 0000000..9df2033 --- /dev/null +++ b/internal/app/libreoffice_headers.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "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 +} diff --git a/internal/app/libreoffice_headers_test.go b/internal/app/libreoffice_headers_test.go new file mode 100644 index 0000000..70ddbe1 --- /dev/null +++ b/internal/app/libreoffice_headers_test.go @@ -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") + } +} diff --git a/internal/app/libreoffice_native_test.go b/internal/app/libreoffice_native_test.go index 5eeed35..95e07dc 100644 --- a/internal/app/libreoffice_native_test.go +++ b/internal/app/libreoffice_native_test.go @@ -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") } diff --git a/internal/app/runtime_commands.go b/internal/app/runtime_commands.go index 8db8ec7..0ca3442 100644 --- a/internal/app/runtime_commands.go +++ b/internal/app/runtime_commands.go @@ -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: diff --git a/internal/app/runtime_commands_test.go b/internal/app/runtime_commands_test.go index df98190..8e6e41a 100644 --- a/internal/app/runtime_commands_test.go +++ b/internal/app/runtime_commands_test.go @@ -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: diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index d0cb815..a7b7e7b 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -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 diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 5c7a78a..605f8cc 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -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 { diff --git a/internal/atspi/adapter_lifecycle_native.go b/internal/atspi/adapter_lifecycle_native.go index 7bf54bf..e3300fb 100644 --- a/internal/atspi/adapter_lifecycle_native.go +++ b/internal/atspi/adapter_lifecycle_native.go @@ -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)) } diff --git a/internal/atspi/libreoffice.go b/internal/atspi/libreoffice.go index 11b71f8..351a90f 100644 --- a/internal/atspi/libreoffice.go +++ b/internal/atspi/libreoffice.go @@ -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 } diff --git a/internal/atspi/libreoffice_headers.go b/internal/atspi/libreoffice_headers.go new file mode 100644 index 0000000..11ccbfc --- /dev/null +++ b/internal/atspi/libreoffice_headers.go @@ -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 +} diff --git a/internal/atspi/libreoffice_headers_native.go b/internal/atspi/libreoffice_headers_native.go new file mode 100644 index 0000000..e3017a5 --- /dev/null +++ b/internal/atspi/libreoffice_headers_native.go @@ -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)) +} diff --git a/internal/atspi/libreoffice_headers_test.go b/internal/atspi/libreoffice_headers_test.go new file mode 100644 index 0000000..4c4306c --- /dev/null +++ b/internal/atspi/libreoffice_headers_test.go @@ -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() +} diff --git a/internal/atspi/libreoffice_native.go b/internal/atspi/libreoffice_native.go index 9b8a559..ece8307 100644 --- a/internal/atspi/libreoffice_native.go +++ b/internal/atspi/libreoffice_native.go @@ -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 diff --git a/internal/input/bindings.go b/internal/input/bindings.go index e68dccc..add3070 100644 --- a/internal/input/bindings.go +++ b/internal/input/bindings.go @@ -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 } diff --git a/internal/input/commands.go b/internal/input/commands.go index 6219ee4..4bffec2 100644 --- a/internal/input/commands.go +++ b/internal/input/commands.go @@ -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" diff --git a/internal/input/commands_test.go b/internal/input/commands_test.go index 2504878..936e8e0 100644 --- a/internal/input/commands_test.go +++ b/internal/input/commands_test.go @@ -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) diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 697d614..46a5cb6 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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. diff --git a/tests/native_libreoffice.py b/tests/native_libreoffice.py index 86475f5..bdfd106 100644 --- a/tests/native_libreoffice.py +++ b/tests/native_libreoffice.py @@ -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, )