toast_queue.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close