1// Package component provides UI components for the browser.
2package component
3
4import (
5 "sort"
6 "strconv"
7 "strings"
8 "time"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11)
12
13// historyGroup represents a day-grouped section of history entries.
14// Groups are ordered newest-first; entries within a group are newest-first.
15type historyGroup struct {
16 Label string
17 Entries []*entity.HistoryEntry
18}
19
20// dayKey identifies a unique calendar day for grouping.
21type dayKey struct {
22 year int
23 month time.Month
24 day int
25}
26
27const (
28 dayLabelToday = "Today"
29 dayLabelYesterday = "Yesterday"
30 dayLabelOtherYearFormat = "January 2, 2006"
31 hoursPerDay = 24
32)
33
34// groupHistoryByDay groups entries by calendar day, newest-first.
35// Days are labeled: "Today", "Yesterday", "Monday, January 2" (current year),
36// "January 2, 2006" (other years). Within each group entries remain in
37// the order they arrived (assumed most-recent-first).
38func groupHistoryByDay(entries []*entity.HistoryEntry) []historyGroup {
39 return groupHistoryByDayAt(entries, time.Now())
40}
41
42func groupHistoryByDayAt(entries []*entity.HistoryEntry, now time.Time) []historyGroup {
43 if len(entries) == 0 {
44 return nil
45 }
46
47 local := now.Location()
48 todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, local)
49
50 groupsMap := make(map[dayKey]*historyGroup)
51 var keys []dayKey
52
53 for _, entry := range entries {
54 t := entry.LastVisited.In(local)
55 key := dayKey{t.Year(), t.Month(), t.Day()}
56
57 if _, ok := groupsMap[key]; !ok {
58 label := dayLabelForKey(key, todayStart, now)
59 groupsMap[key] = &historyGroup{Label: label}
60 keys = append(keys, key)
61 }
62 groupsMap[key].Entries = append(groupsMap[key].Entries, entry)
63 }
64
65 // Sort keys newest-first
66 sort.Slice(keys, func(i, j int) bool {
67 ki, kj := keys[i], keys[j]
68 ti := time.Date(ki.year, ki.month, ki.day, 0, 0, 0, 0, local)
69 tj := time.Date(kj.year, kj.month, kj.day, 0, 0, 0, 0, local)
70 return ti.After(tj)
71 })
72
73 groups := make([]historyGroup, len(keys))
74 for i, k := range keys {
75 groups[i] = *groupsMap[k]
76 }
77 return groups
78}
79
80func dayLabelForKey(key dayKey, todayStart, now time.Time) string {
81 dayStart := time.Date(key.year, key.month, key.day, 0, 0, 0, 0, now.Location())
82 switch {
83 case dayStart.Equal(todayStart):
84 return dayLabelToday
85 case dayStart.Equal(todayStart.AddDate(0, 0, -1)):
86 return dayLabelYesterday
87 case key.year == now.Year():
88 return dayStart.Format("Monday, January 2")
89 default:
90 return dayStart.Format(dayLabelOtherYearFormat)
91 }
92}
93
94// readableURL strips the protocol prefix and optional www. for display.
95func readableURL(rawURL string) string {
96 u := rawURL
97 for _, prefix := range []string{"https://", "http://", "ftp://"} {
98 if strings.HasPrefix(u, prefix) {
99 u = u[len(prefix):]
100 break
101 }
102 }
103 u = strings.TrimPrefix(u, "www.")
104 // Remove trailing slash when there's no path beyond it
105 if slashIdx := strings.IndexByte(u, '/'); slashIdx == -1 || slashIdx == len(u)-1 {
106 u = strings.TrimSuffix(u, "/")
107 }
108 return u
109}
110
111// relativeTime returns a compact relative time label for a timestamp.
112// Examples: "2m ago", "1h ago", "3d ago", "Jan 2".
113func relativeTime(t time.Time) string {
114 return relativeTimeAt(t, time.Now())
115}
116
117func relativeTimeAt(t, now time.Time) string {
118 d := now.Sub(t)
119
120 switch {
121 case d < time.Minute:
122 return "now"
123 case d < time.Hour:
124 m := int(d.Minutes())
125 if m < 2 {
126 return "1m ago"
127 }
128 return strconv.Itoa(m) + "m ago"
129 case d < hoursPerDay*time.Hour:
130 h := int(d.Hours())
131 if h < 2 {
132 return "1h ago"
133 }
134 return strconv.Itoa(h) + "h ago"
135 case d < 7*hoursPerDay*time.Hour:
136 days := int(d.Hours() / hoursPerDay)
137 if days < 2 {
138 return "1d ago"
139 }
140 return strconv.Itoa(days) + "d ago"
141 default:
142 if t.Year() == now.Year() {
143 return t.Format("Jan 2")
144 }
145 return t.Format("Jan 2, 2006")
146 }
147}
148
149// historyDisplayRowKind identifies the type of row rendered in the sidebar.
150type historyDisplayRowKind int
151
152const (
153 historyDisplayRowHeader historyDisplayRowKind = iota
154 historyDisplayRowEntry
155)
156
157// historyDisplayRow is the explicit row model rendered by the history sidebar.
158// It is the only source used for row lookup, selection, activation, and delete.
159type historyDisplayRow struct {
160 Kind historyDisplayRowKind
161 Label string
162 Entry *entity.HistoryEntry
163 GroupIndex int
164}
165
166func buildHistoryDisplayRows(groups []historyGroup) []historyDisplayRow {
167 if len(groups) == 0 {
168 return nil
169 }
170 rows := make([]historyDisplayRow, 0)
171 for gi, group := range groups {
172 rows = append(rows, historyDisplayRow{Kind: historyDisplayRowHeader, Label: group.Label, GroupIndex: gi})
173 for _, entry := range group.Entries {
174 rows = append(rows, historyDisplayRow{Kind: historyDisplayRowEntry, Entry: entry, GroupIndex: gi})
175 }
176 }
177 return rows
178}
179
180// keyboardNavModel provides pure functions for keyboard navigation over the
181// explicit display rows. It has no GTK dependencies and can be tested directly.
182type keyboardNavModel struct {
183 rows []historyDisplayRow
184}
185
186// newKeyboardNavModel creates a keyboardNavModel over day-grouped history.
187func newKeyboardNavModel(groups []historyGroup) keyboardNavModel {
188 return newKeyboardNavModelFromRows(buildHistoryDisplayRows(groups))
189}
190
191func newKeyboardNavModelFromRows(rows []historyDisplayRow) keyboardNavModel {
192 return keyboardNavModel{rows: rows}
193}
194
195// totalRows returns the number of explicit display rows.
196func (m keyboardNavModel) totalRows() int { return len(m.rows) }
197
198// isSelectable returns true when the display row corresponds to an entry.
199func (m keyboardNavModel) isSelectable(index int) bool {
200 return index >= 0 && index < len(m.rows) && m.rows[index].Kind == historyDisplayRowEntry && m.rows[index].Entry != nil
201}
202
203func (m keyboardNavModel) firstSelectableIndex() int {
204 for i := 0; i < m.totalRows(); i++ {
205 if m.isSelectable(i) {
206 return i
207 }
208 }
209 return -1
210}
211
212func (m keyboardNavModel) lastSelectableIndex() int {
213 for i := m.totalRows() - 1; i >= 0; i-- {
214 if m.isSelectable(i) {
215 return i
216 }
217 }
218 return -1
219}
220
221func (m keyboardNavModel) nextSelectableIndex(from, dir int) int {
222 if dir != -1 && dir != +1 {
223 return -1
224 }
225 for i := from + dir; i >= 0 && i < m.totalRows(); i += dir {
226 if m.isSelectable(i) {
227 return i
228 }
229 }
230 return -1
231}
232
233func (m keyboardNavModel) groupIndexAt(index int) int {
234 if index < 0 || index >= len(m.rows) {
235 return -1
236 }
237 return m.rows[index].GroupIndex
238}
239
240func (m keyboardNavModel) maxGroupIndex() int {
241 maxIndex := -1
242 for _, row := range m.rows {
243 if row.GroupIndex > maxIndex {
244 maxIndex = row.GroupIndex
245 }
246 }
247 return maxIndex
248}
249
250func (m keyboardNavModel) cumulativeOffsetAtGroup(gi int) int {
251 if gi < 0 {
252 return -1
253 }
254 for i, row := range m.rows {
255 if row.GroupIndex == gi && row.Kind == historyDisplayRowHeader {
256 return i
257 }
258 }
259 return -1
260}
261
262func (m keyboardNavModel) firstEntryOfGroup(gi int) int {
263 for i, row := range m.rows {
264 if row.GroupIndex == gi && row.Kind == historyDisplayRowEntry && row.Entry != nil {
265 return i
266 }
267 }
268 return -1
269}
270
271func (m keyboardNavModel) previousDayBoundary(from int) int {
272 gi := m.groupIndexAt(from)
273 if gi <= 0 {
274 return -1
275 }
276 return m.firstEntryOfGroup(gi - 1)
277}
278
279func (m keyboardNavModel) nextDayBoundary(from int) int {
280 gi := m.groupIndexAt(from)
281 if gi < 0 || gi >= m.maxGroupIndex() {
282 return -1
283 }
284 return m.firstEntryOfGroup(gi + 1)
285}
286
287func (m keyboardNavModel) entryAt(index int) *entity.HistoryEntry {
288 if !m.isSelectable(index) {
289 return nil
290 }
291 return m.rows[index].Entry
292}
293
294func (m keyboardNavModel) entryURLAt(index int) string {
295 e := m.entryAt(index)
296 if e == nil {
297 return ""
298 }
299 return e.URL
300}
301
302func (m keyboardNavModel) entryCount() int {
303 n := 0
304 for i := range m.rows {
305 if m.isSelectable(i) {
306 n++
307 }
308 }
309 return n
310}