Files
arr-tui/internal/ui/notify.go
T
nilsandClaude Opus 5.5 820b17842b Fixed dark and light themes, ui.theme auto/dark/light
Colors no longer come from the terminal's palette, whose color 0 many
color themes set to a grey that is not their background. A theme sets
tview's default styles and the color names used in texts, all chosen from
entries 16-255 of the 256-color table so terminals without 24-bit color
show them exactly. "auto" picks light or dark from the background color
the terminal reports. Settable in the Config view, applied at the next
start (widgets copy their colors when created). Replaces using the
terminal's own background and text colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:05:00 +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(current.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--
}
}