1package component
2
3import (
4 "fmt"
5
6 "github.com/bnema/puregotk/v4/gtk"
7 "github.com/bnema/puregotk/v4/pango"
8
9 "github.com/bnema/dumber/internal/domain/entity"
10)
11
12type favoriteSidebarDisplayRowKind int
13
14const (
15 favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota
16)
17
18type favoriteSidebarDisplayRow struct {
19 Kind favoriteSidebarDisplayRowKind
20 FavoriteID entity.FavoriteID
21 TagID entity.TagID
22 URL string
23 Favorite *entity.Favorite
24 Selectable bool
25}
26
27func buildFavoriteSidebarDisplayRows(favorites []*entity.Favorite) []favoriteSidebarDisplayRow {
28 rows := make([]favoriteSidebarDisplayRow, 0, len(favorites))
29 for _, fav := range favorites {
30 if fav == nil {
31 continue
32 }
33 rows = append(rows, favoriteSidebarDisplayRow{
34 Kind: favoriteSidebarDisplayRowFavorite,
35 FavoriteID: fav.ID,
36 URL: fav.URL,
37 Favorite: fav,
38 Selectable: fav.URL != "",
39 })
40 }
41 return rows
42}
43
44func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int {
45 for i := range rows {
46 if rows[i].Selectable {
47 return i
48 }
49 }
50 return -1
51}
52
53func lastSelectableIndex(rows []favoriteSidebarDisplayRow) int {
54 for i := len(rows) - 1; i >= 0; i-- {
55 if rows[i].Selectable {
56 return i
57 }
58 }
59 return -1
60}
61
62func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction int) int {
63 if direction != -1 && direction != 1 {
64 return -1
65 }
66 for i := current + direction; i >= 0 && i < len(rows); i += direction {
67 if rows[i].Selectable {
68 return i
69 }
70 }
71 return -1
72}
73
74func (fs *FavoritesSidebar) renderTags() {
75 tagBox, tags, selectedTagIDs, ok := fs.tagRenderState()
76 if !ok {
77 return
78 }
79 clearBoxChildren(tagBox)
80 callbacks := make([]any, 0, len(tags)+2)
81 controls := make([]*gtk.Button, 0, len(tags)+2)
82
83 fs.appendTagFilter(tagBox, "All", len(selectedTagIDs) == 0, fs.clearTagFilters, &callbacks, &controls)
84 for _, tag := range tags {
85 if tag == nil {
86 continue
87 }
88 t := tag
89 _, selected := selectedTagIDs[t.ID]
90 fs.appendTagFilter(tagBox, t.Name, selected, func() { fs.toggleTag(t.ID) }, &callbacks, &controls)
91 }
92 fs.appendTagFilter(tagBox, "+", false, fs.showCreateTagPrompt, &callbacks, &controls)
93 if len(controls) > 0 {
94 controls[len(controls)-1].AddCssClass("favorites-sidebar-tag-add")
95 }
96 fs.storeTagControls(callbacks, controls)
97}
98
99func (fs *FavoritesSidebar) tagRenderState() (*gtk.Box, []*entity.Tag, map[entity.TagID]struct{}, bool) {
100 if fs == nil || fs.tagBox == nil {
101 return nil, nil, nil, false
102 }
103 fs.mu.RLock()
104 defer fs.mu.RUnlock()
105 if fs.destroyed {
106 return nil, nil, nil, false
107 }
108 selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
109 for id := range fs.selectedTagIDs {
110 selected[id] = struct{}{}
111 }
112 return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true
113}
114
115func (fs *FavoritesSidebar) appendTagFilter(
116 box *gtk.Box,
117 label string,
118 selected bool,
119 action func(),
120 callbacks *[]any,
121 controls *[]*gtk.Button,
122) {
123 button := gtk.NewButtonWithLabel(label)
124 if button == nil {
125 return
126 }
127 button.AddCssClass("favorites-sidebar-tag-filter")
128 if selected {
129 button.AddCssClass("favorites-sidebar-tag-filter-active")
130 }
131 callback := func(_ gtk.Button) { action() }
132 *callbacks = append(*callbacks, callback)
133 button.ConnectClicked(&callback)
134 box.Append(&button.Widget)
135 *controls = append(*controls, button)
136}
137
138func (fs *FavoritesSidebar) clearTagFilters() {
139 if fs == nil {
140 return
141 }
142 fs.mu.Lock()
143 if fs.destroyed {
144 fs.mu.Unlock()
145 return
146 }
147 fs.selectedTagIDs = make(map[entity.TagID]struct{})
148 fs.rebuildDisplayRowsLocked()
149 fs.mu.Unlock()
150 fs.renderTags()
151 fs.rebuildList()
152}
153
154func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) {
155 fs.mu.Lock()
156 if !fs.destroyed {
157 fs.tagCallbacks = callbacks
158 fs.tagControls = controls
159 }
160 fs.mu.Unlock()
161}
162
163func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) {
164 if fs == nil {
165 return
166 }
167 fs.mu.Lock()
168 if fs.destroyed {
169 fs.mu.Unlock()
170 return
171 }
172 if fs.selectedTagIDs == nil {
173 fs.selectedTagIDs = make(map[entity.TagID]struct{})
174 }
175 if _, ok := fs.selectedTagIDs[tagID]; ok {
176 delete(fs.selectedTagIDs, tagID)
177 } else {
178 fs.selectedTagIDs[tagID] = struct{}{}
179 }
180 fs.rebuildDisplayRowsLocked()
181 fs.mu.Unlock()
182 fs.renderTags()
183 fs.rebuildList()
184}
185
186func (fs *FavoritesSidebar) rebuildList() {
187 if fs == nil {
188 return
189 }
190 fs.mu.RLock()
191 if fs.destroyed || fs.listBox == nil {
192 fs.mu.RUnlock()
193 return
194 }
195 listBox := fs.listBox
196 rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...)
197 notice := fs.notice
198 query := fs.currentQuery
199 fs.mu.RUnlock()
200
201 listBox.RemoveAll()
202 if len(rows) == 0 {
203 for _, text := range favoriteSidebarNoticeRows(notice, query, false) {
204 fs.appendNoticeRow(listBox, text)
205 }
206 return
207 }
208 for _, row := range rows {
209 fs.appendFavoriteRow(listBox, row)
210 }
211 for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
212 fs.appendNoticeRow(listBox, text)
213 }
214 fs.ensureAtLeastOneSelectionInListBox(listBox)
215}
216
217func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string {
218 if notice != "" {
219 return []string{notice}
220 }
221 if hasRows {
222 return nil
223 }
224 if query != "" {
225 return []string{noResultsText(query)}
226 }
227 return []string{"No favorites"}
228}
229
230func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
231 label := gtk.NewLabel(&text)
232 if label == nil {
233 return
234 }
235 label.AddCssClass("sidebar-empty")
236 label.AddCssClass("favorites-sidebar-empty")
237 label.SetXalign(0.0)
238 row := gtk.NewListBoxRow()
239 if row == nil {
240 return
241 }
242 row.SetSelectable(false)
243 row.SetCanFocus(false)
244 row.SetActivatable(false)
245 row.SetChild(&label.Widget)
246 listBox.Append(&row.Widget)
247}
248
249func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow favoriteSidebarDisplayRow) {
250 fav := displayRow.Favorite
251 if fav == nil {
252 return
253 }
254 rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
255 if rowBox == nil {
256 return
257 }
258 rowBox.SetHexpand(true)
259 appendFavoriteRowTitle(rowBox, fav)
260 appendFavoriteRowSubtitle(rowBox, fav)
261 appendFavoriteRowTags(rowBox, fav.Tags)
262
263 row := gtk.NewListBoxRow()
264 if row == nil {
265 return
266 }
267 row.AddCssClass("sidebar-row")
268 row.AddCssClass("favorites-sidebar-row")
269 row.SetSelectable(displayRow.Selectable)
270 row.SetActivatable(displayRow.Selectable)
271 row.SetCanFocus(displayRow.Selectable)
272 row.SetFocusOnClick(displayRow.Selectable)
273 row.SetChild(&rowBox.Widget)
274 listBox.Append(&row.Widget)
275}
276
277func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
278 title := gtk.NewLabel(nil)
279 if title == nil {
280 return
281 }
282 title.AddCssClass("sidebar-row-title")
283 title.AddCssClass("favorites-sidebar-row-title")
284 title.SetText(safeSidebarString(fav.Title, fav.URL))
285 title.SetXalign(0.0)
286 title.SetHexpand(true)
287 title.SetEllipsize(pango.EllipsizeEndValue)
288 rowBox.Append(&title.Widget)
289}
290
291func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
292 sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
293 if sub == nil {
294 return
295 }
296 sub.SetHexpand(true)
297 url := gtk.NewLabel(nil)
298 if url != nil {
299 url.AddCssClass("sidebar-row-subtitle")
300 url.AddCssClass("favorites-sidebar-row-subtitle")
301 url.SetText(readableURL(fav.URL))
302 url.SetXalign(0.0)
303 url.SetHexpand(true)
304 url.SetEllipsize(pango.EllipsizeEndValue)
305 sub.Append(&url.Widget)
306 }
307 if fav.ShortcutKey != nil {
308 badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey)
309 badgeLabel := gtk.NewLabel(&badge)
310 if badgeLabel != nil {
311 badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge")
312 sub.Append(&badgeLabel.Widget)
313 }
314 }
315 rowBox.Append(&sub.Widget)
316}
317
318func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
319 if len(favoriteTags) == 0 {
320 return
321 }
322 tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3)
323 if tags == nil {
324 return
325 }
326 tags.AddCssClass("favorites-sidebar-row-tags")
327 for _, tag := range favoriteTags {
328 name := tag.Name
329 label := gtk.NewLabel(&name)
330 if label != nil {
331 label.AddCssClass("favorites-sidebar-tag-chip")
332 tags.Append(&label.Widget)
333 }
334 }
335 rowBox.Append(&tags.Widget)
336}
337
338func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
339 if listBox == nil || listBox.GetSelectedRow() != nil {
340 return
341 }
342 fs.mu.RLock()
343 index := firstSelectableIndex(fs.displayRows)
344 fs.mu.RUnlock()
345 if row := listBox.GetRowAtIndex(index); row != nil {
346 listBox.SelectRow(row)
347 }
348}
349
350func (fs *FavoritesSidebar) rowURLAt(index int) string {
351 if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable {
352 return ""
353 }
354 return fs.displayRows[index].URL
355}