Files

199 lines
5.9 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
// 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
}