// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" ) // prepareLibreOfficeEvent follows current Orca's soffice focus/caret policy. // A Calc active cell is the logical focus even when editing destroyed the old // focus or a sheet switch changed its managed container (LO bug 161444). func prepareLibreOfficeEvent(event atspi.Event, current atspi.Snapshot, owner windowOwner) applicationEventResult { result := applicationEventResult{event: event} context := event.Source.LibreOffice if !atspi.IsLibreOfficeProcess(event.Source.ApplicationProcess) || event.Source.Defunct || !event.Source.ID().Valid() || owner.window.Valid() && (eventWindow(event) != owner.window || event.Source.BusName != owner.applicationBus) { return result } if isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent && current.LibreOffice != nil && current.LibreOffice.InSpreadsheet { // LibreOffice also claims focus for ancestors of the real focused // object. Orca ignores these; copied ancestry is enough to identify // them without another native tree query. for _, ancestor := range current.BrailleContext { if ancestor.ID().Valid() && ancestor.ID() == event.Source.ID() { result.suppress = true return result } } } if context != nil && context.InSpreadsheet && context.Cell && event.Source.Role == "table cell" && event.Type == atspi.ActiveDescendantEvent && (event.Source.Focused || context.ContainerFocused) { // Preserve NativeType/OriginPath for diagnostics. Ordinary focus // handling now owns review, duplicate suppression, and Braille. if result.event.NativeType == "" { result.event.NativeType = event.Type } result.event.Type = atspi.FocusEvent } if context != nil && context.InSpreadsheet && event.Type == atspi.SelectionChangedEvent { // Selection can name the first cell of a range rather than the // active cell. 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" && isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent && current.LibreOffice != nil && (current.LibreOffice.CellEditor || current.LibreOffice.Cell && current.TablePath == event.Source.Path) { // Orca treats this as the sheet reclaiming focus after editing. // Own it silently; the following active cell supplies presentation. result.adoptFocus, result.suppress = true, true } if context != nil && context.Cell && isFocusGainEvent(result.event) && current.ID() != event.Source.ID() { // Returning to the cell after typing must speak it even if its // value is unchanged; typing uses a separate presentation history. result.resetPresentation = true } if context != nil && context.CellEditor && event.Source.Role == "paragraph" && (isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent) && owner.window.Valid() && event.Source.ID() != current.ID() && (event.Source.Focused || context.EditorParent == current.ID() && current.LibreOffice != nil && current.LibreOffice.CellEditor) { // Empty-cell F2 can focus a panel before its paragraph exists. // Admit that editor's first mutation before generic text filtering. result.adoptFocus = true } if event.Type == atspi.CaretEvent && current.LibreOffice != nil && current.LibreOffice.Cell && (context == nil || !context.CellEditor) { // Formula-bar/background caret events must not displace the cell. result.suppress = true } return result } func libreOfficeBrailleFrame(snapshot atspi.Snapshot, options config.BrailleOptions) (braille.Frame, bool) { context := snapshot.LibreOffice if !atspi.IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Role != "table cell" || context == nil || !context.InSpreadsheet || !context.Cell { return braille.Frame{}, false } // Orca presents the value followed by its cell address. This is a // component, not an editable-text route; cell focus has no text caret. return braille.Frame{Text: brailleJoin(context.CellText, context.Coordinates), Cursor: 0, WordWrap: options.WordWrap}, true } // The event loop owns the accepted focus snapshot. Formula commands share its // foreground/lock gates and output queue, with no separate application state. func (handler *eventReviewHandler) presentCellFormula() { if handler.focusGate.Locked() || handler.focusGate.Suspended() { return } handler.syncForeground() handler.state.pending.clear() text := presentation.LibreOfficeFormulaText(handler.state.current) handler.speech.Interrupt() handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "cell-formula"}) }