Add popup notifications, log view (F5) and context-sensitive key hints (M9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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--
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user