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