From b5a5b86ddaf1401cce5442da8403e7024cf696a2 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 15 Oct 2026 01:10:43 -0400 Subject: [PATCH] More LibreOffice work. --- LIBREOFFICE_SUPPORT.md | 86 ++++++++-- internal/app/application_behavior.go | 7 +- internal/app/event_state.go | 12 +- internal/app/libreoffice.go | 4 +- internal/app/libreoffice_native_test.go | 50 +++++- internal/app/libreoffice_selection.go | 118 +++++++++++++ internal/app/libreoffice_selection_test.go | 156 ++++++++++++++++++ .../atspi/adapter_event_callback_native.go | 7 +- internal/atspi/libreoffice.go | 20 +++ internal/atspi/libreoffice_native.go | 22 ++- .../atspi/libreoffice_selection_native.go | 135 +++++++++++++++ .../presentation/libreoffice_selection.go | 102 ++++++++++++ .../libreoffice_selection_test.go | 45 +++++ testdata/parity-ledger.csv | 2 +- tests/native_libreoffice.py | 30 ++++ 15 files changed, 768 insertions(+), 28 deletions(-) create mode 100644 internal/app/libreoffice_selection.go create mode 100644 internal/app/libreoffice_selection_test.go create mode 100644 internal/atspi/libreoffice_selection_native.go create mode 100644 internal/presentation/libreoffice_selection.go create mode 100644 internal/presentation/libreoffice_selection_test.go diff --git a/LIBREOFFICE_SUPPORT.md b/LIBREOFFICE_SUPPORT.md index 819b333..97ec8c9 100644 --- a/LIBREOFFICE_SUPPORT.md +++ b/LIBREOFFICE_SUPPORT.md @@ -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 diff --git a/internal/app/application_behavior.go b/internal/app/application_behavior.go index 029b004..db52251 100644 --- a/internal/app/application_behavior.go +++ b/internal/app/application_behavior.go @@ -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 diff --git a/internal/app/event_state.go b/internal/app/event_state.go index 97b1b95..9264ef7 100644 --- a/internal/app/event_state.go +++ b/internal/app/event_state.go @@ -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 diff --git a/internal/app/libreoffice.go b/internal/app/libreoffice.go index 5e0346a..1726568 100644 --- a/internal/app/libreoffice.go +++ b/internal/app/libreoffice.go @@ -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" && diff --git a/internal/app/libreoffice_native_test.go b/internal/app/libreoffice_native_test.go index a4719d8..5eeed35 100644 --- a/internal/app/libreoffice_native_test.go +++ b/internal/app/libreoffice_native_test.go @@ -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.") } diff --git a/internal/app/libreoffice_selection.go b/internal/app/libreoffice_selection.go new file mode 100644 index 0000000..a0c3c23 --- /dev/null +++ b/internal/app/libreoffice_selection.go @@ -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 +} diff --git a/internal/app/libreoffice_selection_test.go b/internal/app/libreoffice_selection_test.go new file mode 100644 index 0000000..3b66fd7 --- /dev/null +++ b/internal/app/libreoffice_selection_test.go @@ -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") + } + }) + } +} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index e682b2f..5c7a78a 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -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()) } } diff --git a/internal/atspi/libreoffice.go b/internal/atspi/libreoffice.go index b0dc1b4..11b71f8 100644 --- a/internal/atspi/libreoffice.go +++ b/internal/atspi/libreoffice.go @@ -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 { diff --git a/internal/atspi/libreoffice_native.go b/internal/atspi/libreoffice_native.go index 4dc6648..9b8a559 100644 --- a/internal/atspi/libreoffice_native.go +++ b/internal/atspi/libreoffice_native.go @@ -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 { diff --git a/internal/atspi/libreoffice_selection_native.go b/internal/atspi/libreoffice_selection_native.go new file mode 100644 index 0000000..14963f5 --- /dev/null +++ b/internal/atspi/libreoffice_selection_native.go @@ -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 +} diff --git a/internal/presentation/libreoffice_selection.go b/internal/presentation/libreoffice_selection.go new file mode 100644 index 0000000..22cd71f --- /dev/null +++ b/internal/presentation/libreoffice_selection.go @@ -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) +} diff --git a/internal/presentation/libreoffice_selection_test.go b/internal/presentation/libreoffice_selection_test.go new file mode 100644 index 0000000..533d20b --- /dev/null +++ b/internal/presentation/libreoffice_selection_test.go @@ -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) + } +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index b882bd3..062680e 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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. diff --git a/tests/native_libreoffice.py b/tests/native_libreoffice.py index 9bf4f79..86475f5 100644 --- a/tests/native_libreoffice.py +++ b/tests/native_libreoffice.py @@ -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