libreoffice support started, still broken. Some updates to how wine-access gets called.

This commit is contained in:
Storm Dragon committed 2026-10-14 16:24:49 -04:00
1 parent 8e76d2341c
commit 7f78177786
26 files changed
+1173 -21

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import "git.stormux.org/storm/cthulhu/internal/atspi"
type applicationEventResult struct {
event atspi.Event
adoptFocus bool
suppress bool
resetPresentation bool
}
// prepareApplicationEvent runs after security/foreground checks and before
// generic focus and text filters. Application policy receives copied semantics;
// the reader retains ownership, review, cancellation, and output services.
// The first handler is stateless: document-specific state can be owned here
// when a concrete feature needs it, rather than hidden in native callbacks.
func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) {
result := applicationEventResult{event: event}
if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) {
result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner)
}
if result.adoptFocus {
if handler.focusGate.Suspended() {
return event, false
}
state := handler.state
if !state.owner.follow(result.event) {
return event, true
}
state.pending.caret.clear()
state.focusedID = result.event.Source.ID()
state.current = result.event.Source
state.managedContainerID, state.managedDescendantID = atspi.ObjectID{}, atspi.ObjectID{}
if result.event.Source.Role != "table" && result.event.Source.Role != "table cell" {
// A late-created editor has no ordinary focus event to reset
// row history. Returning to its old cell is a fresh focus.
state.lastTable, state.lastTableCell = atspi.ObjectID{}, atspi.ObjectID{}
}
state.selection.reset(result.event.Source)
if handler.objectReview != nil {
handler.objectReview.SyncFocus(result.event.Source.ID())
}
}
if result.resetPresentation {
handler.state.pending.caret.clear()
handler.state.lastSpokenPath, handler.state.lastSpokenText, handler.state.lastSound = "", "", ""
}
if result.suppress {
handler.logger.Debug("skipped application-specific accessibility event", "type", event.Type,
"application_process", event.Source.ApplicationProcess, "role", event.Source.Role)
}
return result.event, result.suppress
}
+3
View File
@@ -160,6 +160,9 @@ func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOption
}
func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
return frame
}
name, role := strings.TrimSpace(s.Name), s.Role
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
label := name
+5
View File
@@ -135,6 +135,11 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
}
return
}
var applicationSuppressed bool
event, applicationSuppressed = handler.prepareApplicationEvent(event)
if applicationSuppressed {
return
}
if handler.handleLifecycle(event) {
return
}
+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 (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
)
// prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy.
// A Calc active cell is the logical focus even when editing destroyed the old
// focus or a sheet switch changed its managed container (LO bug 161444).
func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner windowOwner) applicationEventResult {
result := applicationEventResult{event: event}
context := event.Source.LibreOffice
if !atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) ||
event.Source.Defunct || !event.Source.ID().Valid() ||
owner.window.Valid() && (eventWindow(event) != owner.window || event.Source.BusName != owner.applicationBus) {
return result
}
if isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent &&
current.LibreOffice != nil && current.LibreOffice.InSpreadsheet {
// LibreOffice also claims focus for ancestors of the real focused
// object. Orca ignores these; copied ancestry is enough to identify
// them without another native tree query.
for _, ancestor := range current.BrailleContext {
if ancestor.ID().Valid() && ancestor.ID() == event.Source.ID() {
result.suppress = true
return result
}
}
}
if context != nil && context.InSpreadsheet && context.Cell && event.Source.Role == "table cell" &&
event.Type == atspi.ActiveDescendantEvent &&
(event.Source.Focused || context.ContainerFocused) {
// Preserve NativeType/OriginPath for diagnostics. Ordinary focus
// handling now owns review, duplicate suppression, and Braille.
if result.event.NativeType == "" {
result.event.NativeType = event.Type
}
result.event.Type = atspi.FocusEvent
}
if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent {
// Selection can name the first cell of a range rather than the
// active cell. Movement follows active-descendant events; range
// presentation belongs to the later selection-support slice.
result.suppress = true
}
if context != nil && context.InSpreadsheet && event.Source.Role == "table" &&
isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent &&
current.LibreOffice != nil &&
(current.LibreOffice.CellEditor || current.LibreOffice.Cell && current.TablePath == event.Source.Path) {
// Orca treats this as the sheet reclaiming focus after editing.
// Own it silently; the following active cell supplies presentation.
result.adoptFocus, result.suppress = true, true
}
if context != nil && context.Cell && isFocusGainEvent(result.event) && current.ID() != event.Source.ID() {
// Returning to the cell after typing must speak it even if its
// value is unchanged; typing uses a separate presentation history.
result.resetPresentation = true
}
if context != nil && context.CellEditor && event.Source.Role == "paragraph" &&
(isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent) &&
owner.window.Valid() && event.Source.ID() != current.ID() &&
(event.Source.Focused || context.EditorParent == current.ID() && current.LibreOffice != nil && current.LibreOffice.CellEditor) {
// Empty-cell F2 can focus a panel before its paragraph exists.
// Admit that editor's first mutation before generic text filtering.
result.adoptFocus = true
}
if event.Type == atspi.CaretEvent && current.LibreOffice != nil && current.LibreOffice.Cell &&
(context == nil || !context.CellEditor) {
// Formula-bar/background caret events must not displace the cell.
result.suppress = true
}
return result
}
func libreOfficeBrailleFrame(snapshot atspi.Snapshot, options config.BrailleOptions) (braille.Frame, bool) {
context := snapshot.LibreOffice
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Role != "table cell" ||
context == nil || !context.InSpreadsheet || !context.Cell {
return braille.Frame{}, false
}
// Orca presents the value followed by its cell address. This is a
// component, not an editable-text route; cell focus has no text caret.
return braille.Frame{Text: brailleJoin(context.CellText, context.Coordinates), Cursor: 0,
WordWrap: options.WordWrap}, true
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bytes"
"context"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
type calcFrameQueue struct{ frames chan braille.Frame }
func (queue calcFrameQueue) EnqueueFrame(frame braille.Frame) { queue.frames <- frame }
// This runs the production native adapter and event loop against real Calc.
// Speech/Braille are recorded queues, not audible or physical display proof.
func TestNativeLibreOfficeCalc(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_LIBREOFFICE") != "1" {
t.Skip("requires tests/native_libreoffice.py")
}
window := os.Getenv("CTHULHU_CALC_WINDOW")
if window == "" {
t.Fatal("Calc window missing")
}
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Errorf("stop adapter: %v", err)
}
}()
speech := &fakeSpeechQueue{spoken: make(chan string, 256)}
frames := make(chan braille.Frame, 256)
done := make(chan error, 1)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
options := presentation.DefaultOptions()
options.EnableEchoByCharacter = true
go func() {
done <- processEvents(ctx, logger, outputQueue{speech: speech, braille: calcFrameQueue{frames}}, adapter, 0, options)
}()
defer func() {
cancel()
<-done
if t.Failed() {
t.Log(logBuffer.String())
}
}()
press := func(keys ...string) {
t.Helper()
args := append([]string{"key", "--clearmodifiers"}, keys...)
if err := exec.CommandContext(ctx, "xdotool", args...).Run(); err != nil {
t.Fatal(err)
}
}
waitSpeech := func(want string) {
t.Helper()
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case got := <-speech.spoken:
t.Logf("Calc speech: %q", got)
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
t.Fatalf("Calc capacity/state chatter: %q", got)
}
if got == want {
return
}
case <-timeout.C:
t.Fatalf("missing Calc speech %q", want)
}
}
}
waitFrame := func(suffix string) {
t.Helper()
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case frame := <-frames:
if strings.HasSuffix(frame.Text, suffix) {
return
}
case <-timeout.C:
t.Fatalf("missing Calc Braille ending %q", suffix)
}
}
}
enterEditor := func(coordinate string) {
t.Helper()
drain:
for {
select {
case <-frames:
default:
break drain
}
}
press("F2")
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case frame := <-frames:
if strings.Contains(frame.Text, "Cell "+coordinate) {
return
}
case <-timeout.C:
t.Fatal("Calc did not expose its temporary cell editor")
}
}
}
// Focus once. Refocusing before every key sequence can hide failures
// to retain or recover Calc's document focus during normal keyboard use.
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
t.Fatal(err)
}
press("Escape", "ctrl+Home", "Right")
waitSpeech("42, B1")
waitFrame("42 B1")
press("Down")
waitSpeech("Beta, B2")
press("Left")
waitSpeech("blank, A2")
waitFrame("A2")
enterEditor("A2")
press("x")
waitSpeech("x")
press("Escape")
waitSpeech("blank, A2")
enterEditor("A2")
press("y")
waitSpeech("y")
press("Return")
waitSpeech("blank, A3")
press("Up")
waitSpeech("y, A2")
press("ctrl+Page_Down", "ctrl+Home")
waitSpeech("Gamma, A1")
press("ctrl+Page_Up", "ctrl+Home")
waitSpeech("Alpha, A1")
press("Right")
waitSpeech("42, B1")
enterEditor("B1")
press("Left")
waitSpeech("2")
press("Escape")
waitSpeech("42, B1")
press("F6")
press("ctrl+F6", "Down")
waitSpeech("Beta, B2")
waitFrame("Beta B2")
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func calcTestEvent(eventType, path, value, coordinate string) atspi.Event {
source := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "table cell", Name: coordinate,
Text: value, ApplicationProcess: "soffice.bin", Focused: true,
TablePath: "/sheet", TablePositionKnown: true,
LibreOffice: &atspi.LibreOfficeContext{InSpreadsheet: true, Cell: true, CellText: value, Coordinates: coordinate}}
event := atspi.NewEvent(eventType, source)
event.NativeType = eventType
event.Window = atspi.NewObjectID(source.BusName, "/window")
event.OriginPath = "/sheet"
return event
}
func TestLibreOfficeMovementEditingAndFocusReturn(t *testing.T) {
state := eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
state.presentationOptions.EnableEchoByCharacter = true
speech := &fakeSpeechQueue{spoken: make(chan string, 32)}
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
speech: speech, adapter: &fakeAdapter{}, clearOwnedFocus: func() {}, syncForeground: func() {}}
presenter := eventPresenter{state: &state, logger: handler.logger, speech: speech}
handler.speak = presenter.speak
handler.titles = &windowTitleController{state: &state, speech: speech, logger: handler.logger}
handler.webScripts = &webScriptController{speech: speech}
expectSpeech := func(want string) {
t.Helper()
select {
case got := <-speech.spoken:
if got != want {
t.Fatalf("Calc speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing Calc speech %q", want)
}
}
first := calcTestEvent(atspi.ActiveDescendantEvent, "/cell1", "42", "B1")
first.Source.TableColumnIndex = 1
handler.handle(first)
expectSpeech("42, B1")
ancestor := first
ancestor.Type = atspi.FocusEvent
ancestor.Source.Path, ancestor.Source.Role, ancestor.Source.Name = "/root_pane", "root pane", "Example"
ancestor.Source.LibreOffice = nil
state.current.BrailleContext = []atspi.Snapshot{ancestor.Source}
handler.handle(ancestor)
if state.current.ID() != first.Source.ID() || len(speech.spoken) != 0 {
t.Fatal("ancestor focus displaced the active cell")
}
handler.handle(first)
if len(speech.spoken) != 0 {
t.Fatal("repeated cell descendant was spoken")
}
blank := calcTestEvent(atspi.ActiveDescendantEvent, "/cell2", "", "A2")
blank.Source.TableRowIndex = 1
handler.handle(blank)
expectSpeech("blank, A2")
backgroundCaret := blank
backgroundCaret.Type = atspi.CaretEvent
backgroundCaret.Source.Path = "/input_line"
backgroundCaret.Source.Role = "paragraph"
backgroundCaret.Source.Text = "unrelated"
backgroundCaret.Source.LibreOffice = nil
handler.handle(backgroundCaret)
if state.current.ID() != blank.Source.ID() || len(speech.spoken) != 0 {
t.Fatal("formula-bar caret displaced cell focus")
}
editor := blank
editor.Type = atspi.TextInsertedEvent
editor.Source.Path, editor.Source.Role, editor.Source.Name = "/editor", "paragraph", ""
editor.Source.Editable = true
editor.Source.Text, editor.Payload = "x", "x"
editor.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, CellEditor: true}
handler.handle(editor)
if state.current.ID() != editor.Source.ID() || state.focusedID != editor.Source.ID() {
t.Fatal("first editor mutation was filtered before focus adoption")
}
expectSpeech("x")
table := editor
table.Type = atspi.FocusEvent
table.Source.Path, table.Source.Role, table.Source.Name = "/sheet", "table", "Sheet Example"
table.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true}
handler.handle(table)
if state.current.ID() != table.Source.ID() || len(speech.spoken) != 0 {
t.Fatal("post-edit table focus was not owned silently")
}
handler.handle(blank)
expectSpeech("blank, A2")
otherSheet := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "Gamma", "A1")
otherSheet.OriginPath = "/other_sheet"
otherSheet.Source.TablePath = "/other_sheet"
handler.handle(otherSheet)
expectSpeech("Gamma, A1")
selection := blank
selection.Type = atspi.SelectionChangedEvent
handler.handle(selection)
if state.current.ID() != otherSheet.Source.ID() || len(speech.spoken) != 0 {
t.Fatal("range selection displaced the active cell")
}
}
func TestLibreOfficePolicyCannotAdoptAnotherWindowOrProcess(t *testing.T) {
current := calcTestEvent(atspi.FocusEvent, "/cell", "42", "B1")
owner := windowOwner{window: current.Window, applicationBus: current.Source.BusName}
for _, change := range []func(*atspi.Event){
func(event *atspi.Event) { event.Window.Path = "/other_window" },
func(event *atspi.Event) { event.Source.ApplicationProcess = "chromium" },
func(event *atspi.Event) { event.Source.Focused = false },
func(event *atspi.Event) { event.Source.Defunct = true },
} {
event := calcTestEvent(atspi.ActiveDescendantEvent, "/other_cell", "wrong", "C1")
change(&event)
result := prepareLibreOfficeEvent(event, current.Source, owner)
if result.event.Type != atspi.ActiveDescendantEvent || result.adoptFocus {
t.Fatalf("unowned cell became focus: %+v", result)
}
}
}
func TestLibreOfficeBraillePreservesSpacingAndHasNoEditorRoute(t *testing.T) {
event := calcTestEvent(atspi.FocusEvent, "/cell", "é 猫", "B3")
frame := brailleFocusFrameWithOptions(event.Source, config.DefaultSettings().Braille)
if frame.Text != "é 猫 B3" || frame.Cursor != 0 || len(frame.Routes) != 0 {
t.Fatalf("Calc Braille = %+v", frame)
}
}
func TestLibreOfficeRoutingHonorsLockAndForegroundBeforePolicy(t *testing.T) {
for _, locked := range []bool{true, false} {
t.Run(map[bool]string{true: "locked", false: "stale foreground"}[locked], func(t *testing.T) {
current := calcTestEvent(atspi.FocusEvent, "/current", "42", "B1")
state := eventLoopState{}
state.current, state.focusedID = current.Source, current.Source.ID()
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
state.foregroundState = atspi.ForegroundState{Epoch: 2}
state.lastSpokenText = "unchanged"
gate := newFocusGate(nil)
gate.secure.Store(locked)
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
handler := accessibilityEventHandler{state: &state, focusGate: gate, speech: speech,
logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
foreground: &ocrTestForeground{state: state.foregroundState},
syncForeground: func() {}, webScripts: &webScriptController{}}
event := calcTestEvent(atspi.ActiveDescendantEvent, "/new_cell", "wrong", "C1")
event.Foreground.Epoch = 1
handler.handle(event)
if state.current.ID() != current.Source.ID() || state.lastSpokenText != "unchanged" || len(speech.spoken) != 0 {
t.Fatal("LibreOffice policy ran before security/foreground rejection")
}
})
}
}