Export reviewed object and flat reading to Remote

This commit is contained in:
ak47 committed 2026-11-05 16:50:52 -05:00
1 parent 49be8309fe
commit c03ffa7409
11 files changed
+425 -10

No files matched your search

+7
View File
@@ -62,6 +62,9 @@ type Position struct {
Current atspi.Snapshot
Previous atspi.Snapshot
HasPrevious bool
// SourceContext retains privacy ancestry independently of presentation.
// Missing/stale ancestors leave it empty without changing local review.
SourceContext []atspi.Snapshot
}
// Cursor owns object-navigation state over one AccessibleTree. Focus updates
@@ -185,6 +188,10 @@ func (cursor *Cursor) positionLocked() (Position, error) {
return Position{}, fmt.Errorf("resolve current review object: %w", err)
}
position := Position{Current: current}
position.SourceContext, _ = cursor.tree.Ancestry(cursor.current)
if len(position.SourceContext) > 0 {
position.Current = position.SourceContext[0]
}
if cursor.previous.Valid() {
previous, previousErr := cursor.tree.Resolve(cursor.previous)
if previousErr == nil {
+31 -2
View File
@@ -48,6 +48,9 @@ type FlatPosition struct {
Offset int
HasOffset bool
Bounds atspi.Rectangle
// SourceContexts includes every zone contributing text, in spoken order.
// An empty context marks unavailable privacy metadata.
SourceContexts [][]atspi.Snapshot
}
type flatLocation struct{ line, zone, character int }
@@ -64,6 +67,8 @@ type FlatCursor struct {
tree *atspi.AccessibleTree
references map[atspi.ObjectID]atspi.ObjectReference
parents map[atspi.ObjectID]atspi.ObjectID
snapshots map[atspi.ObjectID]atspi.Snapshot
rootContext []atspi.Snapshot
verticalTargetX int64
hasVerticalTarget bool
started bool
@@ -85,8 +90,10 @@ func (cursor *FlatCursor) Refresh(ctx context.Context, tree *atspi.AccessibleTre
return err
}
next := FlatCursor{lines: lines, focus: focus, tree: tree, references: snapshot.References, completeness: completeness,
parents: make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))}
parents: make(map[atspi.ObjectID]atspi.ObjectID, len(nodes)),
snapshots: make(map[atspi.ObjectID]atspi.Snapshot, len(nodes)), rootContext: snapshot.Context}
for _, n := range nodes {
next.snapshots[n.Snapshot.ID()] = n.Snapshot
for _, child := range n.Children {
next.parents[child] = n.Snapshot.ID()
}
@@ -390,11 +397,13 @@ func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition {
zone := cursor.zone()
start, end := cursor.location.character, min(cursor.location.character+1, len(zone.runes))
text := ""
var contexts [][]atspi.Snapshot
switch unit {
case Line:
var parts []string
for _, z := range cursor.lines[cursor.location.line].zones {
parts = append(parts, z.text)
contexts = append(contexts, cursor.sourceContext(z.object))
}
text = strings.Join(parts, " ")
case Word:
@@ -404,7 +413,27 @@ func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition {
}
if unit != Line {
text = string(zone.runes[start:end])
contexts = [][]atspi.Snapshot{cursor.sourceContext(zone.object)}
}
return FlatPosition{Text: text, Object: zone.object, Offset: zone.start + start,
HasOffset: zone.hasOffset, Bounds: zone.bounds}
HasOffset: zone.hasOffset, Bounds: zone.bounds, SourceContexts: contexts}
}
func (cursor *FlatCursor) sourceContext(id atspi.ObjectID) []atspi.Snapshot {
if len(cursor.rootContext) == 0 {
return nil
}
var sources []atspi.Snapshot
for id.Valid() {
if id == cursor.rootContext[0].ID() {
return append(sources, cursor.rootContext...)
}
snapshot, ok := cursor.snapshots[id]
if !ok {
return nil
}
sources = append(sources, snapshot)
id = cursor.parents[id]
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestObjectReviewRetainsDestinationAncestry(t *testing.T) {
tree := newTree(t)
cursor, _ := NewCursor(tree)
cursor.SyncFocus(objectID("/first"))
position, moved, err := cursor.Move(NextSibling)
if err != nil || !moved || len(position.SourceContext) != 2 ||
position.SourceContext[0].ID() != objectID("/second") || position.SourceContext[1].ID() != objectID("/root") {
t.Fatal("object review lost destination ancestry", position, err)
}
// Dirty privacy metadata must not suppress otherwise valid local review.
tree.InvalidateSnapshot(objectID("/root"))
position, moved, err = cursor.Move(FirstChild)
if err != nil || !moved || position.Current.Name != "Grandchild" || len(position.SourceContext) != 0 {
t.Fatal("stale ancestry changed local navigation or retained export metadata", position, err)
}
}
func TestFlatReviewRetainsEverySpokenSourceAcrossReplacement(t *testing.T) {
tree := flatTree(t, flatNode("/a", "Alpha", 0, 0), flatNode("/b", "Beta", 250, 0))
cursor := &FlatCursor{}
if err := cursor.Refresh(t.Context(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
position, _, err := cursor.Read(Line, 0)
if err != nil || position.Text != "Alpha Beta" || len(position.SourceContexts) != 2 {
t.Fatal("line lost a contributing zone", position, err)
}
for i, path := range []string{"/a", "/b"} {
context := position.SourceContexts[i]
if len(context) != 2 || context[0].ID() != objectID(path) || context[1].Role != "frame" {
t.Fatal("line source lost original ancestry", context)
}
}
// Text and ancestry must stay from the same atomic layout capture even if
// the live tree changes before the result leaves the worker.
window, _ := tree.ReviewWindow(t.Context(), objectID("/a"))
window.Nodes[2].Snapshot.Role = "password text"
if err := tree.Replace(objectID("/window"), window.Nodes); err != nil {
t.Fatal(err)
}
retained, _, err := cursor.Read(Line, 0)
if err != nil || retained.Text != position.Text || retained.SourceContexts[1][0].Role == "password text" {
t.Fatal("replacement mixed old text with new ancestry", retained, err)
}
if err := cursor.Refresh(t.Context(), tree, objectID("/a")); err != nil {
t.Fatal(err)
}
current, _, _ := cursor.Read(Line, 0)
if current.SourceContexts[len(current.SourceContexts)-1][0].Role != "password text" {
t.Fatal("refreshed layout retained old privacy metadata", current)
}
}
func TestFlatReviewIncludesAncestorsOutsideMenuScope(t *testing.T) {
root := node("/root", "")
root.Snapshot.Role = "password text"
root.Children = []atspi.ObjectID{objectID("/menu")}
menu := flatNode("/menu", "", 0, 0)
menu.Text.Ranges = nil
menu.Snapshot.Role = "menu"
menu.Children = []atspi.ObjectID{objectID("/item")}
item := flatNode("/item", "Item", 0, 0)
tree, err := atspi.NewAccessibleTree(root.Snapshot.ID(), []atspi.TreeNode{root, menu, item})
if err != nil {
t.Fatal(err)
}
cursor := &FlatCursor{}
if err := cursor.Refresh(t.Context(), tree, item.Snapshot.ID()); err != nil {
t.Fatal(err)
}
position, _, err := cursor.Read(Word, 0)
if err != nil || len(position.SourceContexts) != 1 || len(position.SourceContexts[0]) != 3 ||
position.SourceContexts[0][2].Role != "password text" {
t.Fatal("menu scope hid a private ancestor", position, err)
}
tree.InvalidateSnapshot(root.Snapshot.ID())
if err := cursor.Refresh(t.Context(), tree, item.Snapshot.ID()); err != nil {
t.Fatal(err)
}
position, _, err = cursor.Read(Word, 0)
if err != nil || position.Text != "Item" || len(position.SourceContexts[0]) != 0 {
t.Fatal("stale outer ancestor changed local text or allowed export", position, err)
}
}