1package component
2
3import (
4 "context"
5 "fmt"
6 "sync"
7
8 "github.com/bnema/puregotk/v4/glib"
9 "github.com/bnema/puregotk/v4/gtk"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"
14)
15
16// FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
17type FavoritesSidebarConfig struct {
18 FavoritesUC port.FavoritesSidebarFavorites
19 OnNavigate func(ctx context.Context, url string) error
20 OnNavigateKeepOpen func(ctx context.Context, url string) error
21 OnOpenInNewPane func(ctx context.Context, url string) error
22 OnClose func()
23}
24
25// FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
26type FavoritesSidebar struct {
27 outerBox *gtk.Box
28 searchEntry *gtk.SearchEntry
29 tagBox *gtk.Box
30 tagControls []*gtk.Button
31 tagPromptBox *gtk.Box
32 tagNameEntry *gtk.Entry
33 tagPromptSaveBtn *gtk.Button
34 scrolledWin *gtk.ScrolledWindow
35 listBox *gtk.ListBox
36 formBox *gtk.Box
37
38 formURLEntry *gtk.SearchEntry
39 formTitleEntry *gtk.SearchEntry
40 formTagSearch *gtk.SearchEntry
41 formTagMatches *gtk.Box
42 formShortcutEntry *gtk.SearchEntry
43 formSaveButton *gtk.Button
44 formURL string
45 formTitle string
46 formShortcut string
47 formTagIDs map[entity.TagID]struct{}
48
49 favoritesUC port.FavoritesSidebarFavorites
50 onNavigate func(ctx context.Context, url string) error
51 onNavigateKeepOpen func(ctx context.Context, url string) error
52 onOpenInNewPane func(ctx context.Context, url string) error
53 onClose func()
54
55 allFavorites []*entity.Favorite
56 allTags []*entity.Tag
57 selectedTagIDs map[entity.TagID]struct{}
58 displayRows []favoriteSidebarDisplayRow
59 currentQuery string
60 notice string
61 noticeLoadGen uint64
62 loadGen uint64
63 destroyed bool
64 visible bool
65 focusZone favoritesSidebarFocusZone
66 textInputWidget *gtk.Widget
67 mode favoritesSidebarMode
68 editingID entity.FavoriteID
69 confirmDelete bool
70 confirmDeleteID entity.FavoriteID
71
72 retainedCallbacks []any
73 tagCallbacks []any
74 tagPromptCallbacks []any
75 formCallbacks []any
76 formTagMatchCallbacks []any
77 ctx context.Context
78 cancel context.CancelFunc
79 mu sync.RWMutex
80
81 idleScheduler func(glib.SourceFunc)
82}
83
84// NewFavoritesSidebar creates a native favorites sidebar and starts loading data.
85func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
86 ctx, cancel := context.WithCancel(ctx)
87 fs := &FavoritesSidebar{
88 favoritesUC: cfg.FavoritesUC,
89 onNavigate: cfg.OnNavigate,
90 onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
91 onOpenInNewPane: cfg.OnOpenInNewPane,
92 onClose: cfg.OnClose,
93 selectedTagIDs: make(map[entity.TagID]struct{}),
94 ctx: ctx,
95 cancel: cancel,
96 }
97 if err := fs.createWidgets(); err != nil {
98 cancel()
99 return nil
100 }
101 fs.setupSearchHandler()
102 fs.setupKeyboardNavigation()
103 fs.startLoad()
104 return fs
105}
106
107// Widget returns the raw GTK widget.
108func (fs *FavoritesSidebar) Widget() *gtk.Widget {
109 if fs == nil || fs.outerBox == nil {
110 return nil
111 }
112 return &fs.outerBox.Widget
113}
114
115// Show displays the sidebar and focuses search. Nil-safe.
116func (fs *FavoritesSidebar) Show() {
117 if fs == nil {
118 return
119 }
120 fs.mu.Lock()
121 if fs.destroyed || fs.outerBox == nil {
122 fs.mu.Unlock()
123 return
124 }
125 fs.outerBox.SetVisible(true)
126 fs.visible = true
127 fs.mu.Unlock()
128 fs.startLoad()
129 fs.focusSearch()
130}
131
132// Hide hides the sidebar. Nil-safe.
133func (fs *FavoritesSidebar) Hide() {
134 if fs == nil {
135 return
136 }
137 fs.mu.Lock()
138 if fs.destroyed {
139 fs.mu.Unlock()
140 return
141 }
142 if fs.outerBox != nil {
143 fs.outerBox.SetVisible(false)
144 }
145 fs.visible = false
146 fs.mu.Unlock()
147}
148
149// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
150func (fs *FavoritesSidebar) Destroy() {
151 if fs == nil {
152 return
153 }
154 fs.mu.Lock()
155 if fs.destroyed {
156 fs.mu.Unlock()
157 return
158 }
159 fs.destroyed = true
160 fs.mu.Unlock()
161 if fs.cancel != nil {
162 fs.cancel()
163 }
164}
165
166func (fs *FavoritesSidebar) createWidgets() error {
167 if err := fs.createOuterAndSearch(); err != nil {
168 return err
169 }
170 if err := fs.createTagControls(); err != nil {
171 return err
172 }
173
174 fs.scrolledWin = gtk.NewScrolledWindow()
175 if fs.scrolledWin == nil {
176 return fmt.Errorf("favorites sidebar: scrolled window creation failed")
177 }
178 fs.scrolledWin.SetVexpand(true)
179 fs.scrolledWin.SetHexpand(true)
180 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
181 fs.scrolledWin.AddCssClass("sidebar-list")
182
183 fs.listBox = gtk.NewListBox()
184 if fs.listBox == nil {
185 return fmt.Errorf("favorites sidebar: list box creation failed")
186 }
187 fs.listBox.AddCssClass("sidebar-list")
188 fs.listBox.AddCssClass("favorites-sidebar-list")
189 fs.trackListFocus(&fs.listBox.Widget)
190 fs.listBox.SetActivateOnSingleClick(true)
191 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
192 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
193 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
194 fs.onRowActivated(row)
195 }
196 fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
197 fs.listBox.ConnectRowActivated(&rowActivatedCb)
198
199 fs.scrolledWin.SetChild(&fs.listBox.Widget)
200 fs.outerBox.Append(&fs.scrolledWin.Widget)
201
202 fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
203 if fs.formBox == nil {
204 return fmt.Errorf("favorites sidebar: form box creation failed")
205 }
206 fs.formBox.AddCssClass("favorites-sidebar-form")
207 fs.formBox.SetVisible(false)
208 fs.outerBox.Append(&fs.formBox.Widget)
209 return nil
210}
211
212func (fs *FavoritesSidebar) createOuterAndSearch() error {
213 fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
214 if fs.outerBox == nil {
215 return fmt.Errorf("favorites sidebar: outer box creation failed")
216 }
217 fs.outerBox.AddCssClass("sidebar-outer")
218 fs.outerBox.AddCssClass("favorites-sidebar-outer")
219 fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
220 fs.outerBox.SetHexpand(false)
221 fs.outerBox.SetVexpand(true)
222 fs.outerBox.SetVisible(false)
223
224 searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
225 if searchBox == nil {
226 return fmt.Errorf("favorites sidebar: search box creation failed")
227 }
228 searchBox.AddCssClass("sidebar-search-box")
229 searchBox.SetHexpand(true)
230 fs.searchEntry = gtk.NewSearchEntry()
231 if fs.searchEntry == nil {
232 return fmt.Errorf("favorites sidebar: search entry creation failed")
233 }
234 fs.searchEntry.AddCssClass("sidebar-search")
235 fs.searchEntry.AddCssClass("favorites-sidebar-search")
236 fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
237 fs.searchEntry.SetHexpand(true)
238 placeholder := "Search favorites..."
239 fs.searchEntry.SetPlaceholderText(&placeholder)
240 searchBox.Append(&fs.searchEntry.Widget)
241 fs.outerBox.Append(&searchBox.Widget)
242 return nil
243}
244
245func (fs *FavoritesSidebar) createTagControls() error {
246 fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
247 if fs.tagBox == nil {
248 return fmt.Errorf("favorites sidebar: tag box creation failed")
249 }
250 fs.tagBox.AddCssClass("favorites-sidebar-tags")
251 fs.tagBox.SetHexpand(true)
252 fs.outerBox.Append(&fs.tagBox.Widget)
253 fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
254 if fs.tagPromptBox == nil {
255 return fmt.Errorf("favorites sidebar: tag prompt creation failed")
256 }
257 fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt")
258 fs.tagPromptBox.SetVisible(false)
259 fs.outerBox.Append(&fs.tagPromptBox.Widget)
260 return nil
261}
262
263func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
264 if fs == nil || widget == nil || callbacks == nil {
265 return
266 }
267 controller := gtk.NewEventControllerFocus()
268 if controller == nil {
269 return
270 }
271 entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
272 left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
273 *callbacks = append(*callbacks, entered, left)
274 controller.ConnectEnter(&entered)
275 controller.ConnectLeave(&left)
276 widget.AddController(&controller.EventController)
277}
278
279func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) {
280 if fs == nil || widget == nil {
281 return
282 }
283 fs.mu.Lock()
284 if !fs.destroyed {
285 fs.textInputWidget = widget
286 if fs.searchEntry != nil && widget == &fs.searchEntry.Widget {
287 fs.focusZone = favoritesSidebarFocusSearch
288 } else {
289 fs.focusZone = favoritesSidebarFocusForm
290 }
291 }
292 fs.mu.Unlock()
293}
294
295func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) {
296 if fs == nil || widget == nil {
297 return
298 }
299 controller := gtk.NewEventControllerFocus()
300 if controller == nil {
301 return
302 }
303 entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) }
304 fs.retainedCallbacks = append(fs.retainedCallbacks, entered)
305 controller.ConnectEnter(&entered)
306 widget.AddController(&controller.EventController)
307}
308
309func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) {
310 if fs == nil {
311 return
312 }
313 fs.mu.Lock()
314 if !fs.destroyed {
315 fs.focusZone = zone
316 }
317 fs.mu.Unlock()
318}
319
320func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) {
321 if fs == nil || widget == nil {
322 return
323 }
324 fs.mu.Lock()
325 if !fs.destroyed && fs.textInputWidget == widget {
326 fs.textInputWidget = nil
327 }
328 fs.mu.Unlock()
329}
330
331func (fs *FavoritesSidebar) focusSearch() {
332 if fs == nil {
333 return
334 }
335 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
336 fs.mu.RLock()
337 destroyed := fs.destroyed
338 entry := fs.searchEntry
339 fs.mu.RUnlock()
340 if !destroyed && entry != nil {
341 entry.GrabFocus()
342 }
343 return false
344 }))
345}
346
347// RequestReloadIfVisible refreshes sidebar data when the sidebar is currently visible.
348func (fs *FavoritesSidebar) RequestReloadIfVisible(_ string) {
349 if fs == nil {
350 return
351 }
352 fs.mu.RLock()
353 visible := fs.visible && !fs.destroyed
354 fs.mu.RUnlock()
355 if visible {
356 fs.startLoad()
357 }
358}
359
360func (fs *FavoritesSidebar) startLoad() {
361 if fs == nil {
362 return
363 }
364 fs.mu.Lock()
365 if fs.destroyed {
366 fs.mu.Unlock()
367 return
368 }
369 fs.loadGen++
370 gen := fs.loadGen
371 uc := fs.favoritesUC
372 ctx := fs.ctx
373 visible := fs.visible
374 fs.notice = "Loading favorites..."
375 fs.noticeLoadGen = gen
376 fs.mu.Unlock()
377 if visible {
378 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
379 fs.mu.RLock()
380 destroyed := fs.destroyed
381 fs.mu.RUnlock()
382 if !destroyed {
383 fs.rebuildList()
384 }
385 return false
386 }))
387 }
388
389 if uc == nil || ctx == nil {
390 fs.applyLoadedData(nil, nil, gen, nil)
391 return
392 }
393 go func() {
394 favorites, favErr := uc.GetAll(ctx)
395 tags, tagErr := uc.GetAllTags(ctx)
396 err := favErr
397 if err == nil {
398 err = tagErr
399 }
400 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
401 fs.applyLoadedData(favorites, tags, gen, err)
402 return false
403 }))
404 }()
405}
406
407func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
408 if fs == nil {
409 return false
410 }
411 fs.mu.Lock()
412 if fs.destroyed || gen != fs.loadGen {
413 fs.mu.Unlock()
414 return false
415 }
416 if favorites == nil {
417 favorites = []*entity.Favorite{}
418 }
419 if tags == nil {
420 tags = []*entity.Tag{}
421 }
422 fs.allFavorites = favorites
423 fs.allTags = tags
424 if fs.noticeLoadGen == gen {
425 fs.noticeLoadGen = 0
426 if err != nil {
427 fs.notice = err.Error()
428 } else {
429 fs.notice = ""
430 }
431 }
432 fs.rebuildDisplayRowsLocked()
433 fs.mu.Unlock()
434 fs.renderTags()
435 fs.rebuildList()
436 return true
437}
438
439func (fs *FavoritesSidebar) setupSearchHandler() {
440 if fs == nil || fs.searchEntry == nil {
441 return
442 }
443 changedCb := func(_ gtk.SearchEntry) {
444 fs.mu.RLock()
445 destroyed := fs.destroyed
446 fs.mu.RUnlock()
447 if destroyed {
448 return
449 }
450 fs.onSearchChanged()
451 }
452 fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
453 fs.searchEntry.ConnectSearchChanged(&changedCb)
454}
455
456func (fs *FavoritesSidebar) onSearchChanged() {
457 fs.mu.Lock()
458 if fs.destroyed {
459 fs.mu.Unlock()
460 return
461 }
462 if fs.searchEntry != nil {
463 fs.currentQuery = fs.searchEntry.GetText()
464 }
465 fs.rebuildDisplayRowsLocked()
466 fs.mu.Unlock()
467 fs.rebuildList()
468}
469
470func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
471 query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
472 for id := range fs.selectedTagIDs {
473 query.TagIDs = append(query.TagIDs, id)
474 }
475 filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
476 fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
477}
478
479// setNoticeLocked records a notice not owned by an in-flight load. fs.mu must be held.
480func (fs *FavoritesSidebar) setNoticeLocked(notice string) {
481 fs.notice = notice
482 fs.noticeLoadGen = 0
483}
484
485func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
486 if fs != nil && fs.idleScheduler != nil {
487 fs.idleScheduler(cb)
488 return
489 }
490 glib.IdleAdd(&cb, 0)
491}