Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,198 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user