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() }