// 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" "git.stormux.org/storm/cthulhu/internal/wine" ) 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 inputSequence uint64 } // 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 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() 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 } 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 // 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() // 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 } 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() || 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.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence { 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}) }