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 without loading
85// data. The initial load runs on the first Show so on-demand instances
86// (see the host ensure methods) issue exactly one GetAll/GetAllTags batch
87// instead of a discarded constructor generation plus the Show reload.
88// Reopening reloads through Show as before.
89func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar {
90 ctx, cancel := context.WithCancel(ctx)
91 fs := &FavoritesSidebar{
92 favoritesUC: cfg.FavoritesUC,
93 onNavigate: cfg.OnNavigate,
94 onNavigateKeepOpen: cfg.OnNavigateKeepOpen,
95 onOpenInNewPane: cfg.OnOpenInNewPane,
96 onClose: cfg.OnClose,
97 selectedTagIDs: make(map[entity.TagID]struct{}),
98 ctx: ctx,
99 cancel: cancel,
100 }
101 if err := fs.createWidgets(); err != nil {
102 cancel()
103 return nil
104 }
105 fs.setupSearchHandler()
106 fs.setupKeyboardNavigation()
107 return fs
108}
109
110// Widget returns the raw GTK widget.
111func (fs *FavoritesSidebar) Widget() *gtk.Widget {
112 if fs == nil || fs.outerBox == nil {
113 return nil
114 }
115 return &fs.outerBox.Widget
116}
117
118// Show displays the sidebar and focuses search. Nil-safe.
119func (fs *FavoritesSidebar) Show() {
120 if fs == nil {
121 return
122 }
123 fs.mu.Lock()
124 if fs.destroyed || fs.outerBox == nil {
125 fs.mu.Unlock()
126 return
127 }
128 fs.outerBox.SetVisible(true)
129 fs.visible = true
130 fs.mu.Unlock()
131 fs.startLoad()
132 fs.focusSearch()
133}
134
135// Hide hides the sidebar. Nil-safe.
136func (fs *FavoritesSidebar) Hide() {
137 if fs == nil {
138 return
139 }
140 fs.mu.Lock()
141 if fs.destroyed {
142 fs.mu.Unlock()
143 return
144 }
145 if fs.outerBox != nil {
146 fs.outerBox.SetVisible(false)
147 }
148 fs.visible = false
149 fs.mu.Unlock()
150}
151
152// Destroy cancels background work and marks the sidebar destroyed. Nil-safe.
153func (fs *FavoritesSidebar) Destroy() {
154 if fs == nil {
155 return
156 }
157 fs.mu.Lock()
158 if fs.destroyed {
159 fs.mu.Unlock()
160 return
161 }
162 fs.destroyed = true
163 fs.mu.Unlock()
164 if fs.cancel != nil {
165 fs.cancel()
166 }
167}
168
169func (fs *FavoritesSidebar) createWidgets() error {
170 if err := fs.createOuterAndSearch(); err != nil {
171 return err
172 }
173 if err := fs.createTagControls(); err != nil {
174 return err
175 }
176
177 fs.scrolledWin = gtk.NewScrolledWindow()
178 if fs.scrolledWin == nil {
179 return fmt.Errorf("favorites sidebar: scrolled window creation failed")
180 }
181 fs.scrolledWin.SetVexpand(true)
182 fs.scrolledWin.SetHexpand(true)
183 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
184 fs.scrolledWin.AddCssClass("sidebar-list")
185
186 fs.listBox = gtk.NewListBox()
187 if fs.listBox == nil {
188 return fmt.Errorf("favorites sidebar: list box creation failed")
189 }
190 fs.listBox.AddCssClass("sidebar-list")
191 fs.listBox.AddCssClass("favorites-sidebar-list")
192 fs.trackListFocus(&fs.listBox.Widget)
193 fs.listBox.SetActivateOnSingleClick(true)
194 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
195 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
196 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
197 fs.onRowActivated(row)
198 }
199 fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb)
200 fs.listBox.ConnectRowActivated(&rowActivatedCb)
201
202 fs.scrolledWin.SetChild(&fs.listBox.Widget)
203 fs.outerBox.Append(&fs.scrolledWin.Widget)
204
205 fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4)
206 if fs.formBox == nil {
207 return fmt.Errorf("favorites sidebar: form box creation failed")
208 }
209 fs.formBox.AddCssClass("favorites-sidebar-form")
210 fs.formBox.SetVisible(false)
211 fs.outerBox.Append(&fs.formBox.Widget)
212 return nil
213}
214
215func (fs *FavoritesSidebar) createOuterAndSearch() error {
216 fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
217 if fs.outerBox == nil {
218 return fmt.Errorf("favorites sidebar: outer box creation failed")
219 }
220 fs.outerBox.AddCssClass("sidebar-outer")
221 fs.outerBox.AddCssClass("favorites-sidebar-outer")
222 fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
223 fs.outerBox.SetHexpand(false)
224 fs.outerBox.SetVexpand(true)
225 fs.outerBox.SetVisible(false)
226
227 searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
228 if searchBox == nil {
229 return fmt.Errorf("favorites sidebar: search box creation failed")
230 }
231 searchBox.AddCssClass("sidebar-search-box")
232 searchBox.SetHexpand(true)
233 fs.searchEntry = gtk.NewSearchEntry()
234 if fs.searchEntry == nil {
235 return fmt.Errorf("favorites sidebar: search entry creation failed")
236 }
237 fs.searchEntry.AddCssClass("sidebar-search")
238 fs.searchEntry.AddCssClass("favorites-sidebar-search")
239 fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
240 fs.searchEntry.SetHexpand(true)
241 placeholder := "Search favorites..."
242 fs.searchEntry.SetPlaceholderText(&placeholder)
243 searchBox.Append(&fs.searchEntry.Widget)
244 fs.outerBox.Append(&searchBox.Widget)
245 return nil
246}
247
248func (fs *FavoritesSidebar) createTagControls() error {
249 fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
250 if fs.tagBox == nil {
251 return fmt.Errorf("favorites sidebar: tag box creation failed")
252 }
253 fs.tagBox.AddCssClass("favorites-sidebar-tags")
254 fs.tagBox.SetHexpand(true)
255 fs.outerBox.Append(&fs.tagBox.Widget)
256 fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
257 if fs.tagPromptBox == nil {
258 return fmt.Errorf("favorites sidebar: tag prompt creation failed")
259 }
260 fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt")
261 fs.tagPromptBox.SetVisible(false)
262 fs.outerBox.Append(&fs.tagPromptBox.Widget)
263 return nil
264}
265
266func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
267 if fs == nil || widget == nil || callbacks == nil {
268 return
269 }
270 controller := gtk.NewEventControllerFocus()
271 if controller == nil {
272 return
273 }
274 entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
275 left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
276 *callbacks = append(*callbacks, entered, left)
277 controller.ConnectEnter(&entered)
278 controller.ConnectLeave(&left)
279 widget.AddController(&controller.EventController)
280}
281
282func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) {
283 if fs == nil || widget == nil {
284 return
285 }
286 fs.mu.Lock()
287 if !fs.destroyed {
288 fs.textInputWidget = widget
289 if fs.searchEntry != nil && widget == &fs.searchEntry.Widget {
290 fs.focusZone = favoritesSidebarFocusSearch
291 } else {
292 fs.focusZone = favoritesSidebarFocusForm
293 }
294 }
295 fs.mu.Unlock()
296}
297
298func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) {
299 if fs == nil || widget == nil {
300 return
301 }
302 controller := gtk.NewEventControllerFocus()
303 if controller == nil {
304 return
305 }
306 entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) }
307 fs.retainedCallbacks = append(fs.retainedCallbacks, entered)
308 controller.ConnectEnter(&entered)
309 widget.AddController(&controller.EventController)
310}
311
312func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) {
313 if fs == nil {
314 return
315 }
316 fs.mu.Lock()
317 if !fs.destroyed {
318 fs.focusZone = zone
319 }
320 fs.mu.Unlock()
321}
322
323func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) {
324 if fs == nil || widget == nil {
325 return
326 }
327 fs.mu.Lock()
328 if !fs.destroyed && fs.textInputWidget == widget {
329 fs.textInputWidget = nil
330 }
331 fs.mu.Unlock()
332}
333
334func (fs *FavoritesSidebar) focusSearch() {
335 if fs == nil {
336 return
337 }
338 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
339 fs.mu.RLock()
340 destroyed := fs.destroyed
341 entry := fs.searchEntry
342 fs.mu.RUnlock()
343 if !destroyed && entry != nil {
344 entry.GrabFocus()
345 }
346 return false
347 }))
348}
349
350// RequestReloadIfVisible refreshes sidebar data when the sidebar is currently visible.
351func (fs *FavoritesSidebar) RequestReloadIfVisible(_ string) {
352 if fs == nil {
353 return
354 }
355 fs.mu.RLock()
356 visible := fs.visible && !fs.destroyed
357 fs.mu.RUnlock()
358 if visible {
359 fs.startLoad()
360 }
361}
362
363func (fs *FavoritesSidebar) startLoad() {
364 if fs == nil {
365 return
366 }
367 fs.mu.Lock()
368 if fs.destroyed {
369 fs.mu.Unlock()
370 return
371 }
372 fs.loadGen++
373 gen := fs.loadGen
374 uc := fs.favoritesUC
375 ctx := fs.ctx
376 visible := fs.visible
377 fs.notice = "Loading favorites..."
378 fs.noticeLoadGen = gen
379 fs.mu.Unlock()
380 if visible {
381 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
382 fs.mu.RLock()
383 destroyed := fs.destroyed
384 fs.mu.RUnlock()
385 if !destroyed {
386 fs.rebuildList()
387 }
388 return false
389 }))
390 }
391
392 if uc == nil || ctx == nil {
393 fs.applyLoadedData(nil, nil, gen, nil)
394 return
395 }
396 go func() {
397 favorites, favErr := uc.GetAll(ctx)
398 tags, tagErr := uc.GetAllTags(ctx)
399 err := favErr
400 if err == nil {
401 err = tagErr
402 }
403 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
404 fs.applyLoadedData(favorites, tags, gen, err)
405 return false
406 }))
407 }()
408}
409
410func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool {
411 if fs == nil {
412 return false
413 }
414 fs.mu.Lock()
415 if fs.destroyed || gen != fs.loadGen {
416 fs.mu.Unlock()
417 return false
418 }
419 if favorites == nil {
420 favorites = []*entity.Favorite{}
421 }
422 if tags == nil {
423 tags = []*entity.Tag{}
424 }
425 fs.allFavorites = favorites
426 fs.allTags = tags
427 if fs.noticeLoadGen == gen {
428 fs.noticeLoadGen = 0
429 if err != nil {
430 fs.notice = err.Error()
431 } else {
432 fs.notice = ""
433 }
434 }
435 fs.rebuildDisplayRowsLocked()
436 fs.mu.Unlock()
437 fs.renderTags()
438 fs.rebuildList()
439 return true
440}
441
442func (fs *FavoritesSidebar) setupSearchHandler() {
443 if fs == nil || fs.searchEntry == nil {
444 return
445 }
446 changedCb := func(_ gtk.SearchEntry) {
447 fs.mu.RLock()
448 destroyed := fs.destroyed
449 fs.mu.RUnlock()
450 if destroyed {
451 return
452 }
453 fs.onSearchChanged()
454 }
455 fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb)
456 fs.searchEntry.ConnectSearchChanged(&changedCb)
457}
458
459func (fs *FavoritesSidebar) onSearchChanged() {
460 fs.mu.Lock()
461 if fs.destroyed {
462 fs.mu.Unlock()
463 return
464 }
465 if fs.searchEntry != nil {
466 fs.currentQuery = fs.searchEntry.GetText()
467 }
468 fs.rebuildDisplayRowsLocked()
469 fs.mu.Unlock()
470 fs.rebuildList()
471}
472
473func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
474 query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery}
475 for id := range fs.selectedTagIDs {
476 query.TagIDs = append(query.TagIDs, id)
477 }
478 filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query)
479 fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
480}
481
482// setNoticeLocked records a notice not owned by an in-flight load. fs.mu must be held.
483func (fs *FavoritesSidebar) setNoticeLocked(notice string) {
484 fs.notice = notice
485 fs.noticeLoadGen = 0
486}
487
488func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
489 if fs != nil && fs.idleScheduler != nil {
490 fs.idleScheduler(cb)
491 return
492 }
493 glib.IdleAdd(&cb, 0)
494}