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}