// 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" import "unsafe" // 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 && 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 { if C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET { // Orca clears stale table state around Calc focus transitions. C.atspi_accessible_clear_cache_single(event.source) } C.g_object_unref(C.gpointer(parent)) } return nil } if role != C.ATSPI_ROLE_PANEL && role != C.ATSPI_ROLE_EXTENDED { return nil } parent := C.atspi_accessible_get_parent(event.source, nil) if parent == nil { return nil } spreadsheet := C.atspi_accessible_get_role(parent, nil) == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET C.g_object_unref(C.gpointer(parent)) if !spreadsheet { return nil } count := min(int(C.atspi_accessible_get_child_count(event.source, nil)), 4) for index := 0; index < count; index++ { child := C.atspi_accessible_get_child_at_index(event.source, C.gint(index), nil) if child == nil { continue } states := C.atspi_accessible_get_state_set(child) focused := states != nil && C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 && C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0 if states != nil { C.g_object_unref(C.gpointer(states)) } if focused && C.atspi_accessible_get_role(child, nil) == C.ATSPI_ROLE_PARAGRAPH { return child } C.g_object_unref(C.gpointer(child)) } return nil } func captureLibreOfficeContext(source *C.AtspiAccessible, snapshot *Snapshot) { if !IsLibreOfficeProcess(snapshot.ApplicationProcess) || snapshot.Defunct { return } switch snapshot.Role { case "table", "table cell", "paragraph", "panel", "extended": default: return } context := LibreOfficeContext{} parentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID) grandparentRole := C.AtspiRole(C.ATSPI_ROLE_INVALID) current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) for depth := 0; current != nil && depth < 32; depth++ { role := C.atspi_accessible_get_role(current, nil) if depth == 1 { parentRole = role context.EditorParent = nativeObjectID(current) } if depth == 2 { grandparentRole = role } if role == C.ATSPI_ROLE_TABLE { states := C.atspi_accessible_get_state_set(current) if states != nil { context.ContainerFocused = C.atspi_state_set_contains(states, C.ATSPI_STATE_FOCUSED) != 0 C.g_object_unref(C.gpointer(states)) } } if role == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET { context.InSpreadsheet = true C.g_object_unref(C.gpointer(current)) current = nil break } if role == C.ATSPI_ROLE_FRAME || role == C.ATSPI_ROLE_DIALOG || role == C.ATSPI_ROLE_APPLICATION { C.g_object_unref(C.gpointer(current)) current = nil break } parent := C.atspi_accessible_get_parent(current, nil) C.g_object_unref(C.gpointer(current)) current = parent } if current != nil { C.g_object_unref(C.gpointer(current)) } if !context.InSpreadsheet { return } context.Cell = snapshot.Role == "table cell" context.CellEditor = (snapshot.Role == "panel" || snapshot.Role == "extended") && parentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET || snapshot.Role == "paragraph" && (parentRole == C.ATSPI_ROLE_PANEL || parentRole == C.ATSPI_ROLE_EXTENDED) && grandparentRole == C.ATSPI_ROLE_DOCUMENT_SPREADSHEET if context.Cell { context.Coordinates = snapshot.Name // Orca requests uncached attributes: editing can replace a formula // without replacing the accessible cell. C.atspi_accessible_clear_cache_single(source) attributes := C.atspi_accessible_get_attributes(source, nil) if attributes != nil { columnKey, rowKey := C.CString("colindextext"), C.CString("rowindextext") column := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(columnKey)))) row := C.GoString((*C.gchar)(C.g_hash_table_lookup(attributes, C.gconstpointer(rowKey)))) C.free(unsafe.Pointer(columnKey)) C.free(unsafe.Pointer(rowKey)) if column != "" && row != "" { context.Coordinates = sanitizeSnapshotField(column + row) } // Current providers use lowercase; Orca also accepts Formula. for _, key := range []string{"formula", "Formula"} { name := C.CString(key) value := C.g_hash_table_lookup(attributes, C.gconstpointer(name)) C.free(unsafe.Pointer(name)) if value != nil { context.Formula = sanitizeLibreOfficeFormula(C.GoString((*C.gchar)(value))) break } } C.g_hash_table_unref(attributes) } text := C.atspi_accessible_get_text_iface(source) if text != nil { // Calc's value is Text, while Name is the coordinate. Cell caret // offsets can be invalid, so capture contents independently. length := max(0, min(int(C.atspi_text_get_character_count(text, nil)), maxSnapshotFieldLength)) content := C.atspi_text_get_text(text, 0, C.gint(length), nil) context.CellText = sanitizeTextContext(C.GoString(content)) C.g_free(C.gpointer(content)) C.g_object_unref(C.gpointer(text)) } } snapshot.Spreadsheet = true snapshot.LibreOffice = &context }