Files
cthulhu/internal/atspi/current_line_native.go

168 lines
5.5 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#cgo pkg-config: atspi-2 glib-2.0 gobject-2.0
#include <atspi/atspi.h>
*/
import "C"
import (
"context"
"errors"
"strings"
"time"
)
// ReadFocusedLine reads only the line containing the current native caret.
func (adapter *Adapter) ReadFocusedLine(ctx context.Context, focus ObjectID) (DocumentContent, error) {
result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, readFocusedLine: true})
return result.content, err
}
// ReadFocusedSnapshot captures presentation and privacy metadata together on
// the GLib owner. It neither moves focus nor changes the native caret.
func (adapter *Adapter) ReadFocusedSnapshot(ctx context.Context, focus ObjectID) (DocumentContent, error) {
result, err := adapter.documentOperation(ctx, treeCaptureRequest{reviewFocus: focus, readFocusedSnapshot: true})
return result.content, err
}
func (adapter *Adapter) read_native_focused_snapshot(ctx context.Context, focus ObjectID) (DocumentContent, error) {
if err := ctx.Err(); err != nil {
return DocumentContent{}, err
}
if !adapter.nativeFocusIs(focus) {
return DocumentContent{}, ErrStaleObject
}
source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
C.atspi_accessible_clear_cache_single(source)
snapshot := snapshotFromNativeWithCaret(source, true, false, false, false)
return DocumentContent{Snapshot: snapshot, SourceContext: native_focused_line_context(ctx, source)}, ctx.Err()
}
func (adapter *Adapter) read_native_focused_line(ctx context.Context, focus ObjectID) (content DocumentContent, err error) {
if err := ctx.Err(); err != nil {
return DocumentContent{}, err
}
if !adapter.nativeFocusIs(focus) {
return DocumentContent{}, ErrStaleObject
}
source := adapter.nativeFocus[len(adapter.nativeFocus)-1].source
C.atspi_accessible_clear_cache_single(source)
snapshot := snapshotFromNativeWithCaret(source, false, false, false, false)
if snapshot.Role == "password text" {
return DocumentContent{}, ErrNoDocumentCaret
}
content = DocumentContent{Snapshot: snapshot, Text: snapshot.Name}
defer func() {
if err == nil {
content.SourceContext = native_focused_line_context(ctx, source)
}
}()
text := C.atspi_accessible_get_text_iface(source)
if text == nil {
return content, ctx.Err()
}
defer C.g_object_unref(C.gpointer(text))
reader := nativeReviewText{text: text, terminal: snapshot.Role == "terminal"}
count, err := reader.count(ctx)
if err != nil {
return DocumentContent{}, err
}
content.Text = ""
if count == 0 {
return content, ctx.Err()
}
var nativeErr *C.GError
offset := int(C.atspi_text_get_caret_offset(text, &nativeErr))
if nativeErr != nil {
err := nativeError("read current line caret", nativeErr)
C.g_error_free(nativeErr)
return DocumentContent{}, err
}
// A text object without a caret reads from its first line, as NVDA does.
offset = min(max(0, offset), count)
if offset == count {
last := C.atspi_text_get_text(text, C.gint(count-1), C.gint(count), &nativeErr)
if last != nil {
defer C.g_free(C.gpointer(last))
}
if nativeErr != nil {
err := nativeError("read current line end", nativeErr)
C.g_error_free(nativeErr)
return DocumentContent{}, err
}
// A trailing newline puts the caret on an empty final line.
if strings.HasSuffix(C.GoString(last), "\n") {
content.Start, content.End = count, count
return content, ctx.Err()
}
offset--
}
line, err := reader.line(ctx, offset)
if err != nil {
return DocumentContent{}, err
}
if line.Start < 0 || line.Start > offset || line.End <= offset || line.End > count {
return DocumentContent{}, errors.New("read current line: invalid text range")
}
content.Start, content.End, content.Text, content.RawText = line.Start, line.End, line.Text, line.Text
return content, ctx.Err()
}
// Privacy capture is separate from presentation context: include every parent,
// even unnamed containers and parents outside the window. Keep it on the GLib
// owner and bound the walk without scanning children or retaining native refs.
// Failed/truncated capture must not turn a successful local read into an error.
func native_focused_line_context(ctx context.Context, source *C.AtspiAccessible) []Snapshot {
deadline, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
current := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
defer func() {
if current != nil {
C.g_object_unref(C.gpointer(current))
}
}()
var result []Snapshot
seen := make(map[ObjectID]bool)
for current != nil && len(result) < 64 && deadline.Err() == nil {
C.atspi_accessible_clear_cache_single(current)
id := nativeObjectID(current)
if !id.Valid() || seen[id] {
return nil
}
seen[id] = true
var nativeErr *C.GError
roleName := C.atspi_accessible_get_role_name(current, &nativeErr)
role := C.GoString(roleName)
C.g_free(C.gpointer(roleName))
if nativeErr != nil {
C.g_error_free(nativeErr)
return nil
}
states := C.atspi_accessible_get_state_set(current)
if states == nil {
return nil
}
defunct := C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) != 0
C.g_object_unref(C.gpointer(states))
result = append(result, Snapshot{BusName: id.BusName, Path: id.Path, Role: role, Defunct: defunct})
if role == "application" {
if deadline.Err() != nil {
return nil
}
return result
}
parent := C.atspi_accessible_get_parent(current, &nativeErr)
C.g_object_unref(C.gpointer(current))
current = parent
if nativeErr != nil {
C.g_error_free(nativeErr)
return nil
}
}
return nil
}