toastQueue.wake kept state only tests read. Toaster now reports the frame's wake time in an unexported ToasterResult field, and TestToastExpiresAndHoverPauses asserts on the returned value.
273 lines
7.0 KiB
Go
273 lines
7.0 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
|
|
wake time.Time // when this frame asked for the next one; zero for none
|
|
}
|
|
|
|
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")
|
|
}
|
|
_ = m.Severity.String() // panics on an unknown Severity
|
|
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 shows queued ones.
|
|
func (q *toastQueue) expire(now time.Time) {
|
|
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)
|
|
}
|
|
|
|
// settle pauses the toasts held, resumes the rest, and returns the wait until one expires.
|
|
func (q *toastQueue) settle(now time.Time, held map[string]bool) (next time.Duration) {
|
|
for _, it := range q.items {
|
|
if it.visible {
|
|
q.pause(it.msg.ID, held[it.msg.ID], 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, never taking the focus; call it last in the view each frame.
|
|
func Toaster(c *ui.Context) ToasterResult {
|
|
q := toasts(c)
|
|
now := c.Now()
|
|
q.expire(now)
|
|
held := map[string]bool{}
|
|
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, hold := toastCard(c, k, u, min(360, w-u*8), it.msg)
|
|
held[it.msg.ID] = hold
|
|
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()
|
|
}
|
|
}
|
|
// Scheduled after resuming, so a toast released this frame still expires.
|
|
if next := q.settle(now, held); next > 0 {
|
|
res.wake = now.Add(next)
|
|
c.After(next)
|
|
}
|
|
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()
|
|
}
|