133 lines
4.6 KiB
Go
133 lines
4.6 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
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
// SetFlatOrigin starts a new flat-review context at the browse caret without
|
|
// changing native focus. Once review begins, its cursor moves independently.
|
|
func (worker *Worker) SetFlatOrigin(object atspi.ObjectID, offset int) {
|
|
worker.stateMutex.Lock()
|
|
worker.flatOrigin, worker.flatOffset = object, offset
|
|
worker.stateMutex.Unlock()
|
|
}
|
|
|
|
func (worker *Worker) flatStart() (atspi.ObjectID, int) {
|
|
worker.stateMutex.Lock()
|
|
defer worker.stateMutex.Unlock()
|
|
return worker.flatOrigin, worker.flatOffset
|
|
}
|
|
|
|
// FlatSpeech controls the speech of a review result without moving its cursor.
|
|
type FlatSpeech uint8
|
|
|
|
const (
|
|
FlatSpeak FlatSpeech = iota
|
|
FlatSpell
|
|
FlatPhonetic
|
|
)
|
|
|
|
// ReadFlat queues sequential review on the same bounded FIFO as object review.
|
|
// Capacity is 16; overflow rejects the newest request and is reported to the
|
|
// caller. Results retain FIFO order in a 16-result queue; a stalled consumer
|
|
// blocks only this worker. Cancellation discards pending work and joins it.
|
|
func (worker *Worker) ReadFlat(unit FlatUnit, step int) error {
|
|
return worker.ReadFlatWithSpeech(unit, step, FlatSpeak)
|
|
}
|
|
|
|
// ReadFlatWithSpeech retains presentation policy with its FIFO request, so a
|
|
// later press cannot change the speech of an earlier asynchronous result.
|
|
func (worker *Worker) ReadFlatWithSpeech(unit FlatUnit, step int, style FlatSpeech) error {
|
|
if unit < Line || unit > LineEnd || step < -1 || step > 1 || (unit > Character && step != 0) {
|
|
return ErrInvalidDirection
|
|
}
|
|
if style > FlatPhonetic || (style != FlatSpeak && (step != 0 || (unit != Word && unit != Character))) {
|
|
return ErrInvalidDirection
|
|
}
|
|
select {
|
|
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), unit: unit, step: step, flatSpeech: style}:
|
|
return nil
|
|
default:
|
|
return ErrRequestQueueFull
|
|
}
|
|
}
|
|
|
|
func (worker *Worker) readFlat(ctx context.Context, cursor *FlatCursor, tree *atspi.AccessibleTree, unit FlatUnit, step int) (Result, *atspi.AccessibleTree) {
|
|
focus := worker.focus()
|
|
result := Result{FlatUnit: unit, FlatStep: step, FlatFocus: focus}
|
|
if !focus.Valid() {
|
|
result.Err = ErrNoCurrent
|
|
return result, tree
|
|
}
|
|
fresh := false
|
|
if policy, ok := worker.capturer.(FreshReviewWindowCapturer); ok {
|
|
fresh = policy.FreshReviewWindow(focus)
|
|
}
|
|
var err error
|
|
if !fresh {
|
|
err = cursor.Refresh(ctx, tree, focus)
|
|
}
|
|
if fresh || errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrIncompleteTree) || errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) {
|
|
result.CaptureReason = err
|
|
timeout := worker.flatCaptureTimeout
|
|
if fresh {
|
|
timeout = min(timeout, 500*time.Millisecond)
|
|
}
|
|
captureContext, cancel := context.WithTimeout(ctx, timeout)
|
|
captureStarted := time.Now()
|
|
captured, captureErr := worker.capturer.CaptureReviewWindow(captureContext, focus)
|
|
result.CaptureDuration = time.Since(captureStarted)
|
|
cancel()
|
|
if captureErr != nil {
|
|
result.Err = captureErr
|
|
return result, tree
|
|
}
|
|
if captured == nil {
|
|
result.Err = errors.New("capture flat review tree: nil tree")
|
|
return result, tree
|
|
}
|
|
tree = captured
|
|
// Capture can outlive a focus transition. Use the newest identity and
|
|
// refresh only once: repeated invalidation cannot cause a retry loop.
|
|
result.FlatFocus = worker.focus()
|
|
err = cursor.Refresh(ctx, tree, result.FlatFocus)
|
|
}
|
|
if err != nil {
|
|
result.Err = err
|
|
return result, tree
|
|
}
|
|
if cursor.focus == focus && !cursor.started {
|
|
if origin, offset := worker.flatStart(); origin.Valid() {
|
|
if !cursor.seek(origin, offset, true) {
|
|
if !cursor.seek(origin, 0, false) {
|
|
cursor.seekDescendant(origin)
|
|
}
|
|
}
|
|
}
|
|
cursor.started = true
|
|
}
|
|
result.FlatPosition, result.Moved, result.Err = cursor.Read(unit, step)
|
|
return result, tree
|
|
}
|