// 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 }