package component import ( "context" "fmt" "sync" "github.com/bnema/puregotk/v4/glib" "github.com/bnema/puregotk/v4/gtk" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/application/usecase" "github.com/bnema/dumber/internal/domain/entity" ) // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar. type FavoritesSidebarConfig struct { FavoritesUC port.FavoritesSidebarFavorites OnNavigate func(ctx context.Context, url string) error OnNavigateKeepOpen func(ctx context.Context, url string) error OnOpenInNewPane func(ctx context.Context, url string) error OnClose func() } // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites. type FavoritesSidebar struct { outerBox *gtk.Box searchEntry *gtk.SearchEntry tagBox *gtk.Box tagControls []*gtk.Button tagPromptBox *gtk.Box tagNameEntry *gtk.Entry tagPromptSaveBtn *gtk.Button scrolledWin *gtk.ScrolledWindow listBox *gtk.ListBox formBox *gtk.Box formURLEntry *gtk.SearchEntry formTitleEntry *gtk.SearchEntry formTagSearch *gtk.SearchEntry formTagMatches *gtk.Box formShortcutEntry *gtk.SearchEntry formSaveButton *gtk.Button formURL string formTitle string formShortcut string formTagIDs map[entity.TagID]struct{} favoritesUC port.FavoritesSidebarFavorites onNavigate func(ctx context.Context, url string) error onNavigateKeepOpen func(ctx context.Context, url string) error onOpenInNewPane func(ctx context.Context, url string) error onClose func() allFavorites []*entity.Favorite allTags []*entity.Tag selectedTagIDs map[entity.TagID]struct{} displayRows []favoriteSidebarDisplayRow currentQuery string notice string noticeLoadGen uint64 loadGen uint64 destroyed bool visible bool focusZone favoritesSidebarFocusZone textInputWidget *gtk.Widget mode favoritesSidebarMode editingID entity.FavoriteID confirmDelete bool confirmDeleteID entity.FavoriteID retainedCallbacks []any tagCallbacks []any tagPromptCallbacks []any formCallbacks []any formTagMatchCallbacks []any ctx context.Context cancel context.CancelFunc mu sync.RWMutex idleScheduler func(glib.SourceFunc) } // NewFavoritesSidebar creates a native favorites sidebar without loading // data. The initial load runs on the first Show so on-demand instances // (see the host ensure methods) issue exactly one GetAll/GetAllTags batch // instead of a discarded constructor generation plus the Show reload. // Reopening reloads through Show as before. func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar { ctx, cancel := context.WithCancel(ctx) fs := &FavoritesSidebar{ favoritesUC: cfg.FavoritesUC, onNavigate: cfg.OnNavigate, onNavigateKeepOpen: cfg.OnNavigateKeepOpen, onOpenInNewPane: cfg.OnOpenInNewPane, onClose: cfg.OnClose, selectedTagIDs: make(map[entity.TagID]struct{}), ctx: ctx, cancel: cancel, } if err := fs.createWidgets(); err != nil { cancel() return nil } fs.setupSearchHandler() fs.setupKeyboardNavigation() return fs } // Widget returns the raw GTK widget. func (fs *FavoritesSidebar) Widget() *gtk.Widget { if fs == nil || fs.outerBox == nil { return nil } return &fs.outerBox.Widget } // Show displays the sidebar and focuses search. Nil-safe. func (fs *FavoritesSidebar) Show() { if fs == nil { return } fs.mu.Lock() if fs.destroyed || fs.outerBox == nil { fs.mu.Unlock() return } fs.outerBox.SetVisible(true) fs.visible = true fs.mu.Unlock() fs.startLoad() fs.focusSearch() } // Hide hides the sidebar. Nil-safe. func (fs *FavoritesSidebar) Hide() { if fs == nil { return } fs.mu.Lock() if fs.destroyed { fs.mu.Unlock() return } if fs.outerBox != nil { fs.outerBox.SetVisible(false) } fs.visible = false fs.mu.Unlock() } // Destroy cancels background work and marks the sidebar destroyed. Nil-safe. func (fs *FavoritesSidebar) Destroy() { if fs == nil { return } fs.mu.Lock() if fs.destroyed { fs.mu.Unlock() return } fs.destroyed = true fs.mu.Unlock() if fs.cancel != nil { fs.cancel() } } func (fs *FavoritesSidebar) createWidgets() error { if err := fs.createOuterAndSearch(); err != nil { return err } if err := fs.createTagControls(); err != nil { return err } fs.scrolledWin = gtk.NewScrolledWindow() if fs.scrolledWin == nil { return fmt.Errorf("favorites sidebar: scrolled window creation failed") } fs.scrolledWin.SetVexpand(true) fs.scrolledWin.SetHexpand(true) fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) fs.scrolledWin.AddCssClass("sidebar-list") fs.listBox = gtk.NewListBox() if fs.listBox == nil { return fmt.Errorf("favorites sidebar: list box creation failed") } fs.listBox.AddCssClass("sidebar-list") fs.listBox.AddCssClass("favorites-sidebar-list") fs.trackListFocus(&fs.listBox.Widget) fs.listBox.SetActivateOnSingleClick(true) fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) fs.onRowActivated(row) } fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb) fs.listBox.ConnectRowActivated(&rowActivatedCb) fs.scrolledWin.SetChild(&fs.listBox.Widget) fs.outerBox.Append(&fs.scrolledWin.Widget) fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4) if fs.formBox == nil { return fmt.Errorf("favorites sidebar: form box creation failed") } fs.formBox.AddCssClass("favorites-sidebar-form") fs.formBox.SetVisible(false) fs.outerBox.Append(&fs.formBox.Widget) return nil } func (fs *FavoritesSidebar) createOuterAndSearch() error { fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) if fs.outerBox == nil { return fmt.Errorf("favorites sidebar: outer box creation failed") } fs.outerBox.AddCssClass("sidebar-outer") fs.outerBox.AddCssClass("favorites-sidebar-outer") fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) fs.outerBox.SetHexpand(false) fs.outerBox.SetVexpand(true) fs.outerBox.SetVisible(false) searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4) if searchBox == nil { return fmt.Errorf("favorites sidebar: search box creation failed") } searchBox.AddCssClass("sidebar-search-box") searchBox.SetHexpand(true) fs.searchEntry = gtk.NewSearchEntry() if fs.searchEntry == nil { return fmt.Errorf("favorites sidebar: search entry creation failed") } fs.searchEntry.AddCssClass("sidebar-search") fs.searchEntry.AddCssClass("favorites-sidebar-search") fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks) fs.searchEntry.SetHexpand(true) placeholder := "Search favorites..." fs.searchEntry.SetPlaceholderText(&placeholder) searchBox.Append(&fs.searchEntry.Widget) fs.outerBox.Append(&searchBox.Widget) return nil } func (fs *FavoritesSidebar) createTagControls() error { fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) if fs.tagBox == nil { return fmt.Errorf("favorites sidebar: tag box creation failed") } fs.tagBox.AddCssClass("favorites-sidebar-tags") fs.tagBox.SetHexpand(true) fs.outerBox.Append(&fs.tagBox.Widget) fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) if fs.tagPromptBox == nil { return fmt.Errorf("favorites sidebar: tag prompt creation failed") } fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt") fs.tagPromptBox.SetVisible(false) fs.outerBox.Append(&fs.tagPromptBox.Widget) return nil } func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) { if fs == nil || widget == nil || callbacks == nil { return } controller := gtk.NewEventControllerFocus() if controller == nil { return } entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) } left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) } *callbacks = append(*callbacks, entered, left) controller.ConnectEnter(&entered) controller.ConnectLeave(&left) widget.AddController(&controller.EventController) } func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) { if fs == nil || widget == nil { return } fs.mu.Lock() if !fs.destroyed { fs.textInputWidget = widget if fs.searchEntry != nil && widget == &fs.searchEntry.Widget { fs.focusZone = favoritesSidebarFocusSearch } else { fs.focusZone = favoritesSidebarFocusForm } } fs.mu.Unlock() } func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) { if fs == nil || widget == nil { return } controller := gtk.NewEventControllerFocus() if controller == nil { return } entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) } fs.retainedCallbacks = append(fs.retainedCallbacks, entered) controller.ConnectEnter(&entered) widget.AddController(&controller.EventController) } func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) { if fs == nil { return } fs.mu.Lock() if !fs.destroyed { fs.focusZone = zone } fs.mu.Unlock() } func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) { if fs == nil || widget == nil { return } fs.mu.Lock() if !fs.destroyed && fs.textInputWidget == widget { fs.textInputWidget = nil } fs.mu.Unlock() } func (fs *FavoritesSidebar) focusSearch() { if fs == nil { return } fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { fs.mu.RLock() destroyed := fs.destroyed entry := fs.searchEntry fs.mu.RUnlock() if !destroyed && entry != nil { entry.GrabFocus() } return false })) } // RequestReloadIfVisible refreshes sidebar data when the sidebar is currently visible. func (fs *FavoritesSidebar) RequestReloadIfVisible(_ string) { if fs == nil { return } fs.mu.RLock() visible := fs.visible && !fs.destroyed fs.mu.RUnlock() if visible { fs.startLoad() } } func (fs *FavoritesSidebar) startLoad() { if fs == nil { return } fs.mu.Lock() if fs.destroyed { fs.mu.Unlock() return } fs.loadGen++ gen := fs.loadGen uc := fs.favoritesUC ctx := fs.ctx visible := fs.visible fs.notice = "Loading favorites..." fs.noticeLoadGen = gen fs.mu.Unlock() if visible { fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { fs.mu.RLock() destroyed := fs.destroyed fs.mu.RUnlock() if !destroyed { fs.rebuildList() } return false })) } if uc == nil || ctx == nil { fs.applyLoadedData(nil, nil, gen, nil) return } go func() { favorites, favErr := uc.GetAll(ctx) tags, tagErr := uc.GetAllTags(ctx) err := favErr if err == nil { err = tagErr } fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { fs.applyLoadedData(favorites, tags, gen, err) return false })) }() } func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool { if fs == nil { return false } fs.mu.Lock() if fs.destroyed || gen != fs.loadGen { fs.mu.Unlock() return false } if favorites == nil { favorites = []*entity.Favorite{} } if tags == nil { tags = []*entity.Tag{} } fs.allFavorites = favorites fs.allTags = tags if fs.noticeLoadGen == gen { fs.noticeLoadGen = 0 if err != nil { fs.notice = err.Error() } else { fs.notice = "" } } fs.rebuildDisplayRowsLocked() fs.mu.Unlock() fs.renderTags() fs.rebuildList() return true } func (fs *FavoritesSidebar) setupSearchHandler() { if fs == nil || fs.searchEntry == nil { return } changedCb := func(_ gtk.SearchEntry) { fs.mu.RLock() destroyed := fs.destroyed fs.mu.RUnlock() if destroyed { return } fs.onSearchChanged() } fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb) fs.searchEntry.ConnectSearchChanged(&changedCb) } func (fs *FavoritesSidebar) onSearchChanged() { fs.mu.Lock() if fs.destroyed { fs.mu.Unlock() return } if fs.searchEntry != nil { fs.currentQuery = fs.searchEntry.GetText() } fs.rebuildDisplayRowsLocked() fs.mu.Unlock() fs.rebuildList() } func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() { query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery} for id := range fs.selectedTagIDs { query.TagIDs = append(query.TagIDs, id) } filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query) fs.displayRows = buildFavoriteSidebarDisplayRows(filtered) } // setNoticeLocked records a notice not owned by an in-flight load. fs.mu must be held. func (fs *FavoritesSidebar) setNoticeLocked(notice string) { fs.notice = notice fs.noticeLoadGen = 0 } func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) { if fs != nil && fs.idleScheduler != nil { fs.idleScheduler(cb) return } glib.IdleAdd(&cb, 0) }