Files
cthulhu/internal/app/window_titles.go

267 lines
8.8 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/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})
}