// 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}) }