1// Package notices renders the notification history overlay: a scrollable,
2// newest-first list of the daemon's recorded notifications.
3package notices
4
5import (
6 "fmt"
7 "time"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/usecase/ui"
12)
13
14// RenderStyles are the styles Render uses to draw list rows. The zero value
15// is never used directly by daemon composition, which always supplies a
16// theme-derived RenderStyles the way picker.RenderStyles is supplied.
17type RenderStyles struct {
18 Background renderer.Style // fills unused interior rows
19 Base renderer.Style // non-selected row fill
20 Selection renderer.Style // selected row fill
21 Text renderer.Style // non-selected message segment
22 SelectionText renderer.Style // selected row message segment
23 Muted renderer.Style // non-selected severity/time/slug segment
24 SelectionMuted renderer.Style // selected row severity/time/slug segment
25}
26
27// Model is the history overlay's list state: a snapshot of notifications,
28// newest first, with a navigable selection. now anchors the relative
29// timestamps rendered next to each row and is fixed at construction so the
30// overlay's times don't drift while it stays open.
31type Model struct {
32 rows []domain.Notification
33 selected int
34 now time.Time
35}
36
37// New builds a Model from entries, expected newest-first as returned by the
38// notice center's history(). now is the instant relative row times render
39// against.
40func New(entries []domain.Notification, now time.Time) *Model {
41 m := &Model{rows: append([]domain.Notification(nil), entries...), now: now, selected: -1}
42 if len(m.rows) > 0 {
43 m.selected = 0
44 }
45 return m
46}
47
48func (m *Model) Up() {
49 m.move(-1)
50}
51
52func (m *Model) Down() {
53 m.move(1)
54}
55
56func (m *Model) move(delta int) {
57 if m == nil || len(m.rows) == 0 {
58 return
59 }
60 next := m.selected + delta
61 if next < 0 {
62 next = 0
63 }
64 if next >= len(m.rows) {
65 next = len(m.rows) - 1
66 }
67 m.selected = next
68}
69
70// Selected returns the highlighted notification, or false when history is
71// empty.
72func (m *Model) Selected() (domain.Notification, bool) {
73 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
74 return domain.Notification{}, false
75 }
76 return m.rows[m.selected], true
77}
78
79// Clone returns an independent copy safe to render outside the lock that
80// guards the live model.
81func (m *Model) Clone() *Model {
82 if m == nil {
83 return nil
84 }
85 clone := *m
86 clone.rows = append([]domain.Notification(nil), m.rows...)
87 return &clone
88}
89
90// Render draws the history list into inner, scrolling to keep the selection
91// visible. Degenerate dimensions draw nothing; an empty history draws a
92// centered placeholder instead of a blank box.
93func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
94 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
95 if frame.Width <= 0 || frame.Height <= 0 {
96 return frame
97 }
98 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Background})
99 if m == nil || len(m.rows) == 0 {
100 text := "No notifications"
101 x := max(0, (frame.Width-textWidth(text))/2)
102 ui.DrawText(frame, x, frame.Height/2, frame.Width, text, styles.Text)
103 return frame
104 }
105 offset := m.scrollOffset(frame.Height)
106 for y := 0; y < frame.Height; y++ {
107 idx := offset + y
108 if idx >= len(m.rows) {
109 break
110 }
111 n := m.rows[idx]
112 base, mutedStyle, textStyle := styles.Base, styles.Muted, styles.Text
113 if idx == m.selected {
114 base, mutedStyle, textStyle = styles.Selection, styles.SelectionMuted, styles.SelectionText
115 }
116 ui.FillRect(frame, domain.Rect{X: 0, Y: y, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
117 meta := fmt.Sprintf("[%c] %s %s ", severityLetter(n.Severity), relativeTime(m.now, n.Time), n.Code.String())
118 x := ui.DrawText(frame, 0, y, frame.Width, meta, mutedStyle)
119 ui.DrawText(frame, x, y, frame.Width, rowMessage(n), textStyle)
120 }
121 return frame
122}
123
124// rowMessage appends a "×N" coalesce count suffix. The format string matches
125// the toast overlay's own count formatting (internal/usecase/ui.ComposeNotices),
126// but the field it is appended to differs: the toast appends to Title, while
127// this row appends to Message, matching this overlay's row layout.
128func rowMessage(n domain.Notification) string {
129 if n.Count > 1 {
130 return fmt.Sprintf("%s ×%d", n.Message, n.Count)
131 }
132 return n.Message
133}
134
135func (m *Model) scrollOffset(visible int) int {
136 if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
137 return 0
138 }
139 if m.selected < visible {
140 return 0
141 }
142 offset := m.selected - visible + 1
143 return min(offset, len(m.rows)-visible)
144}
145
146func severityLetter(sev domain.NoticeSeverity) byte {
147 switch sev {
148 case domain.NoticeWarn:
149 return 'W'
150 case domain.NoticeError:
151 return 'E'
152 default:
153 return 'I'
154 }
155}
156
157// relativeTime renders how long ago t was, relative to now, in the coarsest
158// unit that keeps the row compact. A non-positive delta (including clock
159// skew that puts t in the future) reads as "just now" rather than negative.
160func relativeTime(now, t time.Time) string {
161 d := now.Sub(t)
162 switch {
163 case d < time.Minute:
164 return "just now"
165 case d < time.Hour:
166 return fmt.Sprintf("%dm ago", int(d/time.Minute))
167 case d < 24*time.Hour:
168 return fmt.Sprintf("%dh ago", int(d/time.Hour))
169 default:
170 return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
171 }
172}
173
174func textWidth(text string) int {
175 width := 0
176 for _, r := range text {
177 width += renderer.RuneWidth(r)
178 }
179 return width
180}