More LibreOffice work.
This commit is contained in:
1 parent
4ac59d4ced
commit
b5a5b86dda
15 files changed
+768
-28
No files matched your search
+73
-13
@@ -22,13 +22,15 @@ presentation code never retain native objects or perform provider queries.
|
||||
`internal/presentation/libreoffice.go` owns the corresponding speech
|
||||
formatting; LibreOffice Braille formatting stays beside its controller.
|
||||
|
||||
The first implementation has no independent mutable application state:
|
||||
logical focus belongs to the existing event loop. Future selections and
|
||||
dynamic headers must be keyed by application bus, document, and sheet identity.
|
||||
Retire them on document/sheet destruction or application exit. Focus loss
|
||||
must cancel pending output while preserving document header choices; shutdown
|
||||
and screen lock must retire pending operations. Add command routing when the
|
||||
first real Office command requires it, using the existing input machinery.
|
||||
Logical focus belongs to the existing event loop. Selection comparison keeps
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -87,16 +89,52 @@ References inspected: Orca commit `cd58a88ed` (2026-10-14),
|
||||
routes or formula suffix. The explicit formula command uses the existing
|
||||
system-message flash path and its Braille preferences, as in Orca.
|
||||
|
||||
## Selection slice
|
||||
|
||||
- Shift+Arrow selection growth and shrinkage announce selected/unselected
|
||||
coordinates. Adjacent cells form compact rectangles; disjoint cells retain
|
||||
their gaps. The active anchor is left to ordinary cell presentation.
|
||||
- Full rows and columns use row/column labels. Select All says “All cells
|
||||
selected”; leaving a selection says “Selection cleared.” Ctrl+A can first
|
||||
select the used area; press it again to select the entire sheet.
|
||||
These announcements require a provider event. An independent AT-SPI observer
|
||||
on LibreOffice 26.2.6.3 confirmed that Shift+Up after extending whole rows,
|
||||
and Shift+Left after extending whole columns, change the selection without
|
||||
emitting a selection event. Ctrl+Home can also clear those selections without
|
||||
an event. Immediate announcements for those cases remain unsupported; the
|
||||
next selection event refreshes the snapshot. The reader does not poll or
|
||||
infer selection changes from keystrokes.
|
||||
- Small selections capture at most 64 cells, rows, or columns. Messages with
|
||||
more than eight rectangles use a count. Larger selections use a count and
|
||||
two boundary samples for duplicate suppression, without treating endpoints
|
||||
as proof of a filled rectangle. Overlapping count-only selections report
|
||||
the new total rather than falsely announcing the entire old range unselected.
|
||||
- The GTK bridge caps cell counts at 2147483647; partial-sheet selections at
|
||||
that limit say “At least … cells selected.” Whole-sheet selection is checked
|
||||
separately. Large selections with identical counts and boundary samples
|
||||
cannot be distinguished by this bounded snapshot; interior-only changes may
|
||||
therefore be suppressed. Full topology/delta reporting remains future work.
|
||||
- Native capture uses a fixed number of count queries and at most 64 selected
|
||||
positions. It requests no selected-row/column arrays. Ordinary small cell
|
||||
selections skip full-row/column counts; LibreOffice itself scans its row
|
||||
marks when larger selections need a selected-row count. Fixed RPC counts
|
||||
do not guarantee a provider's response time.
|
||||
- Selection events keep both logical and retained native focus on the active
|
||||
cell. Formula commands, object review, cell Braille frames, and routing
|
||||
continue to use that cell. Selection messages use the existing system-message
|
||||
Braille flash path and preferences. Only-displayed-text mode suppresses them.
|
||||
- Current Orca's selection callbacks and utilities guide the behavior. The
|
||||
[LibreOffice spreadsheet provider](https://github.com/LibreOffice/core/blob/libreoffice-26-2/sc/source/ui/Accessibility/AccessibleSpreadsheet.cxx)
|
||||
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.
|
||||
|
||||
## Next slices
|
||||
|
||||
1. Selected/unselected ranges, rows, columns, and Select All. Represent ranges
|
||||
compactly; do not expand every selected cell as Orca's current comparison
|
||||
does. Test very large and disjoint selections.
|
||||
2. Dynamic row/column headers, sheet-scoped state, preferences and bindings.
|
||||
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,
|
||||
explicitly defined extent; the generic setting is not full Calc support.
|
||||
3. Writer paragraph navigation, selection, Say All, spell-check presentation,
|
||||
2. Writer paragraph navigation, selection, Say All, spell-check presentation,
|
||||
and Braille panning. Compare each actual workflow with current Orca.
|
||||
|
||||
Merged cells, overflow announcements, large-sheet responsiveness, other
|
||||
@@ -123,6 +161,10 @@ cancellation, Enter commit, caret movement inside an existing numeric cell,
|
||||
and switching between two sheets. Formula assertions cover a numeric result,
|
||||
an empty string result, literal spaces, replacement with a number, formula
|
||||
and basic Where Am I requests, and non-formula cells on a second sheet.
|
||||
Selection checks cover cell-range growth/shrinkage, clearing, row/column extension,
|
||||
Select All, disjoint cells made through the provider selection
|
||||
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.
|
||||
No active reader is replaced and no normal Office profile is changed.
|
||||
@@ -144,7 +186,15 @@ physical Braille. Before submission, review the diff and test a rebuilt binary:
|
||||
the formula bar, and another application; check logical focus recovery.
|
||||
5. Check the cell value/address, spacing, editor cursor and return to cell
|
||||
display on physical Braille. Text routing applies inside the editor.
|
||||
6. Repeat ordinary GTK table movement/multiselection, browser typing and table
|
||||
6. Use Shift+Arrows to grow and shrink a range; check selected/unselected
|
||||
addresses and continued active-cell/formula output. Try Ctrl+Space,
|
||||
Shift+Space, their Shift+Arrow extensions, and Ctrl+A twice. Clear each
|
||||
selection with an arrow. Select disjoint areas with Ctrl+click and a large
|
||||
area with Ctrl+Shift+Arrows; check gaps in small selections and count summaries
|
||||
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
|
||||
reading, and Steam navigation. Check lock/unlock and stale/background events.
|
||||
|
||||
Human review and testing remain required by `CONTRIBUTING.md`. Passing this
|
||||
@@ -172,6 +222,16 @@ for missing xkbcommon symbols; the formula shortcut was tested directly using
|
||||
the command above. Audible formula speech, physical input/Braille, and real
|
||||
workbook acceptance remain pending.
|
||||
|
||||
Selection-slice verification passed the isolated real Calc harness (including
|
||||
disjoint and capped selections), focused race tests, vet/build, parity-ledger
|
||||
validation, and ordinary GTK table/multiselection checks. The full affected-package
|
||||
race run again hit the startup timing test above; all remaining affected-package
|
||||
race tests passed with
|
||||
`go test -race ./internal/atspi ./internal/app ./internal/presentation -skip '^TestRunReachesSoundStopAfterEarlierServiceStopError$' -count=1`.
|
||||
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.
|
||||
|
||||
## Calc startup focus diagnosis
|
||||
|
||||
A normal-profile startup under i3 can leave Calc accepting arrow keys while
|
||||
|
||||
@@ -16,13 +16,16 @@ type applicationEventResult struct {
|
||||
// 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.
|
||||
// Application-specific state stays on the event loop with an explicit focus
|
||||
// lifetime, 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.suppress || event.Type == atspi.SelectionChangedEvent {
|
||||
handler.handleLibreOfficeSelection(result.event)
|
||||
}
|
||||
if result.adoptFocus {
|
||||
if handler.focusGate.Suspended() {
|
||||
return event, false
|
||||
|
||||
@@ -45,11 +45,12 @@ type eventForegroundState struct {
|
||||
}
|
||||
|
||||
type eventFocusState struct {
|
||||
current atspi.Snapshot
|
||||
brailleSnapshot atspi.Snapshot
|
||||
focusedID atspi.ObjectID
|
||||
managedContainerID atspi.ObjectID
|
||||
managedDescendantID atspi.ObjectID
|
||||
libreOfficeSelection libreOfficeSelectionState
|
||||
current atspi.Snapshot
|
||||
brailleSnapshot atspi.Snapshot
|
||||
focusedID atspi.ObjectID
|
||||
managedContainerID atspi.ObjectID
|
||||
managedDescendantID atspi.ObjectID
|
||||
}
|
||||
|
||||
type eventDocumentState struct {
|
||||
@@ -137,6 +138,7 @@ func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer,
|
||||
// Review metadata can change without new input or a foreground switch. Reset
|
||||
// its navigation state independently so title announcements keep playing.
|
||||
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
|
||||
state.libreOfficeSelection = libreOfficeSelectionState{}
|
||||
state.selection = textSelection{}
|
||||
state.documentSelection = textSelection{}
|
||||
state.lastClipboardSequence = 0
|
||||
|
||||
@@ -46,8 +46,8 @@ func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner wi
|
||||
}
|
||||
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.
|
||||
// active cell. Dedicated selection presentation consumes copied
|
||||
// sheet metadata without changing logical focus or review.
|
||||
result.suppress = true
|
||||
}
|
||||
if context != nil && context.InSpreadsheet && event.Source.Role == "table" &&
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
|
||||
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
|
||||
t.Fatalf("Calc capacity/state chatter: %q", got)
|
||||
}
|
||||
if got == want {
|
||||
if got == want || strings.HasSuffix(got, ". "+want) {
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
@@ -198,4 +198,52 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
|
||||
waitSpeech("blank, C1")
|
||||
commands <- input.PresentCellFormula
|
||||
waitSpeech("empty")
|
||||
press("ctrl+Home")
|
||||
waitSpeech("Gamma, A1")
|
||||
press("shift+Right")
|
||||
waitSpeech("Cell B1 selected.")
|
||||
press("shift+Down")
|
||||
waitSpeech("Cells A2 to B2 selected.")
|
||||
press("shift+Up")
|
||||
waitSpeech("Cells A2 to B2 unselected.")
|
||||
// Formula commands must still use the active cell, not the range's
|
||||
// first selected child or the sheet that emitted the selection event.
|
||||
commands <- input.PresentCellFormula
|
||||
waitSpeech("Gamma")
|
||||
press("Right")
|
||||
waitSpeech("Selection cleared.")
|
||||
press("ctrl+Home", "shift+space")
|
||||
waitSpeech("Row 1 selected.")
|
||||
press("shift+Down")
|
||||
waitSpeech("Row 2 selected.")
|
||||
press("ctrl+Home", "ctrl+space")
|
||||
waitSpeech("Column A selected.")
|
||||
press("shift+Right")
|
||||
waitSpeech("Column B selected.")
|
||||
press("ctrl+Home", "ctrl+a", "ctrl+a")
|
||||
waitSpeech("All cells selected.")
|
||||
// Calc omits events while shrinking full-column ranges. Growing the
|
||||
// shrunken selection again supplies an event for a capped partial sheet.
|
||||
press("shift+Left", "shift+Left", "shift+Right")
|
||||
waitSpeech("At least 2147483647 cells selected.")
|
||||
press("Right")
|
||||
waitSpeech("Selection cleared.")
|
||||
press("Down")
|
||||
waitSpeech("blank, B2")
|
||||
// Use the provider's selection API to make actual disjoint selections;
|
||||
// these are distinct from the synthetic event/presentation unit checks.
|
||||
if output, err := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/native_libreoffice.py", "--select-disjoint").CombinedOutput(); err != nil {
|
||||
t.Fatalf("disjoint Calc selection: %v: %s", err, output)
|
||||
}
|
||||
waitSpeech("Cell C1 selected.")
|
||||
commands <- input.PresentCellFormula
|
||||
waitSpeech("empty")
|
||||
press("Right")
|
||||
waitSpeech("Selection cleared.")
|
||||
// A large non-column selection has an exact count, without enumerating
|
||||
// its cells or inferring a rectangle from boundary samples.
|
||||
press("ctrl+Home", "shift+Right", "ctrl+shift+Down", "shift+Up")
|
||||
waitSpeech("2097150 cells selected.")
|
||||
press("Right")
|
||||
waitSpeech("Selection cleared.")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
// Owned by the event loop, with one bounded snapshot for the focused sheet.
|
||||
// Retired on sheet/application focus changes and the ordinary focus reset
|
||||
// (including lock, destruction, foreground loss, and shutdown). No timers or
|
||||
// provider references survive a selection event.
|
||||
type libreOfficeSelectionState struct {
|
||||
sheet atspi.ObjectID
|
||||
previous *atspi.LibreOfficeSelection
|
||||
}
|
||||
|
||||
func calcSheet(snapshot atspi.Snapshot) atspi.ObjectID {
|
||||
if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.LibreOffice == nil || !snapshot.LibreOffice.InSpreadsheet {
|
||||
return atspi.ObjectID{}
|
||||
}
|
||||
if snapshot.Role == "table" {
|
||||
return snapshot.ID()
|
||||
}
|
||||
if snapshot.LibreOffice.Cell {
|
||||
return atspi.NewObjectID(snapshot.BusName, snapshot.TablePath)
|
||||
}
|
||||
return atspi.ObjectID{}
|
||||
}
|
||||
|
||||
func (handler *accessibilityEventHandler) handleLibreOfficeSelection(event atspi.Event) {
|
||||
state := handler.state
|
||||
if isFocusGainEvent(event) && state.libreOfficeSelection.sheet != calcSheet(event.Source) {
|
||||
state.libreOfficeSelection = libreOfficeSelectionState{}
|
||||
}
|
||||
context := event.Source.LibreOffice
|
||||
if event.Type != atspi.SelectionChangedEvent || context == nil || context.Selection == nil ||
|
||||
event.Source.Role != "table" || event.Source.Defunct || !context.InSpreadsheet ||
|
||||
!atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) ||
|
||||
handler.focusGate.Suspended() || !state.owner.window.Valid() ||
|
||||
state.owner.window != eventWindow(event) || state.owner.applicationBus != event.Source.BusName ||
|
||||
calcSheet(state.current) != event.Source.ID() {
|
||||
return
|
||||
}
|
||||
history := &state.libreOfficeSelection
|
||||
if history.sheet != event.Source.ID() {
|
||||
*history = libreOfficeSelectionState{sheet: event.Source.ID()}
|
||||
}
|
||||
selection := context.Selection
|
||||
handler.logger.Debug("received Calc selection snapshot", "kind", selection.Kind,
|
||||
"count", selection.Count, "count_limited", selection.CountLimited,
|
||||
"complete", selection.Complete, "cells", selection.Cells,
|
||||
"first", selection.First, "last", selection.Last)
|
||||
previous := history.previous
|
||||
history.previous = selection
|
||||
if previous != nil && previous.Kind == selection.Kind && previous.Count == selection.Count &&
|
||||
previous.CountLimited == selection.CountLimited && previous.Complete == selection.Complete &&
|
||||
previous.First == selection.First && previous.Last == selection.Last && slices.Equal(previous.Cells, selection.Cells) {
|
||||
return
|
||||
}
|
||||
reading := state.presentationOptions.Reading
|
||||
if handler.live != nil {
|
||||
settings := handler.live.Load()
|
||||
reading = &settings.Reading
|
||||
}
|
||||
if reading != nil && reading.OnlySpeakDisplayedText {
|
||||
return
|
||||
}
|
||||
var messages []string
|
||||
if previous != nil && (previous.Count != 0 || previous.Kind == "all") {
|
||||
if selection.Count == 0 && selection.Kind != "all" {
|
||||
messages = append(messages, "Selection cleared.")
|
||||
} else if previous.Kind == selection.Kind && previous.Kind != "all" && previous.Complete && selection.Complete {
|
||||
removed := *previous
|
||||
removed.Cells = calcSelectionDifference(previous.Cells, selection.Cells)
|
||||
removed.Count = len(removed.Cells)
|
||||
if text := presentation.LibreOfficeSelectionText(removed, "unselected"); text != "" {
|
||||
messages = append(messages, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
added := *selection
|
||||
if selection.Complete && (selection.Kind == "cells" || previous != nil && previous.Kind == selection.Kind) {
|
||||
if previous != nil && previous.Complete && previous.Kind == selection.Kind {
|
||||
added.Cells = calcSelectionDifference(selection.Cells, previous.Cells)
|
||||
}
|
||||
if selection.Kind == "cells" && state.current.TablePositionKnown {
|
||||
// Orca leaves the anchor to ordinary active-cell presentation.
|
||||
anchor := atspi.LibreOfficeCell{Row: state.current.TableRowIndex, Column: state.current.TableColumnIndex}
|
||||
added.Cells = calcSelectionDifference(added.Cells, []atspi.LibreOfficeCell{anchor})
|
||||
}
|
||||
added.Count = len(added.Cells)
|
||||
}
|
||||
if text := presentation.LibreOfficeSelectionText(added, "selected"); text != "" {
|
||||
messages = append(messages, text)
|
||||
}
|
||||
if text := strings.Join(messages, " "); text != "" {
|
||||
state.pending.clear()
|
||||
handler.speech.Interrupt()
|
||||
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-selection"})
|
||||
}
|
||||
}
|
||||
|
||||
func calcSelectionDifference(first, second []atspi.LibreOfficeCell) []atspi.LibreOfficeCell {
|
||||
var result []atspi.LibreOfficeCell
|
||||
for _, cell := range first {
|
||||
if !slices.Contains(second, cell) {
|
||||
result = append(result, cell)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
func TestLibreOfficeSelectionChangesKeepActiveCellAndBraille(t *testing.T) {
|
||||
current := calcTestEvent(atspi.FocusEvent, "/cell", "43", "C1")
|
||||
current.Source.TableColumnIndex = 2
|
||||
current.Source.LibreOffice.Formula = "B1+1"
|
||||
state := eventLoopState{}
|
||||
state.current, state.focusedID = current.Source, current.Source.ID()
|
||||
state.owner = windowOwner{window: current.Window, applicationBus: current.Source.BusName}
|
||||
state.presentationOptions = presentation.DefaultOptions()
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
|
||||
display := &flashRecorder{}
|
||||
live := &liveSettings{}
|
||||
live.Store(config.DefaultSettings())
|
||||
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler),
|
||||
adapter: &fakeAdapter{}, speech: outputQueue{speech: speech, braille: display, live: live}, syncForeground: func() {}, webScripts: &webScriptController{}}
|
||||
event := current
|
||||
event.Type = atspi.SelectionChangedEvent
|
||||
event.Source.Path, event.Source.Role = "/sheet", "table"
|
||||
selection := func(cells ...atspi.LibreOfficeCell) {
|
||||
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true,
|
||||
Selection: &atspi.LibreOfficeSelection{Kind: "cells", Count: len(cells), Complete: true, Cells: cells}}
|
||||
handler.handle(event)
|
||||
}
|
||||
expect := func(want string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
if state.current.ID() != current.Source.ID() || state.focusedID != current.Source.ID() || presentation.LibreOfficeCellFormula(state.current) != "B1+1" {
|
||||
t.Fatal("selection displaced active cell/formula")
|
||||
}
|
||||
if display.messages[len(display.messages)-1] != want {
|
||||
t.Fatal("selection did not use system Braille flash")
|
||||
}
|
||||
}
|
||||
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2})
|
||||
expect("Cell A1 selected.")
|
||||
handler.handle(event)
|
||||
if len(speech.spoken) != 0 {
|
||||
t.Fatal("duplicate selection repeated")
|
||||
}
|
||||
selection(atspi.LibreOfficeCell{Row: 0, Column: 0}, atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
|
||||
expect("Cell E1 selected.")
|
||||
selection(atspi.LibreOfficeCell{Row: 0, Column: 2}, atspi.LibreOfficeCell{Row: 0, Column: 4})
|
||||
expect("Cell A1 unselected.")
|
||||
selection()
|
||||
expect("Selection cleared.")
|
||||
// A count-only change reports the new total, never a fabricated
|
||||
// unselection of the whole old range when selections overlap.
|
||||
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000}
|
||||
handler.handle(event)
|
||||
expect("1000000 cells selected.")
|
||||
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001}
|
||||
handler.handle(event)
|
||||
expect("1000001 cells selected.")
|
||||
// The same total with different boundary samples is a changed selection.
|
||||
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: "cells", Count: 1000001, First: atspi.LibreOfficeCell{Row: 2, Column: 2}}
|
||||
handler.handle(event)
|
||||
expect("1000001 cells selected.")
|
||||
// Whole-row/column shrinkage has the same delta policy when the
|
||||
// provider supplies an event (the real Calc harness documents gaps).
|
||||
for _, kind := range []string{"rows", "columns"} {
|
||||
state.libreOfficeSelection = libreOfficeSelectionState{}
|
||||
positions := []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}}
|
||||
label := "Row 2 unselected."
|
||||
selectedLabel := "Rows 1 to 2 selected."
|
||||
if kind == "columns" {
|
||||
positions[1] = atspi.LibreOfficeCell{Row: 0, Column: 1}
|
||||
label = "Column B unselected."
|
||||
selectedLabel = "Columns A to B selected."
|
||||
}
|
||||
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 2, Complete: true, Cells: positions}
|
||||
handler.handle(event)
|
||||
expect(selectedLabel)
|
||||
event.Source.LibreOffice.Selection = &atspi.LibreOfficeSelection{Kind: kind, Count: 1, Complete: true, Cells: positions[:1]}
|
||||
handler.handle(event)
|
||||
expect(label)
|
||||
}
|
||||
state.clearObjectFocus(nil, nil, nil)
|
||||
if state.libreOfficeSelection.previous != nil {
|
||||
t.Fatal("selection survived owned-focus reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeSelectionRejectsUnownedAndSuppressedOutput(t *testing.T) {
|
||||
for _, name := range []string{"other sheet", "other bus", "other window", "wrong process", "defunct", "locked", "suspended", "stale foreground", "displayed text"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
current := calcTestEvent(atspi.FocusEvent, "/cell", "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.presentationOptions = presentation.DefaultOptions()
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
gate := newFocusGate(nil)
|
||||
handler := accessibilityEventHandler{state: &state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{}, speech: speech, focusGate: gate,
|
||||
foreground: &ocrTestForeground{state: state.foregroundState}, syncForeground: func() {}, webScripts: &webScriptController{}}
|
||||
event := current
|
||||
event.Type = atspi.SelectionChangedEvent
|
||||
event.Foreground.Epoch = 2
|
||||
event.Source.Path, event.Source.Role = "/sheet", "table"
|
||||
event.Source.LibreOffice = &atspi.LibreOfficeContext{InSpreadsheet: true, Selection: &atspi.LibreOfficeSelection{Kind: "all", Complete: true}}
|
||||
switch name {
|
||||
case "other sheet":
|
||||
event.Source.Path = "/other_sheet"
|
||||
case "other bus":
|
||||
event.Source.BusName = ":1.99"
|
||||
case "other window":
|
||||
event.Window.Path = "/other_window"
|
||||
case "wrong process":
|
||||
event.Source.ApplicationProcess = "example"
|
||||
case "defunct":
|
||||
event.Source.Defunct = true
|
||||
case "locked":
|
||||
gate.secure.Store(true)
|
||||
case "suspended":
|
||||
gate.suspended.Store(true)
|
||||
case "stale foreground":
|
||||
event.Foreground.Epoch = 1
|
||||
case "displayed text":
|
||||
reading := config.DefaultSettings().Reading
|
||||
reading.OnlySpeakDisplayedText = true
|
||||
state.presentationOptions.Reading = &reading
|
||||
}
|
||||
// Test the application hook after the ordinary foreground/security
|
||||
// gates; wrong-process events belong to the generic application path.
|
||||
if name == "wrong process" {
|
||||
handler.handleLibreOfficeSelection(event)
|
||||
} else {
|
||||
handler.handle(event)
|
||||
}
|
||||
if len(speech.spoken) != 0 || state.current.ID() != current.Source.ID() {
|
||||
t.Fatal("unowned/suppressed selection changed output or focus")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,10 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
|
||||
}
|
||||
if !focusEvent.ForegroundMismatch {
|
||||
captureLibreOfficeContext(target, &snapshot)
|
||||
if eventType == SelectionChangedEvent && snapshot.Role == "table" &&
|
||||
snapshot.LibreOffice != nil && snapshot.LibreOffice.InSpreadsheet {
|
||||
snapshot.LibreOffice.Selection = captureLibreOfficeSelection(target)
|
||||
}
|
||||
focusEvent.Source = snapshot
|
||||
if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent {
|
||||
snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target)
|
||||
@@ -477,7 +481,8 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
focusEvent.Window = originID
|
||||
}
|
||||
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
|
||||
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) {
|
||||
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) &&
|
||||
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
|
||||
adapter.rememberNativeFocus(target, snapshot.ID())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,28 @@ type LibreOfficeContext struct {
|
||||
CellText string
|
||||
Coordinates string
|
||||
Formula string
|
||||
Selection *LibreOfficeSelection
|
||||
}
|
||||
|
||||
// LibreOfficeSelection never expands a sheet. Complete small selections carry
|
||||
// at most 64 positions; larger selections carry counts and boundary samples.
|
||||
// Kind is cells, rows, columns, or all. Count is a lower bound when
|
||||
// CountLimited is true.
|
||||
type LibreOfficeSelection struct {
|
||||
Kind string
|
||||
Count int
|
||||
CountLimited bool
|
||||
Complete bool
|
||||
Cells []LibreOfficeCell
|
||||
// Boundary samples distinguish equal-sized large selections. They do
|
||||
// not prove that every cell between the endpoints is selected.
|
||||
First, Last LibreOfficeCell
|
||||
}
|
||||
|
||||
type LibreOfficeCell struct{ Row, Column int }
|
||||
|
||||
const libreOfficeSelectionLimit = 64
|
||||
|
||||
// IsLibreOfficeProcess uses /proc identity verified by the adapter. An
|
||||
// accessible name or document title must never enable application policy.
|
||||
func IsLibreOfficeProcess(process string) bool {
|
||||
|
||||
@@ -11,15 +11,31 @@ import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// libreOfficeFocusTarget resolves only a Calc editor's direct text children.
|
||||
// F2 focuses its temporary panel; the paragraph can appear later, on typing.
|
||||
// libreOfficeFocusTarget resolves a Calc editor's direct text children and
|
||||
// preserves the sheet source for selection events. F2 focuses its temporary
|
||||
// panel; the paragraph can appear later, on typing.
|
||||
// The callback owns and releases the returned reference. No sheet is walked.
|
||||
func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible {
|
||||
if eventType != FocusEvent && eventType != FocusedStateEvent ||
|
||||
if eventType != FocusEvent && eventType != FocusedStateEvent && eventType != SelectionChangedEvent ||
|
||||
!IsLibreOfficeProcess(adapter.applicationProcess(event.source)) {
|
||||
return nil
|
||||
}
|
||||
role := C.atspi_accessible_get_role(event.source, nil)
|
||||
if eventType == SelectionChangedEvent {
|
||||
// Keep the sheet as the selection source. Its first selected cell
|
||||
// is not necessarily the active cell (and can be far offscreen).
|
||||
if role == C.ATSPI_ROLE_TABLE {
|
||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||
if parent != nil {
|
||||
spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET
|
||||
C.g_object_unref(C.gpointer(parent))
|
||||
if spreadsheet {
|
||||
return (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(event.source)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if role == C.ATSPI_ROLE_TABLE {
|
||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||
if parent != nil {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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"
|
||||
|
||||
// Only the native owner calls this, after foreground validation. Counts use a
|
||||
// fixed number of RPCs; no selected-row/column arrays or sheet walks are used.
|
||||
// LibreOffice itself scans its row marks when answering selected-row counts.
|
||||
func captureLibreOfficeSelection(source *C.AtspiAccessible) *LibreOfficeSelection {
|
||||
selection := C.atspi_accessible_get_selection_iface(source)
|
||||
table := C.atspi_accessible_get_table_iface(source)
|
||||
if selection == nil || table == nil {
|
||||
if selection != nil {
|
||||
C.g_object_unref(C.gpointer(selection))
|
||||
}
|
||||
if table != nil {
|
||||
C.g_object_unref(C.gpointer(table))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer C.g_object_unref(C.gpointer(selection))
|
||||
defer C.g_object_unref(C.gpointer(table))
|
||||
var queryError *C.GError
|
||||
count := int(C.atspi_selection_get_n_selected_children(selection, &queryError))
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
return nil
|
||||
}
|
||||
if count < 0 {
|
||||
return nil
|
||||
}
|
||||
result := &LibreOfficeSelection{Kind: "cells", Count: count, CountLimited: count == 1<<31-1}
|
||||
if count == 0 {
|
||||
result.Complete = true
|
||||
return result
|
||||
}
|
||||
rows := int(C.atspi_table_get_n_rows(table, &queryError))
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
return nil
|
||||
}
|
||||
columns := int(C.atspi_table_get_n_columns(table, &queryError))
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
return nil
|
||||
}
|
||||
selectedRows, selectedColumns := 0, 0
|
||||
// A small selection cannot contain a whole row or column. Avoid making
|
||||
// LibreOffice scan all its row marks for ordinary Shift+Arrow movement.
|
||||
if count >= columns {
|
||||
selectedRows = int(C.atspi_table_get_n_selected_rows(table, &queryError))
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if count >= rows {
|
||||
selectedColumns = int(C.atspi_table_get_n_selected_columns(table, &queryError))
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if rows <= 0 || columns <= 0 || selectedRows < 0 || selectedColumns < 0 {
|
||||
return nil
|
||||
}
|
||||
if selectedRows == rows || selectedColumns == columns {
|
||||
return &LibreOfficeSelection{Kind: "all", Complete: true}
|
||||
}
|
||||
stride := 1
|
||||
// Only classify pure row/column selections. Mixed selections retain an
|
||||
// honest cell count. GTK caps counts above INT_MAX, rather than wrapping.
|
||||
if !result.CountLimited && selectedRows > 0 && int64(selectedRows)*int64(columns) == int64(count) {
|
||||
result.Kind, result.Count, result.CountLimited = "rows", selectedRows, false
|
||||
stride = columns
|
||||
} else if !result.CountLimited && selectedColumns > 0 && int64(selectedColumns)*int64(rows) == int64(count) {
|
||||
result.Kind, result.Count, result.CountLimited = "columns", selectedColumns, false
|
||||
}
|
||||
indices := make([]int, 0, libreOfficeSelectionLimit)
|
||||
if result.Count > libreOfficeSelectionLimit {
|
||||
indices = append(indices, 0, count-1)
|
||||
} else {
|
||||
for index := 0; index < result.Count; index++ {
|
||||
indices = append(indices, index*stride)
|
||||
}
|
||||
}
|
||||
for _, index := range indices {
|
||||
child := C.atspi_selection_get_selected_child(selection, C.gint(index), &queryError)
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
if child != nil {
|
||||
C.g_object_unref(C.gpointer(child))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if child == nil {
|
||||
return nil
|
||||
}
|
||||
cell := C.atspi_accessible_get_table_cell(child)
|
||||
var row, column C.gint
|
||||
valid := false
|
||||
if cell != nil {
|
||||
valid = C.atspi_table_cell_get_position(cell, &row, &column, &queryError) != 0
|
||||
C.g_object_unref(C.gpointer(cell))
|
||||
}
|
||||
C.g_object_unref(C.gpointer(child))
|
||||
if queryError != nil {
|
||||
C.g_error_free(queryError)
|
||||
return nil
|
||||
}
|
||||
if !valid || row < 0 || column < 0 || int(row) >= rows || int(column) >= columns {
|
||||
return nil
|
||||
}
|
||||
position := LibreOfficeCell{Row: int(row), Column: int(column)}
|
||||
if result.Kind == "rows" {
|
||||
position.Column = 0
|
||||
}
|
||||
if result.Kind == "columns" {
|
||||
position.Row = 0
|
||||
}
|
||||
result.Cells = append(result.Cells, position)
|
||||
}
|
||||
result.First, result.Last = result.Cells[0], result.Cells[len(result.Cells)-1]
|
||||
result.Complete = result.Count <= libreOfficeSelectionLimit
|
||||
if !result.Complete {
|
||||
result.Cells = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
func calcColumnLabel(column int) string {
|
||||
label := ""
|
||||
for column++; column > 0; column = (column - 1) / 26 {
|
||||
label = string(rune('A'+(column-1)%26)) + label
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func calcCellLabel(cell atspi.LibreOfficeCell) string {
|
||||
return fmt.Sprintf("%s%d", calcColumnLabel(cell.Column), cell.Row+1)
|
||||
}
|
||||
|
||||
// LibreOfficeSelectionText formats copied metadata only. A large selection's
|
||||
// endpoints never imply a filled rectangle. Small disjoint selections retain
|
||||
// their gaps; at most eight rectangles are spoken before using a count.
|
||||
func LibreOfficeSelectionText(selection atspi.LibreOfficeSelection, action string) string {
|
||||
if selection.Kind == "all" {
|
||||
return "All cells " + action + "."
|
||||
}
|
||||
if selection.Count == 0 {
|
||||
return ""
|
||||
}
|
||||
if !selection.Complete || len(selection.Cells) > 64 {
|
||||
return libreOfficeSelectionCount(selection, action)
|
||||
}
|
||||
cells := slices.Clone(selection.Cells)
|
||||
slices.SortFunc(cells, func(a, b atspi.LibreOfficeCell) int {
|
||||
if a.Row != b.Row {
|
||||
return a.Row - b.Row
|
||||
}
|
||||
return a.Column - b.Column
|
||||
})
|
||||
cells = slices.Compact(cells)
|
||||
type rectangle struct{ first, last atspi.LibreOfficeCell }
|
||||
var ranges []rectangle
|
||||
for index := 0; index < len(cells); {
|
||||
first, last := cells[index], cells[index]
|
||||
index++
|
||||
for index < len(cells) && (selection.Kind == "rows" && cells[index].Row == last.Row+1 ||
|
||||
selection.Kind != "rows" && cells[index].Row == last.Row && cells[index].Column == last.Column+1) {
|
||||
last = cells[index]
|
||||
index++
|
||||
}
|
||||
merged := false
|
||||
if selection.Kind == "cells" {
|
||||
for i := range ranges {
|
||||
previous := &ranges[i]
|
||||
if previous.first.Column == first.Column && previous.last.Column == last.Column && previous.last.Row+1 == first.Row {
|
||||
previous.last.Row = last.Row
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !merged {
|
||||
ranges = append(ranges, rectangle{first, last})
|
||||
}
|
||||
}
|
||||
if len(ranges) > 8 {
|
||||
return libreOfficeSelectionCount(selection, action)
|
||||
}
|
||||
var messages []string
|
||||
for _, span := range ranges {
|
||||
first, last := calcCellLabel(span.first), calcCellLabel(span.last)
|
||||
singular, plural := "Cell", "Cells"
|
||||
if selection.Kind == "rows" {
|
||||
first, last = fmt.Sprint(span.first.Row+1), fmt.Sprint(span.last.Row+1)
|
||||
singular, plural = "Row", "Rows"
|
||||
} else if selection.Kind == "columns" {
|
||||
first, last = calcColumnLabel(span.first.Column), calcColumnLabel(span.last.Column)
|
||||
singular, plural = "Column", "Columns"
|
||||
}
|
||||
if first == last {
|
||||
messages = append(messages, fmt.Sprintf("%s %s %s.", singular, first, action))
|
||||
} else {
|
||||
messages = append(messages, fmt.Sprintf("%s %s to %s %s.", plural, first, last, action))
|
||||
}
|
||||
}
|
||||
return strings.Join(messages, " ")
|
||||
}
|
||||
|
||||
func libreOfficeSelectionCount(selection atspi.LibreOfficeSelection, action string) string {
|
||||
prefix := ""
|
||||
if selection.CountLimited {
|
||||
prefix = "At least "
|
||||
}
|
||||
return fmt.Sprintf("%s%d %s %s.", prefix, selection.Count, selection.Kind, action)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
func TestLibreOfficeSelectionPreservesGapsAndBoundsOutput(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
selection atspi.LibreOfficeSelection
|
||||
want string
|
||||
}{
|
||||
{"rectangle", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
|
||||
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 1, Column: 0}, {Row: 1, Column: 1}}}, "Cells A1 to B2 selected."},
|
||||
{"disjoint", atspi.LibreOfficeSelection{Kind: "cells", Count: 4, Complete: true,
|
||||
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 0, Column: 1}, {Row: 0, Column: 3}, {Row: 1, Column: 3}}}, "Cells A1 to B1 selected. Cells D1 to D2 selected."},
|
||||
{"row gaps", atspi.LibreOfficeSelection{Kind: "rows", Count: 3, Complete: true,
|
||||
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 0}, {Row: 1, Column: 0}, {Row: 3, Column: 0}}}, "Rows 1 to 2 selected. Row 4 selected."},
|
||||
{"column labels", atspi.LibreOfficeSelection{Kind: "columns", Count: 2, Complete: true,
|
||||
Cells: []atspi.LibreOfficeCell{{Row: 0, Column: 25}, {Row: 0, Column: 26}}}, "Columns Z to AA selected."},
|
||||
{"large", atspi.LibreOfficeSelection{Kind: "cells", Count: 1000000000,
|
||||
First: atspi.LibreOfficeCell{Row: 0, Column: 0}, Last: atspi.LibreOfficeCell{Row: 999999, Column: 999}}, "1000000000 cells selected."},
|
||||
{"capped", atspi.LibreOfficeSelection{Kind: "cells", Count: 2147483647, CountLimited: true}, "At least 2147483647 cells selected."},
|
||||
{"all", atspi.LibreOfficeSelection{Kind: "all", Complete: true}, "All cells selected."},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := LibreOfficeSelectionText(test.selection, "selected"); got != test.want {
|
||||
t.Fatalf("got %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
selection := atspi.LibreOfficeSelection{Kind: "cells", Count: 9, Complete: true}
|
||||
for index := 0; index < 9; index++ {
|
||||
selection.Cells = append(selection.Cells, atspi.LibreOfficeCell{Row: index, Column: index})
|
||||
}
|
||||
if got := LibreOfficeSelectionText(selection, "unselected"); got != "9 cells unselected." {
|
||||
t.Fatalf("fragmented selection = %q", got)
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -54,5 +54,5 @@ 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. Range announcements dynamic headers full-row reading and Writer workflows 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 headers 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.
|
||||
|
@@ -100,7 +100,37 @@ def run_inside():
|
||||
application.wait(timeout=5)
|
||||
|
||||
|
||||
def select_disjoint():
|
||||
"""Mutate only the disposable fixture, without walking sheet cells."""
|
||||
if os.environ.get("CTHULHU_NATIVE_LIBREOFFICE") != "1":
|
||||
raise RuntimeError("Selection helper requires the private Calc harness")
|
||||
import gi
|
||||
gi.require_version("Atspi", "2.0")
|
||||
from gi.repository import Atspi
|
||||
|
||||
pending = [Atspi.get_desktop(0)]
|
||||
remaining = 2500
|
||||
while pending and remaining:
|
||||
node = pending.pop()
|
||||
remaining -= 1
|
||||
if node.get_role_name() == "table":
|
||||
if node.get_name() == "Sheet Second":
|
||||
node.clear_selection()
|
||||
node.select_child(0)
|
||||
node.select_child(2)
|
||||
return
|
||||
continue
|
||||
for index in range(min(node.get_child_count(), 200)):
|
||||
child = node.get_child_at_index(index)
|
||||
if child is not None:
|
||||
pending.append(child)
|
||||
raise RuntimeError("Disposable selection fixture missing")
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["--select-disjoint"]:
|
||||
select_disjoint()
|
||||
return
|
||||
if sys.argv[1:] == ["--inside"]:
|
||||
run_inside()
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user