1package ui
2
3import (
4 "time"
5
6 "github.com/bnema/vev/internal/domain"
7)
8
9// ToastQueueOptions bound one ToastQueue. Zero values select the defaults.
10type ToastQueueOptions struct {
11 // MaxVisible is how many toasts show at once (default 3).
12 MaxVisible int
13 // MaxPending is how many toasts wait for a free slot (default 32). When
14 // full, the oldest waiting toast is dropped.
15 MaxPending int
16}
17
18const (
19 defaultQueueVisible = 3
20 defaultQueuePending = 32
21)
22
23// QueuedToast is one entry of a ToastQueue. Value is the caller's payload.
24type QueuedToast[T any] struct {
25 // ID is the coalescing identity: pushing an ID that is visible or waiting
26 // replaces that entry. An empty ID never matches.
27 ID string
28 Value T
29 // Duration is how long the entry stays visible; zero never expires.
30 Duration time.Duration
31 // ShownAt is when the entry became visible. Push sets it.
32 ShownAt time.Time
33}
34
35// ToastQueue shows a bounded number of toasts and queues the rest, so a burst
36// is displayed one slot at a time instead of being dropped or overdrawn.
37// Time comes only
38// from the caller, so the queue is deterministic; callers serialize access.
39type ToastQueue[T any] struct {
40 opts ToastQueueOptions
41 visible []QueuedToast[T] // newest first
42 pending []QueuedToast[T] // oldest first
43}
44
45// NewToastQueue returns an empty queue with opts, defaults filled in.
46func NewToastQueue[T any](opts ToastQueueOptions) *ToastQueue[T] {
47 if opts.MaxVisible <= 0 {
48 opts.MaxVisible = defaultQueueVisible
49 }
50 if opts.MaxPending <= 0 {
51 opts.MaxPending = defaultQueuePending
52 }
53 return &ToastQueue[T]{opts: opts}
54}
55
56// Push shows toast at now when a slot is free, otherwise queues it. A visible
57// entry with the same ID is replaced, moved to the front, and restarts its
58// lifetime; a waiting one is replaced where it waits.
59func (q *ToastQueue[T]) Push(now time.Time, toast QueuedToast[T]) {
60 q.expire(now)
61 toast.ShownAt = now
62 if i := q.indexVisible(toast.ID); i >= 0 {
63 q.visible = append(q.visible[:i], q.visible[i+1:]...)
64 q.visible = append([]QueuedToast[T]{toast}, q.visible...)
65 return
66 }
67 if i := q.indexPending(toast.ID); i >= 0 {
68 q.pending[i] = toast
69 return
70 }
71 if len(q.visible) < q.opts.MaxVisible {
72 q.visible = append([]QueuedToast[T]{toast}, q.visible...)
73 return
74 }
75 if len(q.pending) >= q.opts.MaxPending {
76 q.pending = q.pending[1:]
77 }
78 q.pending = append(q.pending, toast)
79}
80
81// Get returns the visible or waiting entry with id.
82func (q *ToastQueue[T]) Get(id string) (QueuedToast[T], bool) {
83 if i := q.indexVisible(id); i >= 0 {
84 return q.visible[i], true
85 }
86 if i := q.indexPending(id); i >= 0 {
87 return q.pending[i], true
88 }
89 return QueuedToast[T]{}, false
90}
91
92// Visible expires entries due at now, promotes waiting ones into the freed
93// slots, and returns the displayed entries newest first.
94func (q *ToastQueue[T]) Visible(now time.Time) []QueuedToast[T] {
95 q.expire(now)
96 return append([]QueuedToast[T](nil), q.visible...)
97}
98
99// Peek returns the displayed entries newest first without expiring any, for
100// readers that must not change the queue, such as a render snapshot.
101func (q *ToastQueue[T]) Peek() []QueuedToast[T] {
102 return append([]QueuedToast[T](nil), q.visible...)
103}
104
105// Pending reports how many entries wait for a slot.
106func (q *ToastQueue[T]) Pending() int { return len(q.pending) }
107
108// NextDeadline is the earliest instant a visible entry expires.
109func (q *ToastQueue[T]) NextDeadline() (time.Time, bool) {
110 var next time.Time
111 found := false
112 for _, t := range q.visible {
113 if t.Duration <= 0 {
114 continue
115 }
116 due := t.ShownAt.Add(t.Duration)
117 if !found || due.Before(next) {
118 next, found = due, true
119 }
120 }
121 return next, found
122}
123
124// Dismiss removes the visible or waiting entry with id and fills a
125// freed slot at now. It reports whether an entry was removed.
126func (q *ToastQueue[T]) Dismiss(now time.Time, id string) bool {
127 removed := false
128 if i := q.indexVisible(id); i >= 0 {
129 q.visible = append(q.visible[:i], q.visible[i+1:]...)
130 removed = true
131 } else if i := q.indexPending(id); i >= 0 {
132 q.pending = append(q.pending[:i], q.pending[i+1:]...)
133 removed = true
134 }
135 q.expire(now)
136 q.promote(now)
137 return removed
138}
139
140func (q *ToastQueue[T]) indexVisible(id string) int {
141 if id == "" {
142 return -1
143 }
144 for i := range q.visible {
145 if q.visible[i].ID == id {
146 return i
147 }
148 }
149 return -1
150}
151
152func (q *ToastQueue[T]) indexPending(id string) int {
153 if id == "" {
154 return -1
155 }
156 for i := range q.pending {
157 if q.pending[i].ID == id {
158 return i
159 }
160 }
161 return -1
162}
163
164// expire retires due entries oldest first and fills each freed slot at the
165// instant it was freed, so a burst advances at a steady pace even when the
166// caller polls late.
167func (q *ToastQueue[T]) expire(now time.Time) {
168 for {
169 i, due := q.firstDue(now)
170 if i < 0 {
171 return
172 }
173 q.visible = append(q.visible[:i], q.visible[i+1:]...)
174 q.promote(due)
175 }
176}
177
178// promote fills free slots with waiting entries, shown from at.
179func (q *ToastQueue[T]) promote(at time.Time) {
180 for len(q.visible) < q.opts.MaxVisible && len(q.pending) > 0 {
181 next := q.pending[0]
182 q.pending = q.pending[1:]
183 next.ShownAt = at
184 q.visible = append([]QueuedToast[T]{next}, q.visible...)
185 }
186}
187
188func (q *ToastQueue[T]) firstDue(now time.Time) (int, time.Time) {
189 idx := -1
190 var first time.Time
191 for i, t := range q.visible {
192 if t.Duration <= 0 {
193 continue
194 }
195 due := t.ShownAt.Add(t.Duration)
196 if now.Before(due) {
197 continue
198 }
199 // visible is newest first, so a later index wins a tie: the oldest
200 // entry leaves first.
201 if idx < 0 || !due.After(first) {
202 idx, first = i, due
203 }
204 }
205 return idx, first
206}
207
208// ToastStackBounds lays toasts (newest first) out as a column at their shared
209// anchor: the newest sits at the anchor and older ones step away from it,
210// downward for top and middle anchors and upward for bottom anchors. Boxes that
211// would leave base are omitted, so the result may be shorter than toasts.
212func ToastStackBounds(base domain.Size, toasts []Toast) []domain.Rect {
213 rects := make([]domain.Rect, 0, len(toasts))
214 offset := 0
215 for _, toast := range toasts {
216 r := ToastBounds(base, toast)
217 if r.Width <= 0 || r.Height <= 0 {
218 break
219 }
220 if growsUp(toast.Anchor) {
221 r.Y -= offset
222 } else {
223 r.Y += offset
224 }
225 if r.Y < 0 || r.Y+r.Height > base.Rows {
226 break
227 }
228 rects = append(rects, r)
229 offset += r.Height
230 }
231 return rects
232}
233
234func growsUp(anchor domain.Anchor) bool {
235 switch anchor {
236 case domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight:
237 return true
238 }
239 return false
240}