Preserve meaningful title status symbols during spinner suppression and keep testing version metadata.
432 lines
15 KiB
Go
432 lines
15 KiB
Go
// 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/input"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"git.stormux.org/storm/cthulhu/internal/wine"
|
|
)
|
|
|
|
type windowTitleSource interface {
|
|
ForegroundTitle() atspi.ForegroundTitle
|
|
TitleChanges() <-chan struct{}
|
|
}
|
|
|
|
type pendingWindowTitle struct {
|
|
text string
|
|
previousText string
|
|
window atspi.ObjectID
|
|
epoch uint64
|
|
native xorg.WindowTitle
|
|
changed bool
|
|
application string
|
|
observedAt time.Time
|
|
inputSequence uint64
|
|
}
|
|
|
|
type windowTitleRepeatKey struct {
|
|
window atspi.ObjectID
|
|
nativeWindow, titleWindow uint64
|
|
text string
|
|
}
|
|
|
|
// 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
|
|
inputSource any
|
|
lastNative atspi.ForegroundTitle
|
|
lastTitle string
|
|
recent map[windowTitleRepeatKey]time.Time
|
|
application string
|
|
pending *pendingWindowTitle
|
|
timer *time.Timer
|
|
due <-chan time.Time
|
|
}
|
|
|
|
const (
|
|
windowTitleChangeDelay = 25 * time.Millisecond
|
|
windowTitleIntroductionDelay = 250 * time.Millisecond
|
|
// Animated titles (terminal TUI spinners) cycle through the same set
|
|
// of frames about once a second. A title already announced within
|
|
// this window is another cycle, not a new title.
|
|
windowTitleRepeatWindow = 3 * time.Second
|
|
)
|
|
|
|
func (titles *windowTitleController) clearPending() {
|
|
if titles.timer != nil {
|
|
titles.timer.Stop()
|
|
}
|
|
titles.timer, titles.due, titles.pending = nil, nil, nil
|
|
}
|
|
|
|
// The animated-title repeat history (titles.recent) deliberately
|
|
// survives reset and activation: it expires by time on its own, and a
|
|
// workspace round-trip away from a window with an animated title must
|
|
// not restart its announcement cycle on return.
|
|
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()
|
|
pending.inputSequence = recentInput(titles.inputSource).TitleInterruptionSequence
|
|
}
|
|
if titles.pending != nil {
|
|
// Coalescing must not revive a burst interrupted since its first sample.
|
|
pending.inputSequence = titles.pending.inputSequence
|
|
// Compare the whole burst so an intervening status change cannot
|
|
// disappear behind a later title which mirrors an edit.
|
|
pending.previousText = titles.pending.previousText
|
|
}
|
|
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 = ""
|
|
// Keep the repeat history: animated title cycles include a
|
|
// blank frame every cycle (observed from terminal TUIs), and
|
|
// clearing here would restart the cycle announcement each
|
|
// second. A title restored after a genuine removal announces
|
|
// again once the repeat window expires.
|
|
return true
|
|
}
|
|
previousTitle := titles.state.owner.title
|
|
if !titles.state.owner.changeTitle(event) {
|
|
return true
|
|
}
|
|
titles.queue(pendingWindowTitle{text: strings.TrimSpace(event.Source.Name),
|
|
previousText: previousTitle,
|
|
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
|
|
// Wine review establishes navigation ownership without speaking an
|
|
// activation. Its stored title is metadata, not an announced title.
|
|
accessibleActivation := titles.state.owner.activated && !wine.IsReviewID(titles.state.owner.window)
|
|
if switched {
|
|
titles.clearPending()
|
|
// Keep the repeat history: a native sampler can re-report the
|
|
// same client as a switch (new epoch or title-window id) on
|
|
// every title sample, and clearing here would defeat animated
|
|
// title suppression. History is scoped to the window identity,
|
|
// so another window can still announce the same title.
|
|
if titles.logger != nil {
|
|
titles.logger.Debug("native window title switch",
|
|
"epoch", latest.Epoch, "window", latest.Title.Window,
|
|
"title_window", latest.Title.TitleWindow)
|
|
}
|
|
// An AT-SPI activation may already have introduced this client.
|
|
titles.lastTitle = titles.state.owner.title
|
|
if wine.IsReviewID(titles.state.owner.window) {
|
|
titles.lastTitle = ""
|
|
}
|
|
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 && accessibleActivation && titles.state.owner.title != "" &&
|
|
latest.Title.TitleWindow == latest.Title.Window && !strings.EqualFold(latest.Title.Application, "xterm") {
|
|
changed = true
|
|
}
|
|
previousTitle := previous.Title.Title
|
|
if switched {
|
|
previousTitle = ""
|
|
}
|
|
titles.queue(pendingWindowTitle{text: latest.Title.Title, previousText: previousTitle, epoch: latest.Epoch,
|
|
native: latest.Title, changed: changed})
|
|
}
|
|
|
|
// animatedWindowTitleKey reduces a title to the part that carries meaning
|
|
// across animation frames. Only recognized, single-character spinner frames
|
|
// are removed; symbols such as a check or cross can carry meaningful status.
|
|
// Announcement suppression uses this only to detect cycles; the spoken text
|
|
// remains the original title.
|
|
func animatedWindowTitleKey(title string) string {
|
|
fields := strings.Fields(strings.ToLower(title))
|
|
if len(fields) > 1 {
|
|
frame := []rune(fields[0])
|
|
if len(frame) == 1 && (frame[0] >= 0x2800 && frame[0] <= 0x28ff || strings.ContainsRune("|/-\\", frame[0])) {
|
|
fields = fields[1:]
|
|
}
|
|
}
|
|
return strings.Join(fields, " ")
|
|
}
|
|
|
|
// repeatedWithinWindow reports whether this title was already announced in
|
|
// the repeat window. A suppressed sighting refreshes its entry, so a
|
|
// running animation stays suppressed instead of breaking through once per
|
|
// window; it expires shortly after the animation stops.
|
|
func (titles *windowTitleController) repeatedWithinWindow(pending pendingWindowTitle, at time.Time) bool {
|
|
key := windowTitleRepeatKey{window: pending.window, text: animatedWindowTitleKey(pending.text)}
|
|
if key.text == "" {
|
|
return false
|
|
}
|
|
// Correlate AT-SPI and native samples through the same native client
|
|
// when available. Do not include its epoch: returning to that client
|
|
// should retain its animation history.
|
|
native := pending.native
|
|
if native.Window == 0 && titles.source != nil {
|
|
latest := titles.source.ForegroundTitle()
|
|
if latest.Epoch == pending.epoch {
|
|
native = latest.Title
|
|
}
|
|
}
|
|
if native.Window != 0 {
|
|
key.window = atspi.ObjectID{}
|
|
key.nativeWindow, key.titleWindow = native.Window, native.TitleWindow
|
|
} else if !key.window.Valid() {
|
|
return false
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
for seen, when := range titles.recent {
|
|
if at.Sub(when) > windowTitleRepeatWindow {
|
|
delete(titles.recent, seen)
|
|
}
|
|
}
|
|
if titles.recent == nil {
|
|
titles.recent = make(map[windowTitleRepeatKey]time.Time)
|
|
}
|
|
_, repeated := titles.recent[key]
|
|
titles.recent[key] = at
|
|
return repeated
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Explicit requests bypass automatic-title preferences and duplicate suppression.
|
|
// Read copied foreground metadata only; never block the event loop on native I/O
|
|
// or move either the browser cursor or object focus.
|
|
func (titles *windowTitleController) present() {
|
|
if titles.gate.Locked() || titles.gate.Suspended() {
|
|
return
|
|
}
|
|
foreground := titles.state.foregroundState
|
|
if titles.foreground != nil && titles.foreground.Foreground().Epoch != foreground.Epoch {
|
|
return
|
|
}
|
|
titles.clearPending()
|
|
window := titles.state.owner.window
|
|
text := ""
|
|
if titles.source != nil {
|
|
latest := titles.source.ForegroundTitle()
|
|
if latest.Epoch == foreground.Epoch && latest.Title.Window != 0 && latest.Title.Window == foreground.Client.Window {
|
|
text = strings.TrimSpace(latest.Title.Title)
|
|
}
|
|
}
|
|
if text == "" && window.Valid() {
|
|
text = strings.TrimSpace(titles.state.owner.title)
|
|
}
|
|
if text == "" {
|
|
text = "Could not find current window title."
|
|
} else {
|
|
titles.lastTitle = text
|
|
}
|
|
titles.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "present-window-title",
|
|
Window: window, ExcludeFromHistory: titles.state.historyWindow.Valid() && window == titles.state.historyWindow})
|
|
}
|
|
|
|
// repeats_focused_edit recognizes titles whose only changed portion mirrors
|
|
// the owned entry. Use copied focus and input evidence, never application names
|
|
// or field labels. Window introductions and unrelated status changes still speak.
|
|
func (titles *windowTitleController) repeats_focused_edit(previousTitle, title string) bool {
|
|
current := titles.state.current
|
|
if previousTitle == "" || !current.Editable || current.ReadOnly || !current.Focused ||
|
|
!titles.state.focusedID.Valid() || current.ID() != titles.state.focusedID ||
|
|
titles.state.owner.focus != current.ID() || current.BusName != titles.state.owner.window.BusName {
|
|
return false
|
|
}
|
|
activity := recentInput(titles.inputSource)
|
|
if activity.Intent != input.TypingIntent && activity.Intent != input.DeleteIntent &&
|
|
activity.Clipboard != input.CutAction && activity.Clipboard != input.PasteAction {
|
|
return false
|
|
}
|
|
text := strings.TrimSpace(current.Text)
|
|
if text == "" || strings.Count(title, text) != 1 {
|
|
return false
|
|
}
|
|
start := strings.Index(title, text)
|
|
prefix, suffix := title[:start], title[start+len(text):]
|
|
return len(previousTitle) >= len(prefix)+len(suffix) &&
|
|
strings.HasPrefix(previousTitle, prefix) && strings.HasSuffix(previousTitle, suffix)
|
|
}
|
|
|
|
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() || wine.IsReviewID(pending.window) || 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 pending.changed && titles.repeats_focused_edit(pending.previousText, pending.text) {
|
|
return
|
|
}
|
|
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
|
|
return
|
|
}
|
|
if pending.changed && titles.repeatedWithinWindow(*pending, time.Now()) {
|
|
if titles.logger != nil {
|
|
titles.logger.Debug("suppressed repeated window title",
|
|
"key", animatedWindowTitleKey(pending.text), "recent", len(titles.recent))
|
|
}
|
|
return
|
|
}
|
|
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})
|
|
}
|