More LibreOffice changes.
This commit is contained in:
1 parent
3e0bdc578f
commit
f885333520
23 files changed
+852
-23
No files matched your search
+79
-7
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,7 @@ type eventReviewHandler struct {
|
||||
syncForeground func()
|
||||
speak func(atspi.Event, ...input.Intent)
|
||||
wineReview *wineReviewFocusController
|
||||
calcHeaders libreOfficeHeaderConfigurer
|
||||
}
|
||||
|
||||
func (handler *eventReviewHandler) whereAmI() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user