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
162 lines
3.8 KiB
Go
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--
|
|
}
|
|
}
|