Graphical terminal improvements.

It makes using programs who like to refresh a ton much more tollerable
when I'm not in Emacs; I suspect others will like them too.
This commit is contained in:
hjozwiak committed 2026-11-04 04:05:19 -05:00
1 parent 1fcf7e6f87
commit 00dbb1fea7
12 files changed
+608 -14

No files matched your search

+72
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"strings"
"time"
"unicode"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
@@ -46,6 +47,7 @@ type windowTitleController struct {
inputSource any
lastNative atspi.ForegroundTitle
lastTitle string
recent map[string]time.Time
application string
pending *pendingWindowTitle
timer *time.Timer
@@ -55,6 +57,10 @@ type windowTitleController struct {
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() {
@@ -64,6 +70,10 @@ func (titles *windowTitleController) clearPending() {
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 = "", ""
@@ -140,6 +150,11 @@ func (titles *windowTitleController) accessible(event atspi.Event) bool {
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
@@ -171,6 +186,16 @@ func (titles *windowTitleController) nativeChanged() {
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. Genuine window switches clear it through
// AT-SPI activation instead.
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) {
@@ -200,6 +225,46 @@ func (titles *windowTitleController) nativeChanged() {
native: latest.Title, changed: changed})
}
// animatedWindowTitleKey reduces a title to the part that carries meaning
// across animation frames. A leading field with no letter or digit is a
// spinner frame, not content; a leading count stays, since it can change
// meaningfully. 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 && strings.IndexFunc(fields[0], func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}) < 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(text string, at time.Time) bool {
key := animatedWindowTitleKey(text)
if key == "" {
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[string]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)
@@ -327,6 +392,13 @@ func (titles *windowTitleController) flush() {
if pending.inputSequence != recentInput(titles.inputSource).TitleInterruptionSequence {
return
}
if pending.changed && titles.repeatedWithinWindow(pending.text, pending.observedAt) {
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())