1package ui
2
3import (
4 "fmt"
5 "strings"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9)
10
11const (
12 noticeMargin = 1
13 noticeMaxWidth = 60
14 noticeMinWidth = 24
15 noticeMaxLines = 3
16)
17
18// NoticeView is a plain rendering view of one notification.
19type NoticeView struct {
20 Severity domain.NoticeSeverity
21 Title string
22 Message string
23 Count int
24}
25
26// NoticeStyles selects severity-colored styles for each box's border and title.
27// Text styles the wrapped message body and overflow indicator; it excludes the
28// title, which is drawn with the box's severity style.
29type NoticeStyles struct {
30 Text renderer.Style
31 BoxInfo renderer.Style
32 BoxWarn renderer.Style
33 BoxError renderer.Style
34}
35
36// ComposeNotices stacks notice boxes top-right of frame, newest first. Each
37// box's width starts at frame.Width*2/5, is capped at noticeMaxWidth (60),
38// then floored at noticeMinWidth (24), then clamped down to frame.Width so it
39// never exceeds the frame — on frames narrower than the floor, that final
40// clamp overrides the floor. Each box is titled with the notice's slug and an
41// optional " ×N" count suffix, and its message is word-wrapped to at most 3
42// lines. Boxes stack downward with one blank row between them; once the
43// count behind overflow is nonzero, a right-aligned "+N more" line follows
44// the last box.
45func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect {
46 if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
47 return nil
48 }
49
50 footprints := make([]domain.Rect, 0, len(notices)+1)
51
52 width := frame.Width * 2 / 5
53 if width > noticeMaxWidth {
54 width = noticeMaxWidth
55 }
56 if width < noticeMinWidth {
57 width = noticeMinWidth
58 }
59 if width > frame.Width {
60 width = frame.Width
61 }
62 if width <= 0 {
63 return nil
64 }
65 x := frame.Width - noticeMargin - width
66 y := noticeMargin
67
68 for _, n := range notices {
69 if y >= frame.Height {
70 break
71 }
72 innerWidth := width - 2
73 lines := wrapNoticeText(n.Message, innerWidth, noticeMaxLines)
74 if len(lines) == 0 {
75 lines = []string{""}
76 }
77 height := len(lines) + 2
78 bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}
79 footprints = append(footprints, bounds)
80 box := noticeBoxStyle(styles, n.Severity)
81 DrawBox(frame, bounds, box)
82
83 title := n.Title
84 if n.Count > 1 {
85 title = fmt.Sprintf("%s ×%d", n.Title, n.Count)
86 }
87 if title != "" && bounds.Width > 2 {
88 left := bounds.X + 1
89 right := bounds.X + bounds.Width - 1
90 start := max(left, bounds.X+(bounds.Width-textWidth(title))/2)
91 DrawText(frame, start, bounds.Y, right, title, box)
92 }
93
94 inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
95 if inner.Width > 0 && inner.Height > 0 {
96 fill := renderer.BlankCell()
97 fill.Style = styles.Text
98 FillRect(frame, inner, fill)
99 for i, line := range lines {
100 if i >= inner.Height {
101 break
102 }
103 DrawText(frame, inner.X, inner.Y+i, inner.X+inner.Width, line, styles.Text)
104 }
105 }
106
107 y += height + 1
108 }
109
110 if overflow > 0 && y < frame.Height {
111 text := fmt.Sprintf("+%d more", overflow)
112 start := x + width - textWidth(text)
113 if start < x {
114 start = x
115 }
116 DrawText(frame, start, y, x+width, text, styles.Text)
117 footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1})
118 }
119 return footprints
120}
121
122func noticeBoxStyle(styles NoticeStyles, severity domain.NoticeSeverity) renderer.Style {
123 switch severity {
124 case domain.NoticeWarn:
125 return styles.BoxWarn
126 case domain.NoticeError:
127 return styles.BoxError
128 default:
129 return styles.BoxInfo
130 }
131}
132
133// wrapNoticeText greedily fills lines up to width, then caps at maxLines by
134// collapsing any remainder into the last line and truncating it, so an
135// overlong message is clipped visibly rather than silently dropped mid-word.
136func wrapNoticeText(message string, width, maxLines int) []string {
137 if width <= 0 || maxLines <= 0 {
138 return nil
139 }
140 words := strings.Fields(message)
141 if len(words) == 0 {
142 return nil
143 }
144
145 lines := make([]string, 0, maxLines)
146 current := words[0]
147 for _, w := range words[1:] {
148 candidate := current + " " + w
149 if textWidth(candidate) <= width {
150 current = candidate
151 continue
152 }
153 lines = append(lines, current)
154 current = w
155 }
156 lines = append(lines, current)
157
158 if len(lines) <= maxLines {
159 return lines
160 }
161 rest := strings.Join(lines[maxLines-1:], " ")
162 lines = lines[:maxLines-1]
163 return append(lines, TruncateText(rest, width))
164}