// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Package review owns navigation state over immutable AT-SPI snapshots. package review import ( "errors" "fmt" "sync" "git.stormux.org/storm/cthulhu/internal/atspi" ) var ( ErrNoCurrent = errors.New("review cursor has no current object") ErrInvalidDirection = errors.New("invalid review direction") ) type Direction int const ( Parent Direction = iota + 1 FirstChild PreviousSibling NextSibling ) func (direction Direction) String() string { switch direction { case Parent: return "parent" case FirstChild: return "first-child" case PreviousSibling: return "previous-sibling" case NextSibling: return "next-sibling" default: return fmt.Sprintf("direction(%d)", direction) } } // Position contains owned snapshots for the cursor's current and previous // objects. HasPrevious distinguishes an absent previous object from an empty // snapshot. type Position struct { Current atspi.Snapshot Previous atspi.Snapshot HasPrevious bool } // Cursor owns object-navigation state over one AccessibleTree. Focus updates // and navigation may arrive from different goroutines, so Cursor serializes // its references while AccessibleTree validates their generations. type Cursor struct { mu sync.Mutex tree *atspi.AccessibleTree current atspi.ObjectReference previous atspi.ObjectReference lastFocus atspi.ObjectID } func NewCursor(tree *atspi.AccessibleTree) (*Cursor, error) { if tree == nil { return nil, errors.New("create review cursor: nil accessible tree") } return &Cursor{tree: tree}, nil } // SyncFocus moves the cursor to focus only when focus changes. Repeated focus // snapshots therefore do not pull a user back from an independently reviewed // object. If invalidation made the current reference stale, SyncFocus repairs // it from the current focus identity when possible. func (cursor *Cursor) SyncFocus(focus atspi.ObjectID) (Position, error) { cursor.mu.Lock() defer cursor.mu.Unlock() if !focus.Valid() { return Position{}, errors.New("sync review cursor: invalid focus identity") } if focus == cursor.lastFocus && cursor.current.Valid() { if position, err := cursor.positionLocked(); err == nil { return position, nil } else if !errors.Is(err, atspi.ErrStaleObject) { return Position{}, err } } reference, err := cursor.tree.Reference(focus) if err != nil { return Position{}, fmt.Errorf("sync review cursor: %w", err) } cursor.setCurrentLocked(reference) cursor.lastFocus = focus return cursor.positionLocked() } func (cursor *Cursor) Current() (Position, error) { cursor.mu.Lock() defer cursor.mu.Unlock() return cursor.positionLocked() } // Move traverses one owned-tree relationship. A boundary leaves the cursor in // place and returns moved=false. Stale references and malformed state are // errors and also leave the cursor unchanged. func (cursor *Cursor) Move(direction Direction) (position Position, moved bool, err error) { cursor.mu.Lock() defer cursor.mu.Unlock() if !cursor.current.Valid() { return Position{}, false, ErrNoCurrent } if _, err := cursor.tree.Resolve(cursor.current); err != nil { return Position{}, false, fmt.Errorf("move review cursor: %w", err) } var next atspi.ObjectReference var ok bool switch direction { case Parent: next, ok, err = cursor.tree.Parent(cursor.current) case FirstChild: next, ok, err = cursor.tree.FirstChild(cursor.current) case PreviousSibling: next, ok, err = cursor.tree.PreviousSibling(cursor.current) case NextSibling: next, ok, err = cursor.tree.NextSibling(cursor.current) default: return Position{}, false, fmt.Errorf("%w: %d", ErrInvalidDirection, direction) } if err != nil { return Position{}, false, fmt.Errorf("move review cursor: %w", err) } if !ok { position, err := cursor.positionLocked() return position, false, err } if _, err := cursor.tree.Resolve(next); err != nil { return Position{}, false, fmt.Errorf("resolve review destination: %w", err) } cursor.setCurrentLocked(next) position, err = cursor.positionLocked() return position, true, err } func (cursor *Cursor) Reset() { cursor.mu.Lock() defer cursor.mu.Unlock() cursor.current = atspi.ObjectReference{} cursor.previous = atspi.ObjectReference{} cursor.lastFocus = atspi.ObjectID{} } func (cursor *Cursor) setCurrentLocked(reference atspi.ObjectReference) { if cursor.current.Valid() && cursor.current != reference { cursor.previous = cursor.current } else if !cursor.current.Valid() { cursor.previous = atspi.ObjectReference{} } cursor.current = reference } func (cursor *Cursor) positionLocked() (Position, error) { if !cursor.current.Valid() { return Position{}, ErrNoCurrent } current, err := cursor.tree.Resolve(cursor.current) if err != nil { return Position{}, fmt.Errorf("resolve current review object: %w", err) } position := Position{Current: current} if cursor.previous.Valid() { previous, previousErr := cursor.tree.Resolve(cursor.previous) if previousErr == nil { position.Previous = previous position.HasPrevious = true } else if !errors.Is(previousErr, atspi.ErrStaleObject) { return Position{}, fmt.Errorf("resolve previous review object: %w", previousErr) } } return position, nil }