More LibreOffice work.
This commit is contained in:
1 parent
4ac59d4ced
commit
b5a5b86dda
15 files changed
+763
-23
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
|
`internal/presentation/libreoffice.go` owns the corresponding speech
|
||||||
formatting; LibreOffice Braille formatting stays beside its controller.
|
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. Selection comparison keeps
|
||||||
logical focus belongs to the existing event loop. Future selections and
|
one bounded snapshot for the focused sheet, keyed by its application bus and
|
||||||
dynamic headers must be keyed by application bus, document, and sheet identity.
|
accessible sheet path and admitted only within the owned document window.
|
||||||
Retire them on document/sheet destruction or application exit. Focus loss
|
It is retired on sheet/application focus changes and ordinary owned-focus
|
||||||
must cancel pending output while preserving document header choices; shutdown
|
reset, including destruction, foreground loss, and screen lock. There are no
|
||||||
and screen lock must retire pending operations. Add command routing when the
|
selection workers, timers, or retained native references. Future dynamic
|
||||||
first real Office command requires it, using the existing input machinery.
|
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
|
Application code must not bypass security/foreground gates or manage its own
|
||||||
speech connection, key grabs, or background event loop.
|
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
|
routes or formula suffix. The explicit formula command uses the existing
|
||||||
system-message flash path and its Braille preferences, as in Orca.
|
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
|
## Next slices
|
||||||
|
|
||||||
1. Selected/unselected ranges, rows, columns, and Select All. Represent ranges
|
1. Dynamic row/column headers, sheet-scoped state, preferences and bindings.
|
||||||
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.
|
|
||||||
Inspect current Orca's shared table navigator rather than copying only
|
Inspect current Orca's shared table navigator rather than copying only
|
||||||
the soffice directory. Spreadsheet full-row reading also needs a bounded,
|
the soffice directory. Spreadsheet full-row reading also needs a bounded,
|
||||||
explicitly defined extent; the generic setting is not full Calc support.
|
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.
|
and Braille panning. Compare each actual workflow with current Orca.
|
||||||
|
|
||||||
Merged cells, overflow announcements, large-sheet responsiveness, other
|
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,
|
and switching between two sheets. Formula assertions cover a numeric result,
|
||||||
an empty string result, literal spaces, replacement with a number, formula
|
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.
|
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 native listener tests check the desktop formula shortcut before key release;
|
||||||
the Calc harness sends semantic requests through the production event loop.
|
the Calc harness sends semantic requests through the production event loop.
|
||||||
No active reader is replaced and no normal Office profile is changed.
|
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.
|
the formula bar, and another application; check logical focus recovery.
|
||||||
5. Check the cell value/address, spacing, editor cursor and return to cell
|
5. Check the cell value/address, spacing, editor cursor and return to cell
|
||||||
display on physical Braille. Text routing applies inside the editor.
|
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.
|
reading, and Steam navigation. Check lock/unlock and stale/background events.
|
||||||
|
|
||||||
Human review and testing remain required by `CONTRIBUTING.md`. Passing this
|
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
|
the command above. Audible formula speech, physical input/Braille, and real
|
||||||
workbook acceptance remain pending.
|
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
|
## Calc startup focus diagnosis
|
||||||
|
|
||||||
A normal-profile startup under i3 can leave Calc accepting arrow keys while
|
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
|
// prepareApplicationEvent runs after security/foreground checks and before
|
||||||
// generic focus and text filters. Application policy receives copied semantics;
|
// generic focus and text filters. Application policy receives copied semantics;
|
||||||
// the reader retains ownership, review, cancellation, and output services.
|
// the reader retains ownership, review, cancellation, and output services.
|
||||||
// The first handler is stateless: document-specific state can be owned here
|
// Application-specific state stays on the event loop with an explicit focus
|
||||||
// when a concrete feature needs it, rather than hidden in native callbacks.
|
// lifetime, rather than hidden in native callbacks.
|
||||||
func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) {
|
func (handler *accessibilityEventHandler) prepareApplicationEvent(event atspi.Event) (atspi.Event, bool) {
|
||||||
result := applicationEventResult{event: event}
|
result := applicationEventResult{event: event}
|
||||||
if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) {
|
if atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) {
|
||||||
result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner)
|
result = prepareLibreOfficeEvent(event, handler.state.current, handler.state.owner)
|
||||||
}
|
}
|
||||||
|
if !result.suppress || event.Type == atspi.SelectionChangedEvent {
|
||||||
|
handler.handleLibreOfficeSelection(result.event)
|
||||||
|
}
|
||||||
if result.adoptFocus {
|
if result.adoptFocus {
|
||||||
if handler.focusGate.Suspended() {
|
if handler.focusGate.Suspended() {
|
||||||
return event, false
|
return event, false
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type eventForegroundState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type eventFocusState struct {
|
type eventFocusState struct {
|
||||||
|
libreOfficeSelection libreOfficeSelectionState
|
||||||
current atspi.Snapshot
|
current atspi.Snapshot
|
||||||
brailleSnapshot atspi.Snapshot
|
brailleSnapshot atspi.Snapshot
|
||||||
focusedID atspi.ObjectID
|
focusedID atspi.ObjectID
|
||||||
@@ -137,6 +138,7 @@ func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer,
|
|||||||
// Review metadata can change without new input or a foreground switch. Reset
|
// Review metadata can change without new input or a foreground switch. Reset
|
||||||
// its navigation state independently so title announcements keep playing.
|
// its navigation state independently so title announcements keep playing.
|
||||||
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
|
func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) {
|
||||||
|
state.libreOfficeSelection = libreOfficeSelectionState{}
|
||||||
state.selection = textSelection{}
|
state.selection = textSelection{}
|
||||||
state.documentSelection = textSelection{}
|
state.documentSelection = textSelection{}
|
||||||
state.lastClipboardSequence = 0
|
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 {
|
if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent {
|
||||||
// Selection can name the first cell of a range rather than the
|
// Selection can name the first cell of a range rather than the
|
||||||
// active cell. Movement follows active-descendant events; range
|
// active cell. Dedicated selection presentation consumes copied
|
||||||
// presentation belongs to the later selection-support slice.
|
// sheet metadata without changing logical focus or review.
|
||||||
result.suppress = true
|
result.suppress = true
|
||||||
}
|
}
|
||||||
if context != nil && context.InSpreadsheet && event.Source.Role == "table" &&
|
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") {
|
if strings.Contains(got, "1048576") || strings.Contains(got, "grayed") {
|
||||||
t.Fatalf("Calc capacity/state chatter: %q", got)
|
t.Fatalf("Calc capacity/state chatter: %q", got)
|
||||||
}
|
}
|
||||||
if got == want {
|
if got == want || strings.HasSuffix(got, ". "+want) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-timeout.C:
|
case <-timeout.C:
|
||||||
@@ -198,4 +198,52 @@ func TestNativeLibreOfficeCalc(t *testing.T) {
|
|||||||
waitSpeech("blank, C1")
|
waitSpeech("blank, C1")
|
||||||
commands <- input.PresentCellFormula
|
commands <- input.PresentCellFormula
|
||||||
waitSpeech("empty")
|
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 {
|
if !focusEvent.ForegroundMismatch {
|
||||||
captureLibreOfficeContext(target, &snapshot)
|
captureLibreOfficeContext(target, &snapshot)
|
||||||
|
if eventType == SelectionChangedEvent && snapshot.Role == "table" &&
|
||||||
|
snapshot.LibreOffice != nil && snapshot.LibreOffice.InSpreadsheet {
|
||||||
|
snapshot.LibreOffice.Selection = captureLibreOfficeSelection(target)
|
||||||
|
}
|
||||||
focusEvent.Source = snapshot
|
focusEvent.Source = snapshot
|
||||||
if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent {
|
if eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == CaretEvent || eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent {
|
||||||
snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target)
|
snapshot.SpeechContext, snapshot.BrailleContext = native_speech_context(target)
|
||||||
@@ -477,7 +481,8 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
|||||||
focusEvent.Window = originID
|
focusEvent.Window = originID
|
||||||
}
|
}
|
||||||
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
|
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())
|
adapter.rememberNativeFocus(target, snapshot.ID())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,28 @@ type LibreOfficeContext struct {
|
|||||||
CellText string
|
CellText string
|
||||||
Coordinates string
|
Coordinates string
|
||||||
Formula 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
|
// IsLibreOfficeProcess uses /proc identity verified by the adapter. An
|
||||||
// accessible name or document title must never enable application policy.
|
// accessible name or document title must never enable application policy.
|
||||||
func IsLibreOfficeProcess(process string) bool {
|
func IsLibreOfficeProcess(process string) bool {
|
||||||
|
|||||||
@@ -11,15 +11,31 @@ import "C"
|
|||||||
|
|
||||||
import "unsafe"
|
import "unsafe"
|
||||||
|
|
||||||
// libreOfficeFocusTarget resolves only a Calc editor's direct text children.
|
// libreOfficeFocusTarget resolves a Calc editor's direct text children and
|
||||||
// F2 focuses its temporary panel; the paragraph can appear later, on typing.
|
// 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.
|
// The callback owns and releases the returned reference. No sheet is walked.
|
||||||
func (adapter *Adapter) libreOfficeFocusTarget(event *C.AtspiEvent, eventType string) *C.AtspiAccessible {
|
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)) {
|
!IsLibreOfficeProcess(adapter.applicationProcess(event.source)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
role := C.atspi_accessible_get_role(event.source, 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 {
|
if role == C.ATSPI_ROLE_TABLE {
|
||||||
parent := C.atspi_accessible_get_parent(event.source, nil)
|
parent := C.atspi_accessible_get_parent(event.source, nil)
|
||||||
if parent != 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-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
|
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
|
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.
|
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)
|
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():
|
def main():
|
||||||
|
if sys.argv[1:] == ["--select-disjoint"]:
|
||||||
|
select_disjoint()
|
||||||
|
return
|
||||||
if sys.argv[1:] == ["--inside"]:
|
if sys.argv[1:] == ["--inside"]:
|
||||||
run_inside()
|
run_inside()
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in new issue
Block a user