More LibreOffice changes.

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

No files matched your search

+6 -2
View File
@@ -217,6 +217,7 @@ func processEventsWithAttributes(
focusGate: focusGate, syncForeground: syncForeground, speak: speak,
wineReview: wineReview,
}
reviewHandler.calcHeaders, _ = adapter.(libreOfficeHeaderConfigurer)
eventHandler := accessibilityEventHandler{
state: &state, titles: titles, logger: logger, speech: speech, adapter: adapter,
objectReview: objectReview, browser: browser, inputSource: inputSource,
@@ -317,9 +318,12 @@ func processEventsWithAttributes(
event, _ := state.pending.terminalDeletion.take()
speak(event)
case command := <-reviewRequests:
if command == input.PresentCellFormula {
switch command {
case input.PresentCellFormula:
reviewHandler.presentCellFormula()
} else {
case input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn:
reviewHandler.configureCalcHeader(command)
default:
reviewHandler.whereAmI()
}
case <-attributeRequests:
+1 -1
View File
@@ -65,7 +65,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
if table.skip {
return
}
spoken := presentation.EventSpeechWithOptions(event, table.options)
spoken := presentation.EventSpeechWithOptions(state.calcHeaderSpeechEvent(event), table.options)
spoken, appendTableFocus := state.decorateGUITable(event, spoken, table)
text := spoken.Text
prompt := newPromptPresentation(state, event)
+1
View File
@@ -29,6 +29,7 @@ type eventReviewHandler struct {
syncForeground func()
speak func(atspi.Event, ...input.Intent)
wineReview *wineReviewFocusController
calcHeaders libreOfficeHeaderConfigurer
}
func (handler *eventReviewHandler) whereAmI() {
+4
View File
@@ -139,6 +139,10 @@ func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer,
// its navigation state independently so title announcements keep playing.
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
state.libreOfficeSelection = libreOfficeSelectionState{}
if calcSheet(state.current).Valid() {
state.lastTable = atspi.ObjectID{}
state.lastTableCell = atspi.ObjectID{}
}
state.selection = textSelection{}
state.documentSelection = textSelection{}
state.lastClipboardSequence = 0
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"fmt"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
)
type libreOfficeHeaderConfigurer interface {
ConfigureLibreOfficeHeader(atspi.Snapshot, atspi.ObjectID, bool, bool) error
}
func (handler *eventReviewHandler) configureCalcHeader(command input.Command) {
if handler.focusGate.Locked() || handler.focusGate.Suspended() {
return
}
handler.syncForeground()
state := handler.state
snapshot := state.current
context := snapshot.LibreOffice
text := "Not in a Calc cell."
if handler.calcHeaders != nil && context != nil && context.Cell && context.InSpreadsheet &&
!snapshot.Defunct && atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) &&
state.owner.window.Valid() && state.owner.applicationBus == snapshot.BusName &&
state.owner.focus == snapshot.ID() && snapshot.TablePositionKnown {
columnHeaders := command == input.SetColumnHeadersRow || command == input.ClearColumnHeadersRow
clear := command == input.ClearColumnHeadersRow || command == input.ClearRowHeadersColumn
if err := handler.calcHeaders.ConfigureLibreOfficeHeader(snapshot, state.owner.window, columnHeaders, clear); err != nil {
text = err.Error()
} else if columnHeaders {
// The command changes this axis immediately. Its text is captured
// again on the next cell event, never queried by the event loop.
state.current.TableColumnHeaders = ""
text = fmt.Sprintf("Row %d set as column headers.", snapshot.TableRowIndex+1)
if clear {
text = "Column headers cleared."
}
} else {
state.current.TableRowHeaders = ""
text = fmt.Sprintf("Column %s set as row headers.", calcColumnLabel(snapshot.TableColumnIndex))
if clear {
text = "Row headers cleared."
}
}
if handler.brailleOutput != nil {
options := config.DefaultSettings().Braille
if handler.live != nil {
options = handler.live.Load().Braille
}
state.brailleSnapshot = state.current
handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(state.current, options))
}
}
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-headers"})
}
func calcColumnLabel(column int) string {
label := ""
for column >= 0 {
label = string(rune('A'+column%26)) + label
column = column/26 - 1
}
return label
}
// Use existing table presentation history, which belongs to the event loop.
// Keep the full snapshot for Where Am I and Braille; only automatic speech
// omits headings for an unchanged axis, following current Orca.
func (state *eventLoopState) calcHeaderSpeechEvent(event atspi.Event) atspi.Event {
snapshot := event.Source
if snapshot.LibreOffice == nil || !snapshot.LibreOffice.Cell ||
!atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || !snapshot.TablePositionKnown ||
!state.lastTableCell.Valid() || state.lastTable != calcSheet(snapshot) {
return event
}
if snapshot.TableRowIndex == state.lastTableRow {
event.Source.TableRowHeaders = ""
}
if snapshot.TableColumnIndex == state.lastTableColumn {
event.Source.TableColumnHeaders = ""
}
return event
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestLibreOfficeHeaderCommandsRespectOwnershipAndGates(t *testing.T) {
event := calcTestEvent(atspi.FocusEvent, "/cell", "10", "B3")
event.Source.TableRowIndex, event.Source.TableColumnIndex = 2, 1
event.Source.LibreOffice.Document = atspi.NewObjectID(event.Source.BusName, "/document")
event.Source.LibreOffice.SheetName = "Example"
state := eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
state.current = event.Source
state.owner.follow(event)
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
gate := &focusGate{}
frames := make(chan braille.Frame, 8)
handler := eventReviewHandler{state: &state, speech: speech, focusGate: gate,
calcHeaders: atspi.New(), syncForeground: func() {}, brailleOutput: calcFrameQueue{frames}}
for _, test := range []struct {
command input.Command
want string
}{
{input.SetColumnHeadersRow, "Row 3 set as column headers."},
{input.SetRowHeadersColumn, "Column B set as row headers."},
{input.ClearColumnHeadersRow, "Column headers cleared."},
{input.ClearRowHeadersColumn, "Row headers cleared."},
} {
handler.configureCalcHeader(test.command)
if got := <-speech.spoken; got != test.want {
t.Fatalf("header command = %q", got)
}
<-frames
}
// Clearing immediately refreshes the Braille base frame as well as the
// copied Where Am I snapshot, without waiting for another cell event.
state.current.TableColumnHeaders, state.current.TableRowHeaders = "Total", "Monday"
handler.configureCalcHeader(input.ClearColumnHeadersRow)
<-speech.spoken
if frame := <-frames; frame.Text != "Monday 10 B3" {
t.Fatalf("cleared frame = %+v", frame)
}
if state.current.TableColumnHeaders != "" || state.brailleSnapshot.TableColumnHeaders != "" {
t.Fatal("clear retained column heading")
}
gate.secure.Store(true)
handler.configureCalcHeader(input.SetColumnHeadersRow)
gate.secure.Store(false)
gate.suspended.Store(true)
handler.configureCalcHeader(input.SetColumnHeadersRow)
gate.suspended.Store(false)
if len(speech.spoken) != 0 || len(frames) != 0 {
t.Fatal("header command bypassed lock/suspension")
}
state.owner.focus = atspi.NewObjectID(event.Source.BusName, "/other")
handler.configureCalcHeader(input.SetColumnHeadersRow)
if got := <-speech.spoken; got != "Not in a Calc cell." {
t.Fatal(got)
}
handler.syncForeground = func() { state.current = atspi.Snapshot{} }
handler.configureCalcHeader(input.SetColumnHeadersRow)
if got := <-speech.spoken; got != "Not in a Calc cell." {
t.Fatal(got)
}
}
func TestLibreOfficeHeaderSpeechHistoryAndBraille(t *testing.T) {
event := calcTestEvent(atspi.FocusEvent, "/cell", "10", "B3")
event.Source.TableRowIndex, event.Source.TableColumnIndex = 2, 1
event.Source.TableRowHeaders, event.Source.TableColumnHeaders = "Monday", "Total"
state := eventLoopState{}
state.lastTable, state.lastTableCell = calcSheet(event.Source), event.Source.ID()
state.lastTableRow, state.lastTableColumn = 2, 0
filtered := state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders != "" || filtered.Source.TableColumnHeaders != "Total" {
t.Fatalf("horizontal: %+v", filtered.Source)
}
state.lastTableRow, state.lastTableColumn = 1, 1
filtered = state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders != "Monday" || filtered.Source.TableColumnHeaders != "" {
t.Fatalf("vertical: %+v", filtered.Source)
}
// The accepted snapshot retains both for Where Am I and Braille.
if got := presentation.FocusText(event.Source); got != "Monday row header, Total column header, 10, B3" {
t.Fatal(got)
}
if frame := brailleFocusFrame(event.Source); frame.Text != "Monday Total 10 B3" {
t.Fatalf("frame = %+v", frame)
}
reading := config.DefaultSettings().Reading
reading.OnlySpeakDisplayedText = true
options := presentation.DefaultOptions()
options.Reading = &reading
if got := presentation.FocusTextWithOptions(event.Source, options); got != "10" {
t.Fatal(got)
}
state.current = event.Source
state.clearObjectFocus(nil, nil, nil)
filtered = state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders == "" || filtered.Source.TableColumnHeaders == "" {
t.Fatal("focus return suppressed headers")
}
state.lastTable = atspi.NewObjectID(event.Source.BusName, "/other-sheet")
filtered = state.calcHeaderSpeechEvent(event)
if filtered.Source.TableRowHeaders == "" || filtered.Source.TableColumnHeaders == "" {
t.Fatal("sheet return suppressed headers")
}
}
+93 -1
View File
@@ -34,7 +34,7 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
if window == "" {
t.Fatal("Calc window missing")
}
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
@@ -246,4 +246,96 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
waitSpeech("2097150 cells selected.")
press("Right")
waitSpeech("Selection cleared.")
// Dynamic headers are real provider cell reads, isolated per sheet.
press("ctrl+Page_Up", "ctrl+Home")
waitSpeech("Alpha, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
commands <- input.SetRowHeadersColumn
waitSpeech("Column A set as row headers.")
press("Right")
waitSpeech("42, B1")
press("Down")
waitSpeech("y row header, Beta, B2")
waitFrame("y 42 Beta B2")
commands <- input.WhereAmI
waitSpeech("y row header, 42 column header, Beta, B2")
press("Right")
waitSpeech("43 column header, blank, C2")
press("Down")
waitSpeech("blank, C3")
press("Up")
waitSpeech("y row header, blank, C2")
press("ctrl+Page_Down", "ctrl+Home")
waitSpeech("Gamma, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
press("Down")
waitSpeech("blank, A2") // Same column: only Where Am I repeats its header.
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
press("ctrl+Page_Up", "ctrl+Home", "Right", "Down")
waitSpeech("y row header, Beta, B2")
// A heading edit is visible on the next cell snapshot, without a cache.
press("Up", "8", "Return")
waitSpeech("y row header, Beta, B2")
waitFrame("y 8 Beta B2")
commands <- input.WhereAmI
waitSpeech("y row header, 8 column header, Beta, B2")
commands <- input.ClearColumnHeadersRow
waitSpeech("Column headers cleared.")
commands <- input.WhereAmI
waitSpeech("y row header, Beta, B2")
commands <- input.ClearRowHeadersColumn
waitSpeech("Row headers cleared.")
commands <- input.WhereAmI
waitSpeech("Beta, B2")
press("Up", "Down")
waitSpeech("Beta, B2")
press("ctrl+Page_Down", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("Gamma column header, blank, A2")
// A second workbook in the same provider bus must not inherit choices.
profile, document := os.Getenv("CTHULHU_CALC_PROFILE"), os.Getenv("CTHULHU_CALC_SECOND_DOCUMENT")
if profile == "" || document == "" {
t.Fatal("private second-workbook fixture missing")
}
if output, err := exec.CommandContext(ctx, "libreoffice", "-env:UserInstallation="+profile, "--calc", document).CombinedOutput(); err != nil {
t.Fatalf("open second private workbook: %v: %s", err, output)
}
secondWindow := ""
deadline := time.Now().Add(5 * time.Second)
for secondWindow == "" && time.Now().Before(deadline) {
output, _ := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc").Output()
for _, candidate := range strings.Fields(string(output)) {
if candidate != window {
secondWindow = candidate
}
}
if secondWindow == "" {
time.Sleep(20 * time.Millisecond)
}
}
if secondWindow == "" {
t.Fatal("second private workbook window missing")
}
// A virtual display without a window manager does not focus new windows.
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", secondWindow).Run(); err != nil {
t.Fatal(err)
}
press("ctrl+F6", "ctrl+Page_Down", "ctrl+Home", "Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("blank, A2")
press("Up", "d", "Return")
waitSpeech("blank, A2")
press("Up")
waitSpeech("d, A1")
commands <- input.SetColumnHeadersRow
waitSpeech("Row 1 set as column headers.")
press("Down")
waitSpeech("blank, A2")
commands <- input.WhereAmI
waitSpeech("d column header, blank, A2")
}
+4 -2
View File
@@ -228,11 +228,13 @@ func (worker commandRuntime) run(ctx context.Context) error {
if onNotificationList != nil {
onNotificationList()
}
case input.WhereAmI, input.PresentCellFormula:
case input.WhereAmI, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn:
if command == input.WhereAmI {
logger.Info("where am I command received")
} else {
} else if command == input.PresentCellFormula {
logger.Info("cell formula command received")
} else {
logger.Info("Calc header command received", "command", command)
}
select {
case reviewRequests <- command:
+1 -1
View File
@@ -47,7 +47,7 @@ func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
t.Error("command worker did not stop")
}
}()
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula} {
for _, command := range []input.Command{input.WhereAmI, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn} {
commands.commands <- input.CommandEvent{Command: command}
select {
case got := <-requests:
+1
View File
@@ -107,6 +107,7 @@ var eventTypes = []string{
}
type Adapter struct {
libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop
applicationProcesses map[string]string // GLib owner; bounded provider identity cache
webNotificationRoots map[ObjectID]bool // copied identities only; no native references
foregroundReader *xorg.ForegroundReader // GLib thread only
@@ -302,6 +302,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
}
if !focusEvent.ForegroundMismatch {
captureLibreOfficeContext(target, &snapshot)
adapter.captureLibreOfficeHeaders(target, &snapshot, focusEvent.Window)
if eventType == SelectionChangedEvent && snapshot.Role == "table" &&
snapshot.LibreOffice != nil && snapshot.LibreOffice.InSpreadsheet {
snapshot.LibreOffice.Selection = captureLibreOfficeSelection(target)
@@ -383,15 +384,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
return
}
secureEpoch := adapter.secureEpoch.Load()
// Retire copied header identities even while locked. Tab lifecycle only
// checks roles; it never captures text or presents output.
nativeEventType := C.GoString(C.cthulhu_event_type(event))
eventType := canonicalTextChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
}
if adapter.secure.Load() {
return
}
adapter.applySecureReset()
adapter.refreshForeground()
nativeEventType := C.GoString(C.cthulhu_event_type(event))
eventType := canonicalTextChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
adapter.forgetDocumentWindow(originID)
} else if eventType == WindowDestroyedEvent {
@@ -56,6 +56,7 @@ func (adapter *Adapter) Start(ctx context.Context) error {
C.g_object_unref(C.gpointer(focus.source))
}
adapter.nativeFocus = nil
adapter.libreOfficeHeaders.reset()
for _, known := range adapter.documentWindows {
C.g_object_unref(C.gpointer(known.source))
}
+2
View File
@@ -21,6 +21,8 @@ type LibreOfficeContext struct {
CellText string
Coordinates string
Formula string
Document ObjectID
SheetName string // provider's page-name; table objects are recreated on tab switches
Selection *LibreOfficeSelection
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"errors"
"sync"
)
// Choices contain only identities and coordinates, never provider objects or
// cell text. They survive focus/lock/tab changes and expire on document/window
// destruction, sheet topology/name changes and shutdown. A fixed limit bounds
// missed deaths. Calc recreates its table objects on every sheet switch.
type libreOfficeHeaderKey struct {
window, document ObjectID
sheet string
}
type libreOfficeHeaderChoice struct {
row, column int
rowSet, columnSet bool
}
type libreOfficeHeaderStore struct {
mutex sync.Mutex
choices map[libreOfficeHeaderKey]libreOfficeHeaderChoice
}
func libreOfficeHeaderIdentity(snapshot Snapshot, window ObjectID) (libreOfficeHeaderKey, bool) {
context := snapshot.LibreOffice
key := libreOfficeHeaderKey{window: window}
if context != nil {
key.document = context.Document
key.sheet = context.SheetName
}
valid := IsLibreOfficeProcess(snapshot.ApplicationProcess) && !snapshot.Defunct &&
snapshot.Role == "table cell" && context != nil && context.InSpreadsheet && context.Cell &&
snapshot.TablePositionKnown && snapshot.TableRowIndex >= 0 && snapshot.TableColumnIndex >= 0 &&
key.window.Valid() && key.document.Valid() && key.sheet != "" && snapshot.TablePath != "" &&
key.window.BusName == snapshot.BusName && key.document.BusName == snapshot.BusName
return key, valid
}
// ConfigureLibreOfficeHeader is called only for the event loop's owned cell.
// It copies a choice without querying AT-SPI or waiting for the GLib thread.
// columnHeaders selects the row that supplies column headings; otherwise the
// current column supplies row headings. clear removes only that axis.
func (adapter *Adapter) ConfigureLibreOfficeHeader(snapshot Snapshot, window ObjectID, columnHeaders, clear bool) error {
key, valid := libreOfficeHeaderIdentity(snapshot, window)
if !valid || adapter.secure.Load() {
return errors.New("Not in a Calc cell.")
}
store := &adapter.libreOfficeHeaders
store.mutex.Lock()
defer store.mutex.Unlock()
choice, exists := store.choices[key]
if !exists && !clear && len(store.choices) >= 64 {
return errors.New("Header choices are full. Clear a sheet's headers first.")
}
if columnHeaders {
choice.row, choice.rowSet = snapshot.TableRowIndex, !clear
} else {
choice.column, choice.columnSet = snapshot.TableColumnIndex, !clear
}
if !choice.rowSet && !choice.columnSet {
delete(store.choices, key)
return nil
}
if store.choices == nil {
store.choices = make(map[libreOfficeHeaderKey]libreOfficeHeaderChoice)
}
store.choices[key] = choice
return nil
}
func (store *libreOfficeHeaderStore) get(key libreOfficeHeaderKey) libreOfficeHeaderChoice {
store.mutex.Lock()
defer store.mutex.Unlock()
return store.choices[key]
}
func (store *libreOfficeHeaderStore) forget(id ObjectID) {
store.mutex.Lock()
defer store.mutex.Unlock()
for key := range store.choices {
if id == key.window || id == key.document {
delete(store.choices, key)
}
}
}
func (store *libreOfficeHeaderStore) hasBus(bus string) bool {
store.mutex.Lock()
defer store.mutex.Unlock()
for key := range store.choices {
if key.window.BusName == bus {
return true
}
}
return false
}
func (store *libreOfficeHeaderStore) reset() {
store.mutex.Lock()
defer store.mutex.Unlock()
store.choices = nil
}
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
import "strings"
// Sheet names are unique within a Calc document. Conservatively retire choices
// for this window when sheet tabs are added, removed or renamed, so a
// deleted/recreated name cannot inherit an old sheet's choice. Switching tabs
// only changes selected state and does not retire choices.
func (adapter *Adapter) retireLibreOfficeHeaders(source *C.AtspiAccessible, eventType string, origin ObjectID) {
if eventType == WindowDestroyedEvent || eventType == DefunctStateEvent {
adapter.libreOfficeHeaders.forget(origin)
}
if (eventType != AccessibleNameEvent && !strings.HasPrefix(eventType, ChildrenChangedEvent)) ||
!adapter.libreOfficeHeaders.hasBus(origin.BusName) {
return
}
role := C.atspi_accessible_get_role(source, nil)
if eventType == AccessibleNameEvent && role == C.ATSPI_ROLE_PAGE_TAB ||
strings.HasPrefix(eventType, ChildrenChangedEvent) && role == C.ATSPI_ROLE_PAGE_TAB_LIST {
window := C.cthulhu_window_ancestor(source)
if window != nil {
adapter.libreOfficeHeaders.forget(nativeObjectID(window))
C.g_object_unref(C.gpointer(window))
}
}
}
// GLib owner only: at most two direct cell lookups, with no sheet enumeration
// or retained native references. Read fresh text on each accepted snapshot so
// edits to a heading are reflected when the user next visits a data cell.
func (adapter *Adapter) captureLibreOfficeHeaders(source *C.AtspiAccessible, snapshot *Snapshot, window ObjectID) {
key, valid := libreOfficeHeaderIdentity(*snapshot, window)
if !valid {
return
}
choice := adapter.libreOfficeHeaders.get(key)
if !choice.rowSet && !choice.columnSet {
return
}
cell := C.atspi_accessible_get_table_cell(source)
if cell == nil {
return
}
defer C.g_object_unref(C.gpointer(cell))
accessible := C.atspi_table_cell_get_table(cell, nil)
if accessible == nil {
return
}
defer C.g_object_unref(C.gpointer(accessible))
if nativeObjectID(accessible) != NewObjectID(snapshot.BusName, snapshot.TablePath) {
return
}
table := C.atspi_accessible_get_table_iface(accessible)
if table == nil {
return
}
defer C.g_object_unref(C.gpointer(table))
if choice.columnSet && choice.column != snapshot.TableColumnIndex {
snapshot.TableRowHeaders = libreOfficeHeaderText(table, snapshot.TableRowIndex, choice.column)
}
if choice.rowSet && choice.row != snapshot.TableRowIndex {
snapshot.TableColumnHeaders = libreOfficeHeaderText(table, choice.row, snapshot.TableColumnIndex)
}
}
func libreOfficeHeaderText(table *C.AtspiTable, row, column int) string {
cell := C.atspi_table_get_accessible_at(table, C.gint(row), C.gint(column), nil)
if cell == nil {
return ""
}
defer C.g_object_unref(C.gpointer(cell))
if C.atspi_accessible_get_role(cell, nil) != C.ATSPI_ROLE_TABLE_CELL {
return ""
}
C.atspi_accessible_clear_cache_single(cell)
states := C.atspi_accessible_get_state_set(cell)
if states == nil {
return ""
}
defunct := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0
C.g_object_unref(C.gpointer(states))
if defunct {
return ""
}
text := C.atspi_accessible_get_text_iface(cell)
if text == nil {
return ""
}
defer C.g_object_unref(C.gpointer(text))
length := max(0, min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength))
content := C.atspi_text_get_text(text, 0, C.gint(length), nil)
defer C.g_free(C.gpointer(content))
return sanitizeSnapshotField(C.GoString(content))
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"fmt"
"sync"
"testing"
)
func headerTestSnapshot() (Snapshot, ObjectID) {
return Snapshot{BusName: ":1.42", Path: "/cell", TablePath: "/sheet", Role: "table cell",
ApplicationProcess: "soffice.bin", TablePositionKnown: true, TableRowIndex: 2, TableColumnIndex: 3,
LibreOffice: &LibreOfficeContext{Cell: true, InSpreadsheet: true, Document: NewObjectID(":1.42", "/document"), SheetName: "Example"}}, NewObjectID(":1.42", "/window")
}
func TestLibreOfficeHeadersScopeLifetimeAndIndependentAxes(t *testing.T) {
adapter := New()
snapshot, window := headerTestSnapshot()
key, _ := libreOfficeHeaderIdentity(snapshot, window)
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err != nil {
t.Fatal(err)
}
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, false, false); err != nil {
t.Fatal(err)
}
choice := adapter.libreOfficeHeaders.get(key)
if !choice.rowSet || choice.row != 2 || !choice.columnSet || choice.column != 3 {
t.Fatalf("choice = %+v", choice)
}
snapshot.TablePath = "/recreated-sheet-table"
recreated, _ := libreOfficeHeaderIdentity(snapshot, window)
if adapter.libreOfficeHeaders.get(recreated) != choice {
t.Fatal("sheet switch lost the document choice")
}
for _, other := range []libreOfficeHeaderKey{
{NewObjectID(":1.42", "/other-window"), key.document, key.sheet},
{key.window, NewObjectID(":1.42", "/other-document"), key.sheet},
{key.window, key.document, "Other"},
{NewObjectID(":1.43", "/window"), NewObjectID(":1.43", "/document"), "Example"},
} {
if got := adapter.libreOfficeHeaders.get(other); got.rowSet || got.columnSet {
t.Fatalf("headers crossed identity: %+v", other)
}
}
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, true); err != nil {
t.Fatal(err)
}
if got := adapter.libreOfficeHeaders.get(key); got.rowSet || !got.columnSet {
t.Fatalf("clearing one axis changed the other: %+v", got)
}
// Locking retires output/native focus, but document choices survive.
adapter.SetSecureInputActive(true)
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err == nil {
t.Fatal("configuration accepted while locked")
}
adapter.SetSecureInputActive(false)
if !adapter.libreOfficeHeaders.get(key).columnSet {
t.Fatal("lock discarded the document choice")
}
adapter.libreOfficeHeaders.forget(NewObjectID(":1.42", "/other-window"))
if !adapter.libreOfficeHeaders.get(key).columnSet {
t.Fatal("unrelated destruction discarded choice")
}
for _, id := range []ObjectID{key.document, key.window} {
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, false, false); err != nil {
t.Fatal(err)
}
adapter.libreOfficeHeaders.forget(id)
if got := adapter.libreOfficeHeaders.get(key); got.rowSet || got.columnSet {
t.Fatalf("destruction retained choice: %+v", id)
}
}
_ = adapter.ConfigureLibreOfficeHeader(snapshot, window, false, false)
adapter.libreOfficeHeaders.reset()
if len(adapter.libreOfficeHeaders.choices) != 0 {
t.Fatal("shutdown retained choices")
}
_ = adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false)
adapter.libreOfficeHeaders.forget(NewObjectID(":1.42", "/other-window"))
if !adapter.libreOfficeHeaders.get(key).rowSet {
t.Fatal("unrelated tab changes retired choice")
}
adapter.libreOfficeHeaders.forget(window)
if adapter.libreOfficeHeaders.get(key).rowSet {
t.Fatal("sheet topology change retained choice")
}
}
func TestLibreOfficeHeadersRejectInvalidContextAndBoundChoices(t *testing.T) {
for _, invalid := range []string{"process", "editor", "defunct", "position", "negative", "document", "sheet", "bus", "window"} {
snapshot, window := headerTestSnapshot()
switch invalid {
case "process":
snapshot.ApplicationProcess = "chromium"
case "editor":
snapshot.Role = "paragraph"
case "defunct":
snapshot.Defunct = true
case "position":
snapshot.TablePositionKnown = false
case "negative":
snapshot.TableColumnIndex = -1
case "document":
snapshot.LibreOffice.Document = ObjectID{}
case "sheet":
snapshot.LibreOffice.SheetName = ""
case "bus":
window.BusName = ":1.43"
case "window":
window = ObjectID{}
}
adapter := New()
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err == nil {
t.Fatalf("accepted invalid %s context", invalid)
}
}
adapter := New()
snapshot, window := headerTestSnapshot()
for index := 0; index < 65; index++ {
snapshot.LibreOffice.SheetName = fmt.Sprintf("Sheet%d", index)
err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false)
if (err != nil) != (index == 64) {
t.Fatalf("bounded choice %d: %v", index, err)
}
}
snapshot.LibreOffice.SheetName = "Sheet0"
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, true); err != nil {
t.Fatal(err)
}
snapshot.LibreOffice.SheetName = "Sheet64"
if err := adapter.ConfigureLibreOfficeHeader(snapshot, window, true, false); err != nil {
t.Fatal("clear did not free capacity:", err)
}
}
func TestLibreOfficeHeadersConcurrentCaptureAndCommands(t *testing.T) {
adapter := New()
snapshot, window := headerTestSnapshot()
key, _ := libreOfficeHeaderIdentity(snapshot, window)
var workers sync.WaitGroup
for index := 0; index < 3; index++ {
workers.Add(1)
go func(index int) {
defer workers.Done()
for count := 0; count < 100; count++ {
switch index {
case 0:
_ = adapter.ConfigureLibreOfficeHeader(snapshot, window, true, count%2 == 0)
case 1:
_ = adapter.libreOfficeHeaders.get(key)
case 2:
adapter.libreOfficeHeaders.forget(window)
}
}
}(index)
}
workers.Wait()
}
+18
View File
@@ -110,6 +110,24 @@ func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) {
}
if role == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET {
context.InSpreadsheet = true
context.Document = nativeObjectID(current)
if snapshot.Role == "table cell" {
// The document/view survives sheet switches; its table does not.
// Read the provider's uncached page-name, never a localized title.
C.atspi_accessible_clear_cache_single(current)
attributes := C.atspi_accessible_get_attributes(current, nil)
if attributes != nil {
key := C.CString("page-name")
// Identity must preserve whitespace: distinct sheet names
// must not collapse onto the same key. Reject oversized names.
context.SheetName = C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(key))))
if len(context.SheetName) > maxSnapshotFieldLength {
context.SheetName = ""
}
C.free(unsafe.Pointer(key))
C.g_hash_table_unref(attributes)
}
}
C.g_object_unref(C.gpointer(current))
current = nil
break
+9 -1
View File
@@ -55,6 +55,14 @@ func CommandLabel(command Command) string {
switch command {
case PresentCellFormula:
return "Present the formula for the current cell"
case SetColumnHeadersRow:
return "Use the current row as Calc column headers"
case ClearColumnHeadersRow:
return "Clear Calc column headers"
case SetRowHeadersColumn:
return "Use the current column as Calc row headers"
case ClearRowHeadersColumn:
return "Clear Calc row headers"
case OCRObject:
return "Recognize object of interest"
case OCRWindow:
@@ -166,7 +174,7 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo
groups[command] = append(groups[command], FormatCommandChord(chord))
}
if scope == "global" {
for _, command := range []Command{PresentCellFormula, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
for _, command := range []Command{PresentCellFormula, SetColumnHeadersRow, ClearColumnHeadersRow, SetRowHeadersColumn, ClearRowHeadersColumn, PresentDate, StickyFocusMode, StickyBrowseMode, PresentPreviousNotification, PresentNextNotification, OCRObject, OCRWindow, OCRDesktop, OCRClipboard} {
if _, exists := groups[command]; !exists {
groups[command] = nil
}
+4
View File
@@ -108,6 +108,10 @@ const (
ApplicationPreferences Command = "application-preferences"
ChooseApplicationPreferences Command = "choose-application-preferences"
PresentCellFormula Command = "present-cell-formula"
SetColumnHeadersRow Command = "set-column-headers-row"
ClearColumnHeadersRow Command = "clear-column-headers-row"
SetRowHeadersColumn Command = "set-row-headers-column"
ClearRowHeadersColumn Command = "clear-row-headers-column"
WhereAmI Command = "where-am-i"
PresentCharacterAttributes Command = "present-character-attributes"
PresentLastNotification Command = "present-last-notification"
+27
View File
@@ -134,6 +134,33 @@ func TestCellFormulaBindingAndLaptopOverride(t *testing.T) {
}
}
func TestCalcHeaderCommandsCanBeBoundInBothLayouts(t *testing.T) {
commands := []Command{SetColumnHeadersRow, ClearColumnHeadersRow, SetRowHeadersColumn, ClearRowHeadersColumn}
keys := []string{"Cthulhu+Ctrl+Shift+r", "Cthulhu+Ctrl+Alt+r", "Cthulhu+Ctrl+Shift+c", "Cthulhu+Ctrl+Alt+c"}
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
var overrides []BindingOverride
for index, command := range commands {
overrides = append(overrides, BindingOverride{Layout: layout, Scope: "global", Command: command, Keys: []string{keys[index]}})
}
if err := ValidateBindingOverrides(overrides); err != nil {
t.Fatal(err)
}
bindings, err := ResolveCommandBindings(layout, false, overrides)
if err != nil {
t.Fatal(err)
}
for index, command := range commands {
chord, _ := ParseCommandChord(keys[index])
if bindings[chord] != command {
t.Fatalf("layout %v: %s did not bind", layout, command)
}
}
if got := bindings[CommandChord{Symbol: 'c', Modifiers: 1, CthulhuModifier: true}]; got != PresentClipboard {
t.Fatalf("header commands displaced clipboard: %s", got)
}
}
}
func TestCharacterAttributesShortcutInBothLayouts(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
bindings := DefaultCommandBindings(layout)