Make web document navigation survive live-updating pages
Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
This commit is contained in:
1 parent
8ce5e5cdad
commit
7eef6e8be6
30 files changed
+1484
-174
No files matched your search
+80
-18
@@ -54,12 +54,23 @@ type Worker struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
wakeFocus chan struct{}
|
||||
requests chan request
|
||||
results chan Result
|
||||
lineSequence atomic.Uint64
|
||||
documentEpoch atomic.Uint64
|
||||
documentBraille atomic.Bool
|
||||
wakeFocus chan struct{}
|
||||
requests chan request
|
||||
results chan Result
|
||||
lineSequence atomic.Uint64
|
||||
documentEpoch atomic.Uint64
|
||||
// documentLoadEpoch bumps only on hard document resets (page loads).
|
||||
// A retained cursor stamped with an older load epoch must never be
|
||||
// reused: its tree may be the previous page at reused paths.
|
||||
documentLoadEpoch atomic.Uint64
|
||||
documentBraille atomic.Bool
|
||||
|
||||
// Retained browse cursors per captured document tree, so returning to
|
||||
// a tab resumes its place instead of restarting cold. Owned by the
|
||||
// run goroutine (document moves run there); no lock.
|
||||
documentCursors map[*atspi.AccessibleTree]*DocumentCursor
|
||||
documentCursorEpochs map[*atspi.AccessibleTree]uint64
|
||||
documentCursorOrder []*atspi.AccessibleTree
|
||||
}
|
||||
|
||||
// SetDocumentBrailleEnabled avoids extra native line reads while braille is off.
|
||||
@@ -68,13 +79,30 @@ func (worker *Worker) SetDocumentBrailleEnabled(enabled bool) {
|
||||
}
|
||||
|
||||
// ResetDocument retires queued work and cursors for the previous page, even
|
||||
// when the provider reuses accessible paths after navigation.
|
||||
// when the provider reuses accessible paths after navigation. It is the
|
||||
// hard reset for page loads: retained cursors from before it must not be
|
||||
// reused, because a new page can reuse the same accessible paths.
|
||||
func (worker *Worker) ResetDocument(focus atspi.ObjectID) {
|
||||
worker.resetDocument(focus, true)
|
||||
}
|
||||
|
||||
// SuspendDocument retires queued work for the previously selected tab
|
||||
// without retiring its browse cursors, so returning to the tab (or its
|
||||
// workspace) resumes each document's place. Only for focus/tab switches
|
||||
// where no page (re)loaded.
|
||||
func (worker *Worker) SuspendDocument(focus atspi.ObjectID) {
|
||||
worker.resetDocument(focus, false)
|
||||
}
|
||||
|
||||
func (worker *Worker) resetDocument(focus atspi.ObjectID, hard bool) {
|
||||
worker.stateMutex.Lock()
|
||||
worker.latestFocus = focus
|
||||
worker.focusRevision++
|
||||
worker.flatOrigin = atspi.ObjectID{}
|
||||
worker.documentEpoch.Add(1)
|
||||
if hard {
|
||||
worker.documentLoadEpoch.Add(1)
|
||||
}
|
||||
worker.stateMutex.Unlock()
|
||||
select {
|
||||
case worker.wakeFocus <- struct{}{}:
|
||||
@@ -251,20 +279,47 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
explicitInterest := implicitInterest
|
||||
flat := &FlatCursor{}
|
||||
var flatTree *atspi.AccessibleTree
|
||||
// held is the first non-identical request found while coalescing
|
||||
// queued identical document moves; it runs next, in order.
|
||||
var held *request
|
||||
takeRepeat := func(first request) int {
|
||||
repeat := 1
|
||||
for {
|
||||
select {
|
||||
case next := <-worker.requests:
|
||||
if sameDocumentRepeat(first, next) {
|
||||
repeat++
|
||||
continue
|
||||
}
|
||||
held = &next
|
||||
return repeat
|
||||
default:
|
||||
return repeat
|
||||
}
|
||||
}
|
||||
}
|
||||
for {
|
||||
// A ready request/result channel can win a select even after Stop.
|
||||
// Do not drain queued work or start further captures during shutdown.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-worker.wakeFocus:
|
||||
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
|
||||
cursor = next
|
||||
var request request
|
||||
if held != nil {
|
||||
request, held = *held, nil
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-worker.wakeFocus:
|
||||
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
|
||||
cursor = next
|
||||
}
|
||||
continue
|
||||
case request = <-worker.requests:
|
||||
}
|
||||
case request := <-worker.requests:
|
||||
}
|
||||
{
|
||||
worker.stateMutex.Lock()
|
||||
focus, revision := worker.latestFocus, worker.focusRevision
|
||||
worker.stateMutex.Unlock()
|
||||
@@ -283,6 +338,12 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
request.documentList.result <- documentListReply{list, err}
|
||||
continue
|
||||
}
|
||||
if request.warmDocument {
|
||||
if request.documentEpoch == documentEpoch {
|
||||
document = worker.warmDocument(ctx, document, request.documentLoadEpoch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if request.interest != nil {
|
||||
interest, err := resolve_interest(request.interest.ctx, interestFocus, explicitInterest, cursor, flat, flatTree, document)
|
||||
worker.stateMutex.Lock()
|
||||
@@ -338,16 +399,16 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
result, documentTextTree = worker.readDocumentLine(ctx, documentTextTree, request.lineRequest, request.lineFocus, request.lineOffset)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.target != 0 {
|
||||
result, document = worker.moveDocument(ctx, document, request.target, request.level, request.backwards, request.documentEpoch)
|
||||
result, document = worker.moveDocumentRepeated(ctx, document, request.target, request.level, request.backwards, request.documentEpoch, request.documentLoadEpoch, takeRepeat(request))
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.caretCommand != 0 {
|
||||
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch)
|
||||
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch, request.documentLoadEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.activate {
|
||||
result, document = worker.activateDocumentCaret(ctx, document, request.documentEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.fragment != "" {
|
||||
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch)
|
||||
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch, request.documentLoadEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.unit != 0 {
|
||||
result, flatTree = worker.readFlat(ctx, flat, flatTree, request.unit, request.step)
|
||||
@@ -373,7 +434,8 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
worker.stateMutex.Unlock()
|
||||
}
|
||||
result.DocumentEpoch = request.documentEpoch
|
||||
if documentCommand && worker.CurrentDocumentResult(result) && errors.Is(result.Err, context.DeadlineExceeded) {
|
||||
if documentCommand && worker.CurrentDocumentResult(result) &&
|
||||
(errors.Is(result.Err, context.DeadlineExceeded) || errors.Is(result.Err, atspi.ErrProviderHung)) {
|
||||
// Keys pressed while the provider was unavailable must not each
|
||||
// start another full timeout. A command issued after this failure
|
||||
// can try again; successful navigation keeps its ordered steps.
|
||||
|
||||
Reference in new issue
Block a user