// 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 import ( "context" "errors" "fmt" "git.stormux.org/storm/cthulhu/internal/atspi" ) type moveResult struct { Result cursor *Cursor } func (worker *Worker) move(ctx context.Context, cursor *Cursor, direction Direction) moveResult { var err error cursor, err = worker.syncLatestFocus(ctx, cursor, true) if err != nil { return moveResult{Result: Result{Direction: direction, Err: err}, cursor: cursor} } if cursor == nil { return moveResult{Result: Result{Direction: direction, Err: ErrNoCurrent}} } position, moved, err := cursor.Move(direction) if err == nil { return moveResult{Result: Result{Direction: direction, Position: position, Moved: moved}, cursor: cursor} } if !errors.Is(err, atspi.ErrStaleObject) && !errors.Is(err, atspi.ErrIncompleteTree) { return moveResult{Result: Result{Direction: direction, Err: err}, cursor: cursor} } // An incomplete child list needs recapture without abandoning an otherwise // live independent review position. Stale objects still recover from focus. var retained *Cursor if errors.Is(err, atspi.ErrIncompleteTree) { retained = cursor } recovered, recoveryErr := worker.captureCursor(ctx, retained) if recoveryErr != nil { return moveResult{Result: Result{Direction: direction, Err: recoveryErr}, cursor: retained} } if recovered == nil { return moveResult{Result: Result{Direction: direction, Err: err}} } position, moved, err = recovered.Move(direction) return moveResult{Result: Result{Direction: direction, Position: position, Moved: moved, Err: err}, cursor: recovered} } func (worker *Worker) syncLatestFocus(ctx context.Context, cursor *Cursor, captureIfMissing bool) (*Cursor, error) { focus := worker.focus() if !focus.Valid() { return cursor, nil } if cursor != nil { _, currentErr := cursor.Current() _, syncErr := cursor.SyncFocus(focus) if syncErr == nil && !errors.Is(currentErr, atspi.ErrStaleObject) { return cursor, nil } if syncErr != nil && !errors.Is(syncErr, atspi.ErrObjectNotFound) && !errors.Is(syncErr, atspi.ErrStaleObject) { return cursor, syncErr } } if !captureIfMissing && cursor == nil { return nil, nil } return worker.captureCursor(ctx, nil) } func (worker *Worker) captureCursor(ctx context.Context, cursor *Cursor) (*Cursor, error) { captureContext, cancelCapture := context.WithTimeout(ctx, worker.captureTimeout) tree, err := worker.capturer.CaptureDesktop(captureContext) cancelCapture() if err != nil { return nil, fmt.Errorf("capture object review tree: %w", err) } if tree == nil { return nil, errors.New("capture object review tree: nil tree") } if cursor == nil || cursor.tree != tree { cursor, err = NewCursor(tree) if err != nil { return nil, err } } // Focus can change while capture is underway. Always synchronize to the // newest identity after the capture returns. focus := worker.focus() if _, err := cursor.SyncFocus(focus); err != nil { return nil, err } return cursor, nil }