199 lines
5.9 KiB
Go
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
|
|
}
|