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,249 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
)
|
||||
|
||||
type windowTitleSource interface {
|
||||
ForegroundTitle() atspi.ForegroundTitle
|
||||
TitleChanges() <-chan struct{}
|
||||
}
|
||||
|
||||
type pendingWindowTitle struct {
|
||||
text string
|
||||
window atspi.ObjectID
|
||||
epoch uint64
|
||||
native xorg.WindowTitle
|
||||
changed bool
|
||||
application string
|
||||
observedAt time.Time
|
||||
}
|
||||
|
||||
// windowTitleController is owned by the accessibility event loop. Discovery
|
||||
// stays in the native adapter; this controller owns coalescing, preferences,
|
||||
// and duplicate suppression across AT-SPI and X11.
|
||||
type windowTitleController struct {
|
||||
state *eventLoopState
|
||||
live *liveSettings
|
||||
gate *focusGate
|
||||
speech speechEnqueuer
|
||||
logger *slog.Logger
|
||||
source windowTitleSource
|
||||
foreground foregroundMonitor
|
||||
lastNative atspi.ForegroundTitle
|
||||
lastTitle string
|
||||
application string
|
||||
pending *pendingWindowTitle
|
||||
timer *time.Timer
|
||||
due <-chan time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
windowTitleChangeDelay = 25 * time.Millisecond
|
||||
windowTitleIntroductionDelay = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
func (titles *windowTitleController) clearPending() {
|
||||
if titles.timer != nil {
|
||||
titles.timer.Stop()
|
||||
}
|
||||
titles.timer, titles.due, titles.pending = nil, nil, nil
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) reset() {
|
||||
titles.clearPending()
|
||||
titles.lastTitle, titles.application = "", ""
|
||||
titles.lastNative = atspi.ForegroundTitle{}
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) activated(event atspi.Event) {
|
||||
titles.lastTitle = titles.state.owner.title
|
||||
// XTerm activation is consumed by the Fenrir handoff before presentation.
|
||||
// Its native title still needs an introduction, even if AT-SPI named it.
|
||||
if isXTermSnapshot(event.Source) {
|
||||
titles.lastTitle = ""
|
||||
}
|
||||
titles.application = titles.applicationForEvent(event)
|
||||
if titles.pending != nil && titles.pending.native.Window == 0 {
|
||||
titles.clearPending()
|
||||
}
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) applicationForEvent(event atspi.Event) string {
|
||||
if titles.source != nil {
|
||||
native := titles.source.ForegroundTitle()
|
||||
if native.Epoch == event.Foreground.Epoch && native.Title.Window != 0 &&
|
||||
native.Title.TitleWindow != native.Title.Window && native.Title.Application != "" {
|
||||
return native.Title.Application
|
||||
}
|
||||
}
|
||||
if event.Source.ApplicationName != "" {
|
||||
return event.Source.ApplicationName
|
||||
}
|
||||
return event.Source.ApplicationProcess
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) queue(pending pendingWindowTitle) {
|
||||
if pending.observedAt.IsZero() {
|
||||
pending.observedAt = time.Now()
|
||||
}
|
||||
if previous := titles.pending; previous != nil && !previous.changed && previous.epoch == pending.epoch &&
|
||||
previous.window == pending.window && previous.native.Window == pending.native.Window &&
|
||||
previous.native.TitleWindow == pending.native.TitleWindow {
|
||||
pending.changed = false
|
||||
}
|
||||
titles.pending = &pending
|
||||
// Keep the first deadline: continuous progress must not postpone speech
|
||||
// forever. Each burst speaks its latest value rather than every update.
|
||||
if titles.timer == nil {
|
||||
delay := windowTitleIntroductionDelay
|
||||
if pending.changed {
|
||||
delay = windowTitleChangeDelay
|
||||
}
|
||||
titles.timer = time.NewTimer(delay)
|
||||
titles.due = titles.timer.C
|
||||
}
|
||||
}
|
||||
|
||||
func isWindowTitleEvent(event atspi.Event) bool {
|
||||
return event.Type == atspi.AccessibleNameEvent &&
|
||||
(event.Source.Role == "frame" || event.Source.Role == "window" || event.Source.Role == "dialog")
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) accessible(event atspi.Event) bool {
|
||||
if !isWindowTitleEvent(event) {
|
||||
return false
|
||||
}
|
||||
if event.Source.ID() == titles.state.owner.window && strings.TrimSpace(event.Source.Name) == "" {
|
||||
titles.state.owner.changeTitle(event)
|
||||
titles.clearPending()
|
||||
titles.lastTitle = ""
|
||||
return true
|
||||
}
|
||||
if !titles.state.owner.changeTitle(event) {
|
||||
return true
|
||||
}
|
||||
titles.queue(pendingWindowTitle{text: strings.TrimSpace(event.Source.Name),
|
||||
window: titles.state.owner.window, epoch: titles.state.foregroundState.Epoch,
|
||||
changed: true, application: titles.application})
|
||||
return true
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) nativeChanged() {
|
||||
latest := titles.source.ForegroundTitle()
|
||||
if latest == titles.lastNative {
|
||||
return
|
||||
}
|
||||
previous := titles.lastNative
|
||||
titles.lastNative = latest
|
||||
if latest.Title.Window == 0 {
|
||||
titles.reset()
|
||||
return
|
||||
}
|
||||
switched := latest.Epoch != previous.Epoch || latest.Title.Window != previous.Title.Window ||
|
||||
latest.Title.TitleWindow != previous.Title.TitleWindow
|
||||
if switched {
|
||||
titles.clearPending()
|
||||
// An AT-SPI activation may already have introduced this client.
|
||||
titles.lastTitle = titles.state.owner.title
|
||||
if strings.EqualFold(latest.Title.Application, "xterm") {
|
||||
titles.lastTitle = ""
|
||||
}
|
||||
}
|
||||
if latest.Title.Title == "" {
|
||||
titles.clearPending()
|
||||
titles.lastTitle = ""
|
||||
return
|
||||
}
|
||||
changed := !switched
|
||||
// AT-SPI can introduce a window before its first X11 title sample. A
|
||||
// later differing sample is still a title change, not a new activation.
|
||||
if switched && titles.state.owner.activated && titles.state.owner.title != "" &&
|
||||
latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") {
|
||||
changed = true
|
||||
}
|
||||
titles.queue(pendingWindowTitle{text: latest.Title.Title, epoch: latest.Epoch,
|
||||
native: latest.Title, changed: changed})
|
||||
}
|
||||
|
||||
func equivalentWindowTitles(first, second string) bool {
|
||||
normalize := func(value string) string { return strings.Join(strings.Fields(strings.ToLower(value)), " ") }
|
||||
first, second = normalize(first), normalize(second)
|
||||
if first == "" || second == "" {
|
||||
return false
|
||||
}
|
||||
// Compare full titles: a changed status suffix is meaningful information.
|
||||
return first == second
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) enabled() bool {
|
||||
return titles.live == nil || titles.live.Load().Reading.AutomaticallyReadTitleChanges
|
||||
}
|
||||
|
||||
func (titles *windowTitleController) flush() {
|
||||
pending := titles.pending
|
||||
titles.clearPending()
|
||||
if pending == nil || titles.gate.Locked() {
|
||||
return
|
||||
}
|
||||
// Only a native foreground introduction may cross the terminal handoff.
|
||||
// Content and subsequent title changes remain Fenrir's responsibility.
|
||||
xtermIntroduction := !pending.changed && pending.native.Window != 0 &&
|
||||
strings.EqualFold(pending.native.Application, "xterm")
|
||||
if titles.gate.Suspended() && !xtermIntroduction {
|
||||
return
|
||||
}
|
||||
if titles.foreground != nil && pending.epoch != titles.foreground.Foreground().Epoch {
|
||||
return
|
||||
}
|
||||
if pending.native.Window != 0 {
|
||||
latest := titles.source.ForegroundTitle()
|
||||
if latest.Epoch != pending.epoch || latest.Title != pending.native {
|
||||
return
|
||||
}
|
||||
pending.window = titles.state.owner.window
|
||||
pending.application = titles.application
|
||||
if !pending.window.Valid() || pending.native.TitleWindow != pending.native.Window {
|
||||
pending.application = pending.native.Application
|
||||
}
|
||||
} else if pending.window != titles.state.owner.window {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(pending.application), "cthulhu") {
|
||||
pending.application = ""
|
||||
}
|
||||
if pending.application != "" && titles.live != nil && titles.live.onApplication != nil {
|
||||
titles.live.onApplication(pending.application)
|
||||
// Application switching is asynchronous. Wait for its committed
|
||||
// settings before reading a title, including Wine without AT-SPI.
|
||||
applied := titles.live.application.Load()
|
||||
if applied == nil || *applied != pending.application {
|
||||
titles.queue(*pending)
|
||||
return
|
||||
}
|
||||
}
|
||||
if pending.changed && !titles.enabled() {
|
||||
titles.lastTitle = pending.text
|
||||
return
|
||||
}
|
||||
if equivalentWindowTitles(titles.lastTitle, pending.text) {
|
||||
return
|
||||
}
|
||||
titles.lastTitle = pending.text
|
||||
if titles.logger != nil {
|
||||
titles.logger.Debug("queued automatic window title", "title_changed", pending.changed,
|
||||
"observed_to_queue_ms", time.Since(pending.observedAt).Milliseconds())
|
||||
}
|
||||
titles.speech.Enqueue(speechRequest{Text: pending.text, Role: "system", Cause: "window-title",
|
||||
Window: pending.window, Append: true, BypassFocusGate: xtermIntroduction,
|
||||
ExcludeFromHistory: titles.state.historyWindow.Valid() && pending.window == titles.state.historyWindow})
|
||||
}
|
||||
Reference in new issue
Block a user