v0.3.0 99dc266

toast.go RawHistoryFolder

1package ui
2
3import (
4	"time"
5
6	renderer "github.com/bnema/vev-vt"
7	"github.com/bnema/vev/internal/domain"
8)
9
10const (
11	defaultToastMargin   = 2
12	defaultToastPaddingX = 1
13	defaultToastMaxWidth = 60
14)
15
16// Toast describes a transient message rendered over a frame.
17type Toast struct {
18	ID            string
19	Message       string
20	Anchor        domain.Anchor
21	Duration      time.Duration
22	DimBackground bool
23	MinWidth      int
24	MaxWidth      int
25	PaddingX      int
26	PaddingY      int
27}
28
29// ActiveToast is a toast currently tracked by a manager.
30type ActiveToast struct {
31	Toast
32	ShownAt time.Time
33}
34
35// ToastManager tracks currently visible toasts.
36type ToastManager struct {
37	active map[string]ActiveToast
38}
39
40// ToastStyles contains styles used when drawing a toast.
41type ToastStyles struct {
42	Text renderer.Style
43	Box  renderer.Style
44}
45
46// DimStyleFunc transforms an existing cell style for dimmed backgrounds.
47type DimStyleFunc func(renderer.Style) renderer.Style
48
49// NewToastManager creates an empty toast manager.
50func NewToastManager() *ToastManager {
51	return &ToastManager{active: make(map[string]ActiveToast)}
52}
53
54// Show records toast as active at now. Empty IDs are replaced with a stable key.
55func (m *ToastManager) Show(now time.Time, toast Toast) {
56	if m.active == nil {
57		m.active = make(map[string]ActiveToast)
58	}
59	id := toast.ID
60	if id == "" {
61		id = toastAnchorID(toast.Anchor)
62		toast.ID = id
63	}
64	m.active[id] = ActiveToast{Toast: toast, ShownAt: now}
65}
66
67func toastAnchorID(anchor domain.Anchor) string {
68	return "anchor:" + anchor.String()
69}
70
71// Dismiss removes a toast by ID.
72func (m *ToastManager) Dismiss(id string) {
73	delete(m.active, id)
74}
75
76// Clear removes all toasts.
77func (m *ToastManager) Clear() {
78	m.active = make(map[string]ActiveToast)
79}
80
81// Active returns non-expired toasts at now and prunes expired entries.
82func (m *ToastManager) Active(now time.Time) []ActiveToast {
83	if m.active == nil {
84		return nil
85	}
86	active := make([]ActiveToast, 0, len(m.active))
87	for id, toast := range m.active {
88		if toast.Duration > 0 && !now.Before(toast.ShownAt.Add(toast.Duration)) {
89			delete(m.active, id)
90			continue
91		}
92		active = append(active, toast)
93	}
94	return active
95}
96
97// HasActive reports whether any toast is active at now.
98func (m *ToastManager) HasActive(now time.Time) bool {
99	return len(m.Active(now)) > 0
100}
101
102// ToastBounds returns the toast rectangle positioned within base and clamped to base.
103func ToastBounds(base domain.Size, toast Toast) domain.Rect {
104	if base.Cols <= 0 || base.Rows <= 0 {
105		return domain.Rect{}
106	}
107
108	paddingX := toast.PaddingX
109	if paddingX == 0 {
110		paddingX = defaultToastPaddingX
111	}
112	const borderWidth = 2
113	const borderHeight = 2
114
115	maxWidth := toast.MaxWidth
116	if maxWidth <= 0 {
117		maxWidth = defaultToastMaxWidth
118	}
119	width := textWidth(toast.Message) + paddingX*2 + borderWidth
120	width = max(width, toast.MinWidth)
121	width = clamp(width, 0, maxWidth)
122	width = clamp(width, 0, base.Cols)
123
124	height := 1 + toast.PaddingY*2 + borderHeight
125	height = clamp(height, 0, base.Rows)
126
127	return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{
128		Top:    defaultToastMargin,
129		Right:  defaultToastMargin,
130		Bottom: defaultToastMargin,
131		Left:   defaultToastMargin,
132	})
133}
134
135// CompositeToasts draws the latest toast per anchor over frame.
136func CompositeToasts(frame renderer.Frame, toasts []ActiveToast, styles ToastStyles, dim DimStyleFunc) {
137	visible := latestToastsByAnchor(toasts)
138	if len(visible) == 0 {
139		return
140	}
141
142	if dim != nil {
143		shouldDim := false
144		for _, toast := range visible {
145			if toast.DimBackground {
146				shouldDim = true
147				break
148			}
149		}
150		if shouldDim {
151			for y := 0; y < frame.Height; y++ {
152				for x := 0; x < frame.Width; x++ {
153					cell := frame.At(x, y)
154					cell.Style = dim(cell.Style)
155					frame.Set(x, y, cell)
156				}
157			}
158		}
159	}
160
161	for _, toast := range visible {
162		drawToast(frame, toast.Toast, styles)
163	}
164}
165
166func latestToastsByAnchor(toasts []ActiveToast) []ActiveToast {
167	byAnchor := make(map[domain.Anchor]ActiveToast)
168	for _, toast := range toasts {
169		current, ok := byAnchor[toast.Anchor]
170		if !ok || toast.ShownAt.After(current.ShownAt) || toast.ShownAt.Equal(current.ShownAt) {
171			byAnchor[toast.Anchor] = toast
172		}
173	}
174	anchors := []domain.Anchor{
175		domain.AnchorTopLeft,
176		domain.AnchorTopRight,
177		domain.AnchorBottomLeft,
178		domain.AnchorBottomRight,
179		domain.AnchorCenter,
180		domain.AnchorTop,
181		domain.AnchorLeft,
182		domain.AnchorRight,
183		domain.AnchorBottom,
184	}
185	visible := make([]ActiveToast, 0, len(byAnchor))
186	for _, anchor := range anchors {
187		if toast, ok := byAnchor[anchor]; ok {
188			visible = append(visible, toast)
189		}
190	}
191	return visible
192}
193
194func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) {
195	bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast)
196	if bounds.Width <= 0 || bounds.Height <= 0 {
197		return
198	}
199
200	DrawBox(frame, bounds, styles.Box)
201	inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
202	if inner.Width <= 0 || inner.Height <= 0 {
203		return
204	}
205
206	fill := renderer.BlankCell()
207	fill.Style = styles.Text
208	FillRect(frame, inner, fill)
209
210	paddingX := toast.PaddingX
211	if paddingX == 0 {
212		paddingX = defaultToastPaddingX
213	}
214	textY := inner.Y + toast.PaddingY
215	if textY >= inner.Y+inner.Height {
216		return
217	}
218	textLeft := inner.X + paddingX
219	textRight := inner.X + inner.Width - paddingX
220	if textRight <= textLeft {
221		return
222	}
223	DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text)
224}
225
226// TruncateText clips text to maxWidth cells, appending an ellipsis when cut.
227func TruncateText(text string, maxWidth int) string { return truncateText(text, maxWidth) }
228
229func truncateText(text string, maxWidth int) string {
230	if maxWidth <= 0 {
231		return ""
232	}
233	if textWidth(text) <= maxWidth {
234		return text
235	}
236	ellipsis := "…"
237	ellipsisWidth := textWidth(ellipsis)
238	if maxWidth < ellipsisWidth {
239		return ""
240	}
241	limit := maxWidth - ellipsisWidth
242	out := make([]rune, 0, len(text))
243	width := 0
244	for _, r := range text {
245		w := renderer.RuneWidth(r)
246		if w == 0 {
247			continue
248		}
249		if width+w > limit {
250			break
251		}
252		out = append(out, r)
253		width += w
254	}
255	return string(out) + ellipsis
256}

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