Files
cthulhu/internal/app/libreoffice_selection.go
2026-10-15 01:10:43 -04:00

119 lines
4.6 KiB
Go

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