1package ui
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 "github.com/bnema/vev/internal/domain"
6)
7
8const (
9 defaultToastMargin = 2
10 defaultToastPaddingX = 1
11 defaultToastMaxWidth = 60
12)
13
14// Toast describes a transient message rendered over a frame.
15type Toast struct {
16 Message string
17 // Severity is carried for callers that style by it (client toast
18 // borders); DrawToast does not read it.
19 Severity domain.NoticeSeverity
20 Anchor domain.Anchor
21 MinWidth int
22 MaxWidth int
23 PaddingX int
24 PaddingY int
25}
26
27// ToastStyles contains styles used when drawing a toast.
28type ToastStyles struct {
29 Text renderer.Style
30 Box renderer.Style
31}
32
33// ToastBounds returns the toast rectangle positioned within base and clamped to base.
34func ToastBounds(base domain.Size, toast Toast) domain.Rect {
35 if base.Cols <= 0 || base.Rows <= 0 {
36 return domain.Rect{}
37 }
38
39 paddingX := toast.PaddingX
40 if paddingX == 0 {
41 paddingX = defaultToastPaddingX
42 }
43 const borderWidth = 2
44 const borderHeight = 2
45
46 maxWidth := toast.MaxWidth
47 if maxWidth <= 0 {
48 maxWidth = defaultToastMaxWidth
49 }
50 width := textWidth(toast.Message) + paddingX*2 + borderWidth
51 width = max(width, toast.MinWidth)
52 width = clamp(width, 0, maxWidth)
53 width = clamp(width, 0, base.Cols)
54
55 height := 1 + toast.PaddingY*2 + borderHeight
56 height = clamp(height, 0, base.Rows)
57
58 return Place(base, domain.Size{Cols: width, Rows: height}, toast.Anchor, Margins{
59 Top: defaultToastMargin,
60 Right: defaultToastMargin,
61 Bottom: defaultToastMargin,
62 Left: defaultToastMargin,
63 })
64}
65
66// DrawToast draws one bordered toast at its anchor within frame.
67func DrawToast(frame renderer.Frame, toast Toast, styles ToastStyles) {
68 bounds := ToastBounds(domain.Size{Cols: frame.Width, Rows: frame.Height}, toast)
69 if bounds.Width <= 0 || bounds.Height <= 0 {
70 return
71 }
72
73 DrawBox(frame, bounds, styles.Box)
74 inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
75 if inner.Width <= 0 || inner.Height <= 0 {
76 return
77 }
78
79 fill := renderer.BlankCell()
80 fill.Style = styles.Text
81 FillRect(frame, inner, fill)
82
83 paddingX := toast.PaddingX
84 if paddingX == 0 {
85 paddingX = defaultToastPaddingX
86 }
87 textY := inner.Y + toast.PaddingY
88 if textY >= inner.Y+inner.Height {
89 return
90 }
91 textLeft := inner.X + paddingX
92 textRight := inner.X + inner.Width - paddingX
93 if textRight <= textLeft {
94 return
95 }
96 DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text)
97}
98
99// TruncateText clips text to maxWidth cells, appending an ellipsis when cut.
100func TruncateText(text string, maxWidth int) string { return truncateText(text, maxWidth) }
101
102func truncateText(text string, maxWidth int) string {
103 if maxWidth <= 0 {
104 return ""
105 }
106 if textWidth(text) <= maxWidth {
107 return text
108 }
109 ellipsis := "…"
110 ellipsisWidth := textWidth(ellipsis)
111 if maxWidth < ellipsisWidth {
112 return ""
113 }
114 limit := maxWidth - ellipsisWidth
115 out := make([]rune, 0, len(text))
116 width := 0
117 for _, r := range text {
118 w := renderer.RuneWidth(r)
119 if w == 0 {
120 continue
121 }
122 if width+w > limit {
123 break
124 }
125 out = append(out, r)
126 width += w
127 }
128 return string(out) + ellipsis
129}