Files
arr-tui/internal/ui/notify.go
T
nilsandClaude Opus 5.5 67f5cf6ce7 Switch the theme live
Widgets copy their colors when they are created, so they now get role
tokens (one unique RGB value per role) instead of colors, and a wrapper
around the tcell screen replaces the tokens with the current theme's
colors in SetContent. Changing the theme in the Config view swaps that
table; the next draw recolors everything. Poster colors that equal a
token are nudged off it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:11:19 +02:00

162 lines
3.8 KiB
Go

package ui
import (
"regexp"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type level int
const (
levelInfo level = iota
levelSuccess
levelWarn
levelError
)
func (l level) String() string {
return [...]string{"INFO", "OK", "WARN", "ERROR"}[l]
}
func (l level) color() tcell.Color {
return named([...]string{"white", "green", "yellow", "red"}[l])
}
// how long a toast stays on screen, and how long the end of that time fades.
func (l level) lifetime() time.Duration {
return [...]time.Duration{3 * time.Second, 3 * time.Second, 5 * time.Second, 8 * time.Second}[l]
}
const (
toastFade = time.Second
maxToasts = 4
)
type toast struct {
text string
level level
expires time.Time
}
// progress toasts ("Searching…") are replaced by the next message.
func (t toast) progress() bool { return strings.HasSuffix(t.text, "…") }
var (
tagRe = regexp.MustCompile(`\[([^\[\]]*)\[\]|\[[^\[\]]*\]`)
levelPrefix = []struct {
tag string
level level
}{{"[red]", levelError}, {"[green]", levelSuccess}, {"[yellow]", levelWarn}}
)
// plainText removes tview color tags and undoes tview.Escape.
func plainText(s string) string {
return tagRe.ReplaceAllStringFunc(s, func(m string) string {
if strings.HasSuffix(m, "[]") {
return m[:len(m)-2] + "]"
}
return ""
})
}
// SetMessage reports something to the user: as a short-lived popup and, unless
// it is only progress ("Adding…"), as an entry in the log. A leading [red],
// [yellow] or [green] tag selects the level; an empty text dismisses progress
// popups. UI goroutine only; use post from other goroutines.
func (a *App) SetMessage(text string) {
if text == "" {
a.dropProgress()
return
}
lvl := levelInfo
for _, p := range levelPrefix {
if strings.HasPrefix(text, p.tag) {
lvl, text = p.level, strings.TrimPrefix(text, p.tag)
break
}
}
a.Notify(lvl, plainText(text))
}
// Notify shows a popup and logs the message. UI goroutine only.
func (a *App) Notify(l level, text string) {
t := toast{text: text, level: l, expires: time.Now().Add(l.lifetime())}
a.dropProgress()
if !t.progress() {
a.logs.add(l, text)
}
a.toasts = append(a.toasts, t)
if len(a.toasts) > maxToasts {
a.toasts = a.toasts[len(a.toasts)-maxToasts:]
}
// Redraw when the fade starts and when the toast is gone.
for _, d := range []time.Duration{l.lifetime() - toastFade, l.lifetime() + 50*time.Millisecond} {
time.AfterFunc(d, func() { a.post(func() {}) })
}
}
func (a *App) dropProgress() {
kept := a.toasts[:0]
for _, t := range a.toasts {
if !t.progress() {
kept = append(kept, t)
}
}
a.toasts = kept
}
// toastRoot draws the popups above the whole interface.
type toastRoot struct {
*tview.Flex
app *App
}
// narrowWidth is the terminal width below which the six tabs and the full
// service names no longer fit on one line.
const narrowWidth = 92
func (r *toastRoot) Draw(screen tcell.Screen) {
w, _ := screen.Size()
r.app.setNarrow(w < narrowWidth)
r.Flex.Draw(screen)
a := r.app
now := time.Now()
kept := a.toasts[:0]
for _, t := range a.toasts {
if now.Before(t.expires) {
kept = append(kept, t)
}
}
a.toasts = kept
w, h := screen.Size()
y := h - 3 // above the status bar
for i := len(a.toasts) - 1; i >= 0 && y >= 1; i-- {
t := a.toasts[i]
text := " " + t.text + " "
width := min(tview.TaggedStringWidth(text), w-2)
x := w - width - 1
fg := t.level.color()
if t.expires.Sub(now) < toastFade {
fg = named("gray") // fade out
}
style := tcell.StyleDefault.Background(tokens.toast).Foreground(fg)
for cx := x; cx < x+width; cx++ {
screen.SetContent(cx, y, ' ', nil, style)
}
col := x
for _, ch := range text {
if col >= x+width {
break
}
screen.SetContent(col, y, ch, nil, style)
col += tview.TaggedStringWidth(string(ch))
}
y--
}
}