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 } // 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 = 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-- } }