Files
MujicaUI/toast.go
T
2026-10-05 12:11:45 -04:00

258 lines
6.6 KiB
Go

package mujica
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// Toast timing and stacking per window.
const (
ToastDuration = 5 * time.Second
ToastVisible = 3
)
// ToastMessage is one toast. Message is required.
type ToastMessage struct {
// ID dedupes: showing an ID already queued replaces it. Empty uses Message.
ID string
Severity Severity
Message string
// Action labels a button; Toaster reports its click by ID.
Action string
// Duration is ToastDuration when zero; errors then stay until closed.
Duration time.Duration
}
// ToasterResult reports the toast events of a frame by message ID.
type ToasterResult struct {
Action string
Closed string
}
type toastItem struct {
msg ToastMessage
life time.Duration // zero stays until closed
spent time.Duration
runFrom time.Time // zero while paused or queued
visible bool
announced bool
}
// toastQueue holds a window's toasts, visible ones first in order shown.
type toastQueue struct{ items []toastItem }
type toastKey struct{}
func toastLife(m ToastMessage) time.Duration {
switch {
case m.Duration < 0:
panic("mujica: negative toast Duration")
case m.Duration > 0:
return m.Duration
case m.Severity == SeverityError:
return 0
}
return ToastDuration
}
func (q *toastQueue) push(m ToastMessage, now time.Time) {
if m.Message == "" {
panic("mujica: toast needs a Message")
}
if m.ID == "" {
m.ID = m.Message
}
for i := range q.items {
it := &q.items[i]
if it.msg.ID != m.ID {
continue
}
it.msg, it.life, it.spent, it.announced = m, toastLife(m), 0, false
if it.visible && !it.runFrom.IsZero() {
it.runFrom = now
}
slog.Debug("mujica: toast replaced", "id", m.ID)
return
}
q.items = append(q.items, toastItem{msg: m, life: toastLife(m)})
slog.Debug("mujica: toast queued", "id", m.ID)
q.promote(now)
}
// promote shows queued toasts while fewer than ToastVisible show.
func (q *toastQueue) promote(now time.Time) {
n := 0
for i := range q.items {
it := &q.items[i]
if !it.visible && n < ToastVisible {
it.visible, it.runFrom = true, now
slog.Debug("mujica: toast shown", "id", it.msg.ID)
}
if it.visible {
n++
}
}
}
func (it *toastItem) elapsed(now time.Time) time.Duration {
if it.runFrom.IsZero() {
return it.spent
}
return it.spent + now.Sub(it.runFrom)
}
// expire drops toasts whose time ran out and returns the wait until the next does.
func (q *toastQueue) expire(now time.Time) (next time.Duration) {
live := q.items[:0]
for _, it := range q.items {
if it.visible && it.life > 0 && it.elapsed(now) >= it.life {
slog.Debug("mujica: toast expired", "id", it.msg.ID)
continue
}
live = append(live, it)
}
q.items = live
q.promote(now)
for _, it := range q.items {
if it.visible && it.life > 0 && !it.runFrom.IsZero() {
left := it.life - it.elapsed(now)
if next == 0 || left < next {
next = left
}
}
}
return next
}
func (q *toastQueue) pause(id string, paused bool, now time.Time) {
for i := range q.items {
it := &q.items[i]
if it.msg.ID != id || !it.visible {
continue
}
switch {
case paused && !it.runFrom.IsZero():
it.spent, it.runFrom = it.elapsed(now), time.Time{}
slog.Debug("mujica: toast paused", "id", id)
case !paused && it.runFrom.IsZero():
it.runFrom = now
slog.Debug("mujica: toast resumed", "id", id)
}
}
}
func (q *toastQueue) remove(id string, now time.Time) {
for i, it := range q.items {
if it.msg.ID == id {
q.items = append(q.items[:i], q.items[i+1:]...)
break
}
}
q.promote(now)
}
func toasts(c *ui.Context) *toastQueue {
return ui.Local(c.Root(), toastKey{}, func() toastQueue { return toastQueue{} })
}
// ShowToast queues m in the window. Call it on the UI thread.
func ShowToast(c *ui.Context, m ToastMessage) {
toasts(c).push(m, c.Now())
c.Invalidate()
}
// DismissToast closes the toast with id, shown or queued.
func DismissToast(c *ui.Context, id string) {
toasts(c).remove(id, c.Now())
c.Invalidate()
}
// Toaster draws the window's toasts at its bottom right. Call it once per
// frame, last in the view. Toasts never take the focus; Tab reaches their
// buttons. Hover or focus inside a toast pauses its time.
func Toaster(c *ui.Context) ToasterResult {
q := toasts(c)
now := c.Now()
if next := q.expire(now); next > 0 {
c.After(next)
}
var res ToasterResult
if len(q.items) == 0 {
return res
}
k := Tokens(c)
u := Density(c).Unit()
w, _ := c.Size()
ui.Overlay(c, func() {
stack := ui.Column(c).Absolute().Right(u * 4).Bottom(u * 4).Gap(u * 2).AlignItems(ui.End).
PassThrough().Role(ui.RoleGroup).Label(msg(c, "toast.region"))
stack.Children(func() {
for i := range q.items {
it := q.items[i]
if !it.visible {
continue
}
if !it.announced {
q.items[i].announced = true
c.Announce(it.msg.Message)
}
action, closed, held := toastCard(c, k, u, min(360, w-u*8), it.msg)
q.pause(it.msg.ID, held, now)
switch {
case action:
res.Action = it.msg.ID
slog.Debug("mujica: toast action", "id", it.msg.ID)
case closed:
res.Closed = it.msg.ID
slog.Debug("mujica: toast closed", "id", it.msg.ID)
}
}
})
})
for _, id := range []string{res.Action, res.Closed} {
if id != "" {
q.remove(id, now)
c.Invalidate()
}
}
return res
}
// toastCard builds one toast and reports its events and whether hover or focus holds it.
func toastCard(c *ui.Context, k theme.Tokens, u, width float32, m ToastMessage) (action, closed, held bool) {
tone, icon := severityLook(k, m.Severity)
card := ui.Row(c).Key(m.ID).Width(width).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
Role(ui.RoleStatus).Label(m.Message)
card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14))
card.Transition(ui.ElementTransition{
Duration: theme.PopupDuration,
Enter: &ui.Motion{Y: 8},
Exit: &ui.Motion{},
})
card.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + theme.PopupRadius, W: 3, H: r.H - 2*theme.PopupRadius}, tone, 0)
})
card.Children(func() {
ui.Icon(c, icon).Size(20, 20).TextColor(tone)
ui.Text(c, m.Message).TextColor(k.Text).Grow(1).Shrink(1).LineHeight(theme.LineHeight)
if m.Action != "" && Button(c, m.Action, ButtonOptions{Variant: Ghost}).Clicked() {
action = true
}
x := Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Label: msg(c, "toast.close")})
x.TextColor(k.TextMuted).Padding(0, u)
if x.Clicked() {
closed = true
}
})
if card.Shortcut(0, ui.KeyEscape) {
closed = true
}
return action, closed, card.Hovered() || card.FocusWithin()
}