Files
cthulhu/internal/app/event_browser_tabs.go
hjozwiak 7eef6e8be6 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.
2026-11-03 10:34:55 -05:00

76 lines
3.1 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
// handleBrowserTab presents a selected tab while leaving native focus in chrome.
func (handler *accessibilityEventHandler) handleBrowserTab(event atspi.Event, activity input.Activity) bool {
state := handler.state
browser := handler.browser
objectReview := handler.objectReview
speech := handler.speech
brailleOutput := handler.brailleOutput
live := handler.live
if event.Source.BrowserTab && (event.Type == atspi.SelectedStateEvent || event.Type == atspi.SelectionChangedEvent) {
// Python's web page-switch handler presents the tab without moving
// the locus of focus. Repeated Ctrl+T can keep the same address
// control focused and produce no further focus events at all.
selected := event.Type == atspi.SelectedStateEvent && event.Detail1 != 0 ||
event.Type == atspi.SelectionChangedEvent && event.Source.Selected &&
strings.EqualFold(event.Source.ToolkitName, "Gecko")
if !selected || event.Source.StatesKnown && !event.Source.Selected ||
event.Source.Defunct || !state.owner.containsLoad(event) || event.Source.ID() == state.selectedBrowserTab {
return true
}
state.selectedBrowserTab = event.Source.ID()
state.tabFocusWindow, state.tabFocusSequence = eventWindow(event), activity.Sequence
state.pending.caret.clear()
state.documentLineFocus, state.documentLineRequest, state.documentLineEvent = atspi.ObjectID{}, 0, atspi.Event{}
state.loadingDocument, state.loadedDocument = atspi.ObjectID{}, atspi.ObjectID{}
state.chromeLoadedDocument, state.chromeLoadedWindow = atspi.ObjectID{}, atspi.ObjectID{}
if browser != nil {
browser.clear()
}
if objectReview != nil {
focus := atspi.ObjectID{}
if state.current.Autocomplete {
focus = state.current.ID()
}
objectReview.SuspendDocument(focus)
objectReview.WarmDocument()
}
spoken := presentation.FocusSpeechWithOptions(event.Source, state.presentationOptions)
text := spoken.Text
if text != "" {
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.Chunks, Role: event.Source.Role, Cause: event.Type})
}
if brailleOutput != nil {
brailleOptions := config.DefaultSettings().Braille
textAttributes := config.DefaultTextAttributes()
if live != nil {
settings := live.Load()
brailleOptions, textAttributes = settings.Braille, settings.TextAttributes
}
brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, brailleOptions, textAttributes))
}
return true
}
if event.Source.Autocomplete && eventWindow(event) == state.tabFocusWindow &&
activity.Sequence != 0 && activity.Sequence == state.tabFocusSequence &&
(event.Type == atspi.CaretEvent || event.Type == "object:state-changed:expanded") {
return true // Automatic address reset must not cut off the selected tab.
}
return false
}