Files
arr-tui/internal/ui/notify.go
T

156 lines
3.6 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 [...]tcell.Color{tcell.ColorWhite, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorRed}[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
}
func (r *toastRoot) Draw(screen tcell.Screen) {
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 = tcell.ColorGray // fade out
}
style := tcell.StyleDefault.Background(tcell.NewRGBColor(30, 30, 40)).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--
}
}