feat(favorites): add native sidebar browser
- commit
- 5b8fa27461a7183de792694928ef4acc46a7b7c4
- author
- brice · · 2026-06-28 15:32 UTC
- committer
- brice · · 2026-06-28 15:44 UTC
- parent
- 4a7ea51
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +1124 -0
A internal/ui/component/favorites_sidebar.go +320 -0
| @@ -0,0 +1,320 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 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. | |
| 17 | + | type 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. | |
| 26 | + | type FavoritesSidebar struct { | |
| 27 | + | outerBox *gtk.Box | |
| 28 | + | searchEntry *gtk.SearchEntry | |
| 29 | + | tagBox *gtk.Box | |
| 30 | + | scrolledWin *gtk.ScrolledWindow | |
| 31 | + | listBox *gtk.ListBox | |
| 32 | + | ||
| 33 | + | favoritesUC port.FavoritesSidebarFavorites | |
| 34 | + | onNavigate func(ctx context.Context, url string) error | |
| 35 | + | onNavigateKeepOpen func(ctx context.Context, url string) error | |
| 36 | + | onOpenInNewPane func(ctx context.Context, url string) error | |
| 37 | + | onClose func() | |
| 38 | + | ||
| 39 | + | allFavorites []*entity.Favorite | |
| 40 | + | allTags []*entity.Tag | |
| 41 | + | selectedTagIDs map[entity.TagID]struct{} | |
| 42 | + | displayRows []favoriteSidebarDisplayRow | |
| 43 | + | currentQuery string | |
| 44 | + | notice string | |
| 45 | + | loadGen uint64 | |
| 46 | + | destroyed bool | |
| 47 | + | visible bool | |
| 48 | + | ||
| 49 | + | retainedCallbacks []interface{} | |
| 50 | + | tagCallbacks []interface{} | |
| 51 | + | ctx context.Context | |
| 52 | + | cancel context.CancelFunc | |
| 53 | + | mu sync.RWMutex | |
| 54 | + | ||
| 55 | + | idleScheduler func(glib.SourceFunc) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // NewFavoritesSidebar creates a native favorites sidebar and starts loading data. | |
| 59 | + | func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar { | |
| 60 | + | ctx, cancel := context.WithCancel(ctx) | |
| 61 | + | fs := &FavoritesSidebar{ | |
| 62 | + | favoritesUC: cfg.FavoritesUC, | |
| 63 | + | onNavigate: cfg.OnNavigate, | |
| 64 | + | onNavigateKeepOpen: cfg.OnNavigateKeepOpen, | |
| 65 | + | onOpenInNewPane: cfg.OnOpenInNewPane, | |
| 66 | + | onClose: cfg.OnClose, | |
| 67 | + | selectedTagIDs: make(map[entity.TagID]struct{}), | |
| 68 | + | ctx: ctx, | |
| 69 | + | cancel: cancel, | |
| 70 | + | } | |
| 71 | + | if err := fs.createWidgets(); err != nil { | |
| 72 | + | cancel() | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | fs.setupSearchHandler() | |
| 76 | + | fs.setupKeyboardNavigation() | |
| 77 | + | fs.startLoad() | |
| 78 | + | return fs | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Widget returns the raw GTK widget. | |
| 82 | + | func (fs *FavoritesSidebar) Widget() *gtk.Widget { | |
| 83 | + | if fs == nil || fs.outerBox == nil { | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | return &fs.outerBox.Widget | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Show displays the sidebar and focuses search. Nil-safe. | |
| 90 | + | func (fs *FavoritesSidebar) Show() { | |
| 91 | + | if fs == nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | fs.mu.Lock() | |
| 95 | + | if fs.destroyed || fs.outerBox == nil { | |
| 96 | + | fs.mu.Unlock() | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | fs.outerBox.SetVisible(true) | |
| 100 | + | fs.visible = true | |
| 101 | + | fs.mu.Unlock() | |
| 102 | + | fs.focusSearch() | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Hide hides the sidebar. Nil-safe. | |
| 106 | + | func (fs *FavoritesSidebar) Hide() { | |
| 107 | + | if fs == nil { | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | fs.mu.Lock() | |
| 111 | + | if fs.destroyed { | |
| 112 | + | fs.mu.Unlock() | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | if fs.outerBox != nil { | |
| 116 | + | fs.outerBox.SetVisible(false) | |
| 117 | + | } | |
| 118 | + | fs.visible = false | |
| 119 | + | fs.mu.Unlock() | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Destroy cancels background work and marks the sidebar destroyed. Nil-safe. | |
| 123 | + | func (fs *FavoritesSidebar) Destroy() { | |
| 124 | + | if fs == nil { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | fs.mu.Lock() | |
| 128 | + | if fs.destroyed { | |
| 129 | + | fs.mu.Unlock() | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | fs.destroyed = true | |
| 133 | + | fs.mu.Unlock() | |
| 134 | + | if fs.cancel != nil { | |
| 135 | + | fs.cancel() | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (fs *FavoritesSidebar) createWidgets() error { | |
| 140 | + | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 6) | |
| 141 | + | if fs.outerBox == nil { | |
| 142 | + | return fmt.Errorf("favorites sidebar: outer box creation failed") | |
| 143 | + | } | |
| 144 | + | fs.outerBox.AddCssClass("favorites-sidebar-outer") | |
| 145 | + | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |
| 146 | + | fs.outerBox.SetHexpand(false) | |
| 147 | + | fs.outerBox.SetVexpand(true) | |
| 148 | + | fs.outerBox.SetVisible(false) | |
| 149 | + | ||
| 150 | + | fs.searchEntry = gtk.NewSearchEntry() | |
| 151 | + | if fs.searchEntry == nil { | |
| 152 | + | return fmt.Errorf("favorites sidebar: search entry creation failed") | |
| 153 | + | } | |
| 154 | + | fs.searchEntry.AddCssClass("favorites-sidebar-search") | |
| 155 | + | fs.searchEntry.SetHexpand(true) | |
| 156 | + | placeholder := "Search favorites" | |
| 157 | + | fs.searchEntry.SetPlaceholderText(&placeholder) | |
| 158 | + | fs.outerBox.Append(&fs.searchEntry.Widget) | |
| 159 | + | ||
| 160 | + | fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 161 | + | if fs.tagBox == nil { | |
| 162 | + | return fmt.Errorf("favorites sidebar: tag box creation failed") | |
| 163 | + | } | |
| 164 | + | fs.tagBox.AddCssClass("favorites-sidebar-tags") | |
| 165 | + | fs.tagBox.SetHexpand(true) | |
| 166 | + | fs.outerBox.Append(&fs.tagBox.Widget) | |
| 167 | + | ||
| 168 | + | fs.scrolledWin = gtk.NewScrolledWindow() | |
| 169 | + | if fs.scrolledWin == nil { | |
| 170 | + | return fmt.Errorf("favorites sidebar: scrolled window creation failed") | |
| 171 | + | } | |
| 172 | + | fs.scrolledWin.SetVexpand(true) | |
| 173 | + | fs.scrolledWin.SetHexpand(true) | |
| 174 | + | fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 175 | + | ||
| 176 | + | fs.listBox = gtk.NewListBox() | |
| 177 | + | if fs.listBox == nil { | |
| 178 | + | return fmt.Errorf("favorites sidebar: list box creation failed") | |
| 179 | + | } | |
| 180 | + | fs.listBox.AddCssClass("favorites-sidebar-list") | |
| 181 | + | fs.listBox.SetActivateOnSingleClick(true) | |
| 182 | + | fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 183 | + | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 184 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 185 | + | fs.onRowActivated(row) | |
| 186 | + | } | |
| 187 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb) | |
| 188 | + | fs.listBox.ConnectRowActivated(&rowActivatedCb) | |
| 189 | + | ||
| 190 | + | fs.scrolledWin.SetChild(&fs.listBox.Widget) | |
| 191 | + | fs.outerBox.Append(&fs.scrolledWin.Widget) | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (fs *FavoritesSidebar) focusSearch() { | |
| 196 | + | if fs == nil { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| 200 | + | fs.mu.RLock() | |
| 201 | + | destroyed := fs.destroyed | |
| 202 | + | entry := fs.searchEntry | |
| 203 | + | fs.mu.RUnlock() | |
| 204 | + | if !destroyed && entry != nil { | |
| 205 | + | entry.GrabFocus() | |
| 206 | + | } | |
| 207 | + | return false | |
| 208 | + | })) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (fs *FavoritesSidebar) startLoad() { | |
| 212 | + | if fs == nil { | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | fs.mu.Lock() | |
| 216 | + | if fs.destroyed { | |
| 217 | + | fs.mu.Unlock() | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | fs.loadGen++ | |
| 221 | + | gen := fs.loadGen | |
| 222 | + | uc := fs.favoritesUC | |
| 223 | + | ctx := fs.ctx | |
| 224 | + | fs.notice = "Loading favorites..." | |
| 225 | + | fs.mu.Unlock() | |
| 226 | + | ||
| 227 | + | if uc == nil || ctx == nil { | |
| 228 | + | fs.applyLoadedData(nil, nil, gen, nil) | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | go func() { | |
| 232 | + | favorites, favErr := uc.GetAll(ctx) | |
| 233 | + | tags, tagErr := uc.GetAllTags(ctx) | |
| 234 | + | err := favErr | |
| 235 | + | if err == nil { | |
| 236 | + | err = tagErr | |
| 237 | + | } | |
| 238 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| 239 | + | fs.applyLoadedData(favorites, tags, gen, err) | |
| 240 | + | return false | |
| 241 | + | })) | |
| 242 | + | }() | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool { | |
| 246 | + | if fs == nil { | |
| 247 | + | return false | |
| 248 | + | } | |
| 249 | + | fs.mu.Lock() | |
| 250 | + | if fs.destroyed || gen != fs.loadGen { | |
| 251 | + | fs.mu.Unlock() | |
| 252 | + | return false | |
| 253 | + | } | |
| 254 | + | if favorites == nil { | |
| 255 | + | favorites = []*entity.Favorite{} | |
| 256 | + | } | |
| 257 | + | if tags == nil { | |
| 258 | + | tags = []*entity.Tag{} | |
| 259 | + | } | |
| 260 | + | fs.allFavorites = favorites | |
| 261 | + | fs.allTags = tags | |
| 262 | + | if err != nil { | |
| 263 | + | fs.notice = err.Error() | |
| 264 | + | } else { | |
| 265 | + | fs.notice = "" | |
| 266 | + | } | |
| 267 | + | fs.rebuildDisplayRowsLocked() | |
| 268 | + | fs.mu.Unlock() | |
| 269 | + | fs.renderTags() | |
| 270 | + | fs.rebuildList() | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (fs *FavoritesSidebar) setupSearchHandler() { | |
| 275 | + | if fs == nil || fs.searchEntry == nil { | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | changedCb := func(_ gtk.SearchEntry) { | |
| 279 | + | fs.mu.RLock() | |
| 280 | + | destroyed := fs.destroyed | |
| 281 | + | fs.mu.RUnlock() | |
| 282 | + | if destroyed { | |
| 283 | + | return | |
| 284 | + | } | |
| 285 | + | fs.onSearchChanged() | |
| 286 | + | } | |
| 287 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb) | |
| 288 | + | fs.searchEntry.ConnectSearchChanged(&changedCb) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (fs *FavoritesSidebar) onSearchChanged() { | |
| 292 | + | fs.mu.Lock() | |
| 293 | + | if fs.destroyed { | |
| 294 | + | fs.mu.Unlock() | |
| 295 | + | return | |
| 296 | + | } | |
| 297 | + | if fs.searchEntry != nil { | |
| 298 | + | fs.currentQuery = fs.searchEntry.GetText() | |
| 299 | + | } | |
| 300 | + | fs.rebuildDisplayRowsLocked() | |
| 301 | + | fs.mu.Unlock() | |
| 302 | + | fs.rebuildList() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() { | |
| 306 | + | query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery} | |
| 307 | + | for id := range fs.selectedTagIDs { | |
| 308 | + | query.TagIDs = append(query.TagIDs, id) | |
| 309 | + | } | |
| 310 | + | filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query) | |
| 311 | + | fs.displayRows = buildFavoriteSidebarDisplayRows(filtered) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) { | |
| 315 | + | if fs != nil && fs.idleScheduler != nil { | |
| 316 | + | fs.idleScheduler(cb) | |
| 317 | + | return | |
| 318 | + | } | |
| 319 | + | glib.IdleAdd(&cb, 0) | |
| 320 | + | } | |
| @@ -0,0 +1,320 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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. | |||
| 17 | + | type 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. | |||
| 26 | + | type FavoritesSidebar struct { | |||
| 27 | + | outerBox *gtk.Box | |||
| 28 | + | searchEntry *gtk.SearchEntry | |||
| 29 | + | tagBox *gtk.Box | |||
| 30 | + | scrolledWin *gtk.ScrolledWindow | |||
| 31 | + | listBox *gtk.ListBox | |||
| 32 | + | ||||
| 33 | + | favoritesUC port.FavoritesSidebarFavorites | |||
| 34 | + | onNavigate func(ctx context.Context, url string) error | |||
| 35 | + | onNavigateKeepOpen func(ctx context.Context, url string) error | |||
| 36 | + | onOpenInNewPane func(ctx context.Context, url string) error | |||
| 37 | + | onClose func() | |||
| 38 | + | ||||
| 39 | + | allFavorites []*entity.Favorite | |||
| 40 | + | allTags []*entity.Tag | |||
| 41 | + | selectedTagIDs map[entity.TagID]struct{} | |||
| 42 | + | displayRows []favoriteSidebarDisplayRow | |||
| 43 | + | currentQuery string | |||
| 44 | + | notice string | |||
| 45 | + | loadGen uint64 | |||
| 46 | + | destroyed bool | |||
| 47 | + | visible bool | |||
| 48 | + | ||||
| 49 | + | retainedCallbacks []interface{} | |||
| 50 | + | tagCallbacks []interface{} | |||
| 51 | + | ctx context.Context | |||
| 52 | + | cancel context.CancelFunc | |||
| 53 | + | mu sync.RWMutex | |||
| 54 | + | ||||
| 55 | + | idleScheduler func(glib.SourceFunc) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // NewFavoritesSidebar creates a native favorites sidebar and starts loading data. | |||
| 59 | + | func NewFavoritesSidebar(ctx context.Context, cfg FavoritesSidebarConfig) *FavoritesSidebar { | |||
| 60 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 61 | + | fs := &FavoritesSidebar{ | |||
| 62 | + | favoritesUC: cfg.FavoritesUC, | |||
| 63 | + | onNavigate: cfg.OnNavigate, | |||
| 64 | + | onNavigateKeepOpen: cfg.OnNavigateKeepOpen, | |||
| 65 | + | onOpenInNewPane: cfg.OnOpenInNewPane, | |||
| 66 | + | onClose: cfg.OnClose, | |||
| 67 | + | selectedTagIDs: make(map[entity.TagID]struct{}), | |||
| 68 | + | ctx: ctx, | |||
| 69 | + | cancel: cancel, | |||
| 70 | + | } | |||
| 71 | + | if err := fs.createWidgets(); err != nil { | |||
| 72 | + | cancel() | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | fs.setupSearchHandler() | |||
| 76 | + | fs.setupKeyboardNavigation() | |||
| 77 | + | fs.startLoad() | |||
| 78 | + | return fs | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Widget returns the raw GTK widget. | |||
| 82 | + | func (fs *FavoritesSidebar) Widget() *gtk.Widget { | |||
| 83 | + | if fs == nil || fs.outerBox == nil { | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | return &fs.outerBox.Widget | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Show displays the sidebar and focuses search. Nil-safe. | |||
| 90 | + | func (fs *FavoritesSidebar) Show() { | |||
| 91 | + | if fs == nil { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | fs.mu.Lock() | |||
| 95 | + | if fs.destroyed || fs.outerBox == nil { | |||
| 96 | + | fs.mu.Unlock() | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | fs.outerBox.SetVisible(true) | |||
| 100 | + | fs.visible = true | |||
| 101 | + | fs.mu.Unlock() | |||
| 102 | + | fs.focusSearch() | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Hide hides the sidebar. Nil-safe. | |||
| 106 | + | func (fs *FavoritesSidebar) Hide() { | |||
| 107 | + | if fs == nil { | |||
| 108 | + | return | |||
| 109 | + | } | |||
| 110 | + | fs.mu.Lock() | |||
| 111 | + | if fs.destroyed { | |||
| 112 | + | fs.mu.Unlock() | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | if fs.outerBox != nil { | |||
| 116 | + | fs.outerBox.SetVisible(false) | |||
| 117 | + | } | |||
| 118 | + | fs.visible = false | |||
| 119 | + | fs.mu.Unlock() | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Destroy cancels background work and marks the sidebar destroyed. Nil-safe. | |||
| 123 | + | func (fs *FavoritesSidebar) Destroy() { | |||
| 124 | + | if fs == nil { | |||
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | fs.mu.Lock() | |||
| 128 | + | if fs.destroyed { | |||
| 129 | + | fs.mu.Unlock() | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | fs.destroyed = true | |||
| 133 | + | fs.mu.Unlock() | |||
| 134 | + | if fs.cancel != nil { | |||
| 135 | + | fs.cancel() | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (fs *FavoritesSidebar) createWidgets() error { | |||
| 140 | + | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 6) | |||
| 141 | + | if fs.outerBox == nil { | |||
| 142 | + | return fmt.Errorf("favorites sidebar: outer box creation failed") | |||
| 143 | + | } | |||
| 144 | + | fs.outerBox.AddCssClass("favorites-sidebar-outer") | |||
| 145 | + | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |||
| 146 | + | fs.outerBox.SetHexpand(false) | |||
| 147 | + | fs.outerBox.SetVexpand(true) | |||
| 148 | + | fs.outerBox.SetVisible(false) | |||
| 149 | + | ||||
| 150 | + | fs.searchEntry = gtk.NewSearchEntry() | |||
| 151 | + | if fs.searchEntry == nil { | |||
| 152 | + | return fmt.Errorf("favorites sidebar: search entry creation failed") | |||
| 153 | + | } | |||
| 154 | + | fs.searchEntry.AddCssClass("favorites-sidebar-search") | |||
| 155 | + | fs.searchEntry.SetHexpand(true) | |||
| 156 | + | placeholder := "Search favorites" | |||
| 157 | + | fs.searchEntry.SetPlaceholderText(&placeholder) | |||
| 158 | + | fs.outerBox.Append(&fs.searchEntry.Widget) | |||
| 159 | + | ||||
| 160 | + | fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |||
| 161 | + | if fs.tagBox == nil { | |||
| 162 | + | return fmt.Errorf("favorites sidebar: tag box creation failed") | |||
| 163 | + | } | |||
| 164 | + | fs.tagBox.AddCssClass("favorites-sidebar-tags") | |||
| 165 | + | fs.tagBox.SetHexpand(true) | |||
| 166 | + | fs.outerBox.Append(&fs.tagBox.Widget) | |||
| 167 | + | ||||
| 168 | + | fs.scrolledWin = gtk.NewScrolledWindow() | |||
| 169 | + | if fs.scrolledWin == nil { | |||
| 170 | + | return fmt.Errorf("favorites sidebar: scrolled window creation failed") | |||
| 171 | + | } | |||
| 172 | + | fs.scrolledWin.SetVexpand(true) | |||
| 173 | + | fs.scrolledWin.SetHexpand(true) | |||
| 174 | + | fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |||
| 175 | + | ||||
| 176 | + | fs.listBox = gtk.NewListBox() | |||
| 177 | + | if fs.listBox == nil { | |||
| 178 | + | return fmt.Errorf("favorites sidebar: list box creation failed") | |||
| 179 | + | } | |||
| 180 | + | fs.listBox.AddCssClass("favorites-sidebar-list") | |||
| 181 | + | fs.listBox.SetActivateOnSingleClick(true) | |||
| 182 | + | fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |||
| 183 | + | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |||
| 184 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |||
| 185 | + | fs.onRowActivated(row) | |||
| 186 | + | } | |||
| 187 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, rowActivatedCb) | |||
| 188 | + | fs.listBox.ConnectRowActivated(&rowActivatedCb) | |||
| 189 | + | ||||
| 190 | + | fs.scrolledWin.SetChild(&fs.listBox.Widget) | |||
| 191 | + | fs.outerBox.Append(&fs.scrolledWin.Widget) | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func (fs *FavoritesSidebar) focusSearch() { | |||
| 196 | + | if fs == nil { | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |||
| 200 | + | fs.mu.RLock() | |||
| 201 | + | destroyed := fs.destroyed | |||
| 202 | + | entry := fs.searchEntry | |||
| 203 | + | fs.mu.RUnlock() | |||
| 204 | + | if !destroyed && entry != nil { | |||
| 205 | + | entry.GrabFocus() | |||
| 206 | + | } | |||
| 207 | + | return false | |||
| 208 | + | })) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (fs *FavoritesSidebar) startLoad() { | |||
| 212 | + | if fs == nil { | |||
| 213 | + | return | |||
| 214 | + | } | |||
| 215 | + | fs.mu.Lock() | |||
| 216 | + | if fs.destroyed { | |||
| 217 | + | fs.mu.Unlock() | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | fs.loadGen++ | |||
| 221 | + | gen := fs.loadGen | |||
| 222 | + | uc := fs.favoritesUC | |||
| 223 | + | ctx := fs.ctx | |||
| 224 | + | fs.notice = "Loading favorites..." | |||
| 225 | + | fs.mu.Unlock() | |||
| 226 | + | ||||
| 227 | + | if uc == nil || ctx == nil { | |||
| 228 | + | fs.applyLoadedData(nil, nil, gen, nil) | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | go func() { | |||
| 232 | + | favorites, favErr := uc.GetAll(ctx) | |||
| 233 | + | tags, tagErr := uc.GetAllTags(ctx) | |||
| 234 | + | err := favErr | |||
| 235 | + | if err == nil { | |||
| 236 | + | err = tagErr | |||
| 237 | + | } | |||
| 238 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |||
| 239 | + | fs.applyLoadedData(favorites, tags, gen, err) | |||
| 240 | + | return false | |||
| 241 | + | })) | |||
| 242 | + | }() | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags []*entity.Tag, gen uint64, err error) bool { | |||
| 246 | + | if fs == nil { | |||
| 247 | + | return false | |||
| 248 | + | } | |||
| 249 | + | fs.mu.Lock() | |||
| 250 | + | if fs.destroyed || gen != fs.loadGen { | |||
| 251 | + | fs.mu.Unlock() | |||
| 252 | + | return false | |||
| 253 | + | } | |||
| 254 | + | if favorites == nil { | |||
| 255 | + | favorites = []*entity.Favorite{} | |||
| 256 | + | } | |||
| 257 | + | if tags == nil { | |||
| 258 | + | tags = []*entity.Tag{} | |||
| 259 | + | } | |||
| 260 | + | fs.allFavorites = favorites | |||
| 261 | + | fs.allTags = tags | |||
| 262 | + | if err != nil { | |||
| 263 | + | fs.notice = err.Error() | |||
| 264 | + | } else { | |||
| 265 | + | fs.notice = "" | |||
| 266 | + | } | |||
| 267 | + | fs.rebuildDisplayRowsLocked() | |||
| 268 | + | fs.mu.Unlock() | |||
| 269 | + | fs.renderTags() | |||
| 270 | + | fs.rebuildList() | |||
| 271 | + | return true | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func (fs *FavoritesSidebar) setupSearchHandler() { | |||
| 275 | + | if fs == nil || fs.searchEntry == nil { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | changedCb := func(_ gtk.SearchEntry) { | |||
| 279 | + | fs.mu.RLock() | |||
| 280 | + | destroyed := fs.destroyed | |||
| 281 | + | fs.mu.RUnlock() | |||
| 282 | + | if destroyed { | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | fs.onSearchChanged() | |||
| 286 | + | } | |||
| 287 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, changedCb) | |||
| 288 | + | fs.searchEntry.ConnectSearchChanged(&changedCb) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func (fs *FavoritesSidebar) onSearchChanged() { | |||
| 292 | + | fs.mu.Lock() | |||
| 293 | + | if fs.destroyed { | |||
| 294 | + | fs.mu.Unlock() | |||
| 295 | + | return | |||
| 296 | + | } | |||
| 297 | + | if fs.searchEntry != nil { | |||
| 298 | + | fs.currentQuery = fs.searchEntry.GetText() | |||
| 299 | + | } | |||
| 300 | + | fs.rebuildDisplayRowsLocked() | |||
| 301 | + | fs.mu.Unlock() | |||
| 302 | + | fs.rebuildList() | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() { | |||
| 306 | + | query := usecase.FavoriteSidebarQuery{Text: fs.currentQuery} | |||
| 307 | + | for id := range fs.selectedTagIDs { | |||
| 308 | + | query.TagIDs = append(query.TagIDs, id) | |||
| 309 | + | } | |||
| 310 | + | filtered := usecase.FilterFavoritesForSidebar(fs.allFavorites, query) | |||
| 311 | + | fs.displayRows = buildFavoriteSidebarDisplayRows(filtered) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) { | |||
| 315 | + | if fs != nil && fs.idleScheduler != nil { | |||
| 316 | + | fs.idleScheduler(cb) | |||
| 317 | + | return | |||
| 318 | + | } | |||
| 319 | + | glib.IdleAdd(&cb, 0) | |||
| 320 | + | } | |||
A internal/ui/component/favorites_sidebar_keyboard.go +267 -0
| @@ -0,0 +1,267 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 5 | + | "github.com/bnema/puregotk/v4/glib" | |
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |
| 10 | + | if fs == nil || fs.outerBox == nil { | |
| 11 | + | return | |
| 12 | + | } | |
| 13 | + | keyController := gtk.NewEventControllerKey() | |
| 14 | + | if keyController == nil { | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | keyController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 18 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | |
| 19 | + | fs.mu.RLock() | |
| 20 | + | if fs.destroyed { | |
| 21 | + | fs.mu.RUnlock() | |
| 22 | + | return false | |
| 23 | + | } | |
| 24 | + | searchEntry := fs.searchEntry | |
| 25 | + | onClose := fs.onClose | |
| 26 | + | fs.mu.RUnlock() | |
| 27 | + | ||
| 28 | + | switch keyval { | |
| 29 | + | case uint(gdk.KEY_Escape): | |
| 30 | + | if searchEntry != nil && searchEntry.GetText() != "" { | |
| 31 | + | searchEntry.SetText("") | |
| 32 | + | return true | |
| 33 | + | } | |
| 34 | + | if onClose != nil { | |
| 35 | + | onClose() | |
| 36 | + | } | |
| 37 | + | return true | |
| 38 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 39 | + | return fs.handleEnterKey(state) | |
| 40 | + | case uint(gdk.KEY_Page_Up): | |
| 41 | + | fs.selectAdjacentRow(-5) | |
| 42 | + | return true | |
| 43 | + | case uint(gdk.KEY_Page_Down): | |
| 44 | + | fs.selectAdjacentRow(5) | |
| 45 | + | return true | |
| 46 | + | case uint(gdk.KEY_Home): | |
| 47 | + | fs.mu.RLock() | |
| 48 | + | index := firstSelectableIndex(fs.displayRows) | |
| 49 | + | fs.mu.RUnlock() | |
| 50 | + | fs.selectIndex(index) | |
| 51 | + | return true | |
| 52 | + | case uint(gdk.KEY_End): | |
| 53 | + | fs.mu.RLock() | |
| 54 | + | index := lastSelectableIndex(fs.displayRows) | |
| 55 | + | fs.mu.RUnlock() | |
| 56 | + | fs.selectIndex(index) | |
| 57 | + | return true | |
| 58 | + | case uint(gdk.KEY_Up): | |
| 59 | + | fs.selectAdjacentRow(-1) | |
| 60 | + | return true | |
| 61 | + | case uint(gdk.KEY_Down): | |
| 62 | + | fs.selectAdjacentRow(1) | |
| 63 | + | return true | |
| 64 | + | case uint(gdk.KEY_slash): | |
| 65 | + | searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus() | |
| 66 | + | if !shouldFocusSearchForSlash(searchFocused) { | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | fs.focusSearch() | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb) | |
| 75 | + | keyController.ConnectKeyPressed(&keyPressedCb) | |
| 76 | + | fs.outerBox.AddController(&keyController.EventController) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool { | |
| 80 | + | if fs == nil { | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | url := fs.selectedEnterURL() | |
| 84 | + | if url == "" { | |
| 85 | + | return false | |
| 86 | + | } | |
| 87 | + | return fs.dispatchEnterURL(state, url) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (fs *FavoritesSidebar) selectedEnterURL() string { | |
| 91 | + | if fs == nil { | |
| 92 | + | return "" | |
| 93 | + | } | |
| 94 | + | if fs.listBox == nil { | |
| 95 | + | fs.mu.RLock() | |
| 96 | + | defer fs.mu.RUnlock() | |
| 97 | + | if fs.destroyed || len(fs.displayRows) != 1 { | |
| 98 | + | return "" | |
| 99 | + | } | |
| 100 | + | return fs.rowURLAt(0) | |
| 101 | + | } | |
| 102 | + | row := fs.listBox.GetSelectedRow() | |
| 103 | + | if row == nil || !row.GetSelectable() { | |
| 104 | + | return "" | |
| 105 | + | } | |
| 106 | + | fs.mu.RLock() | |
| 107 | + | defer fs.mu.RUnlock() | |
| 108 | + | if fs.destroyed { | |
| 109 | + | return "" | |
| 110 | + | } | |
| 111 | + | return fs.rowURLAt(row.GetIndex()) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) { | |
| 115 | + | if row == nil || !row.GetSelectable() { | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | fs.mu.RLock() | |
| 119 | + | if fs.destroyed { | |
| 120 | + | fs.mu.RUnlock() | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | url := fs.rowURLAt(row.GetIndex()) | |
| 124 | + | fs.mu.RUnlock() | |
| 125 | + | fs.navigateToURL(url) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (fs *FavoritesSidebar) selectAdjacentRow(delta int) { | |
| 129 | + | if fs == nil || fs.listBox == nil { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | current := -1 | |
| 133 | + | if row := fs.listBox.GetSelectedRow(); row != nil { | |
| 134 | + | current = row.GetIndex() | |
| 135 | + | } | |
| 136 | + | fs.mu.RLock() | |
| 137 | + | if fs.destroyed { | |
| 138 | + | fs.mu.RUnlock() | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | target := current | |
| 142 | + | step := 1 | |
| 143 | + | if delta < 0 { | |
| 144 | + | step = -1 | |
| 145 | + | } | |
| 146 | + | for i := 0; i < abs(delta); i++ { | |
| 147 | + | next := nextSelectableIndex(fs.displayRows, target, step) | |
| 148 | + | if next == -1 { | |
| 149 | + | break | |
| 150 | + | } | |
| 151 | + | target = next | |
| 152 | + | } | |
| 153 | + | if current < 0 { | |
| 154 | + | if step > 0 { | |
| 155 | + | target = firstSelectableIndex(fs.displayRows) | |
| 156 | + | } else { | |
| 157 | + | target = lastSelectableIndex(fs.displayRows) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | fs.mu.RUnlock() | |
| 161 | + | fs.selectIndex(target) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (fs *FavoritesSidebar) selectIndex(index int) { | |
| 165 | + | if fs == nil || index < 0 { | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | fs.mu.RLock() | |
| 169 | + | destroyed := fs.destroyed | |
| 170 | + | listBox := fs.listBox | |
| 171 | + | fs.mu.RUnlock() | |
| 172 | + | if destroyed || listBox == nil { | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() { | |
| 176 | + | listBox.SelectRow(row) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (fs *FavoritesSidebar) navigateToURL(url string) { | |
| 181 | + | if fs == nil || fs.onNavigate == nil || url == "" { | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| 185 | + | fs.mu.RLock() | |
| 186 | + | destroyed := fs.destroyed | |
| 187 | + | fs.mu.RUnlock() | |
| 188 | + | if !destroyed { | |
| 189 | + | fs.handleNavigationError(fs.onNavigate(fs.ctx, url)) | |
| 190 | + | } | |
| 191 | + | return false | |
| 192 | + | })) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (fs *FavoritesSidebar) navigateWithoutClosing(url string) { | |
| 196 | + | if fs == nil || url == "" { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | cb := fs.onNavigateKeepOpen | |
| 200 | + | if cb == nil { | |
| 201 | + | cb = fs.onNavigate | |
| 202 | + | } | |
| 203 | + | if cb == nil { | |
| 204 | + | return | |
| 205 | + | } | |
| 206 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| 207 | + | fs.mu.RLock() | |
| 208 | + | destroyed := fs.destroyed | |
| 209 | + | fs.mu.RUnlock() | |
| 210 | + | if !destroyed { | |
| 211 | + | fs.handleNavigationError(cb(fs.ctx, url)) | |
| 212 | + | } | |
| 213 | + | return false | |
| 214 | + | })) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |
| 218 | + | if fs == nil || fs.onOpenInNewPane == nil || url == "" { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| 222 | + | fs.mu.RLock() | |
| 223 | + | destroyed := fs.destroyed | |
| 224 | + | fs.mu.RUnlock() | |
| 225 | + | if !destroyed { | |
| 226 | + | fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url)) | |
| 227 | + | } | |
| 228 | + | return false | |
| 229 | + | })) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | |
| 233 | + | ||
| 234 | + | func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool { | |
| 235 | + | if fs == nil || url == "" { | |
| 236 | + | return false | |
| 237 | + | } | |
| 238 | + | if state&gdk.ShiftMaskValue != 0 { | |
| 239 | + | fs.navigateToNewPane(url) | |
| 240 | + | } else if state&gdk.ControlMaskValue != 0 { | |
| 241 | + | fs.navigateWithoutClosing(url) | |
| 242 | + | } else { | |
| 243 | + | fs.navigateToURL(url) | |
| 244 | + | } | |
| 245 | + | return true | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (fs *FavoritesSidebar) handleNavigationError(err error) { | |
| 249 | + | if fs == nil || err == nil { | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | fs.mu.Lock() | |
| 253 | + | if fs.destroyed { | |
| 254 | + | fs.mu.Unlock() | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | fs.notice = err.Error() | |
| 258 | + | fs.mu.Unlock() | |
| 259 | + | fs.rebuildList() | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func abs(v int) int { | |
| 263 | + | if v < 0 { | |
| 264 | + | return -v | |
| 265 | + | } | |
| 266 | + | return v | |
| 267 | + | } | |
| @@ -0,0 +1,267 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 5 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 10 | + | if fs == nil || fs.outerBox == nil { | |||
| 11 | + | return | |||
| 12 | + | } | |||
| 13 | + | keyController := gtk.NewEventControllerKey() | |||
| 14 | + | if keyController == nil { | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 17 | + | keyController.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 18 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | |||
| 19 | + | fs.mu.RLock() | |||
| 20 | + | if fs.destroyed { | |||
| 21 | + | fs.mu.RUnlock() | |||
| 22 | + | return false | |||
| 23 | + | } | |||
| 24 | + | searchEntry := fs.searchEntry | |||
| 25 | + | onClose := fs.onClose | |||
| 26 | + | fs.mu.RUnlock() | |||
| 27 | + | ||||
| 28 | + | switch keyval { | |||
| 29 | + | case uint(gdk.KEY_Escape): | |||
| 30 | + | if searchEntry != nil && searchEntry.GetText() != "" { | |||
| 31 | + | searchEntry.SetText("") | |||
| 32 | + | return true | |||
| 33 | + | } | |||
| 34 | + | if onClose != nil { | |||
| 35 | + | onClose() | |||
| 36 | + | } | |||
| 37 | + | return true | |||
| 38 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 39 | + | return fs.handleEnterKey(state) | |||
| 40 | + | case uint(gdk.KEY_Page_Up): | |||
| 41 | + | fs.selectAdjacentRow(-5) | |||
| 42 | + | return true | |||
| 43 | + | case uint(gdk.KEY_Page_Down): | |||
| 44 | + | fs.selectAdjacentRow(5) | |||
| 45 | + | return true | |||
| 46 | + | case uint(gdk.KEY_Home): | |||
| 47 | + | fs.mu.RLock() | |||
| 48 | + | index := firstSelectableIndex(fs.displayRows) | |||
| 49 | + | fs.mu.RUnlock() | |||
| 50 | + | fs.selectIndex(index) | |||
| 51 | + | return true | |||
| 52 | + | case uint(gdk.KEY_End): | |||
| 53 | + | fs.mu.RLock() | |||
| 54 | + | index := lastSelectableIndex(fs.displayRows) | |||
| 55 | + | fs.mu.RUnlock() | |||
| 56 | + | fs.selectIndex(index) | |||
| 57 | + | return true | |||
| 58 | + | case uint(gdk.KEY_Up): | |||
| 59 | + | fs.selectAdjacentRow(-1) | |||
| 60 | + | return true | |||
| 61 | + | case uint(gdk.KEY_Down): | |||
| 62 | + | fs.selectAdjacentRow(1) | |||
| 63 | + | return true | |||
| 64 | + | case uint(gdk.KEY_slash): | |||
| 65 | + | searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus() | |||
| 66 | + | if !shouldFocusSearchForSlash(searchFocused) { | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | fs.focusSearch() | |||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb) | |||
| 75 | + | keyController.ConnectKeyPressed(&keyPressedCb) | |||
| 76 | + | fs.outerBox.AddController(&keyController.EventController) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool { | |||
| 80 | + | if fs == nil { | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | url := fs.selectedEnterURL() | |||
| 84 | + | if url == "" { | |||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | return fs.dispatchEnterURL(state, url) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (fs *FavoritesSidebar) selectedEnterURL() string { | |||
| 91 | + | if fs == nil { | |||
| 92 | + | return "" | |||
| 93 | + | } | |||
| 94 | + | if fs.listBox == nil { | |||
| 95 | + | fs.mu.RLock() | |||
| 96 | + | defer fs.mu.RUnlock() | |||
| 97 | + | if fs.destroyed || len(fs.displayRows) != 1 { | |||
| 98 | + | return "" | |||
| 99 | + | } | |||
| 100 | + | return fs.rowURLAt(0) | |||
| 101 | + | } | |||
| 102 | + | row := fs.listBox.GetSelectedRow() | |||
| 103 | + | if row == nil || !row.GetSelectable() { | |||
| 104 | + | return "" | |||
| 105 | + | } | |||
| 106 | + | fs.mu.RLock() | |||
| 107 | + | defer fs.mu.RUnlock() | |||
| 108 | + | if fs.destroyed { | |||
| 109 | + | return "" | |||
| 110 | + | } | |||
| 111 | + | return fs.rowURLAt(row.GetIndex()) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) { | |||
| 115 | + | if row == nil || !row.GetSelectable() { | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | fs.mu.RLock() | |||
| 119 | + | if fs.destroyed { | |||
| 120 | + | fs.mu.RUnlock() | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | url := fs.rowURLAt(row.GetIndex()) | |||
| 124 | + | fs.mu.RUnlock() | |||
| 125 | + | fs.navigateToURL(url) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (fs *FavoritesSidebar) selectAdjacentRow(delta int) { | |||
| 129 | + | if fs == nil || fs.listBox == nil { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | current := -1 | |||
| 133 | + | if row := fs.listBox.GetSelectedRow(); row != nil { | |||
| 134 | + | current = row.GetIndex() | |||
| 135 | + | } | |||
| 136 | + | fs.mu.RLock() | |||
| 137 | + | if fs.destroyed { | |||
| 138 | + | fs.mu.RUnlock() | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | target := current | |||
| 142 | + | step := 1 | |||
| 143 | + | if delta < 0 { | |||
| 144 | + | step = -1 | |||
| 145 | + | } | |||
| 146 | + | for i := 0; i < abs(delta); i++ { | |||
| 147 | + | next := nextSelectableIndex(fs.displayRows, target, step) | |||
| 148 | + | if next == -1 { | |||
| 149 | + | break | |||
| 150 | + | } | |||
| 151 | + | target = next | |||
| 152 | + | } | |||
| 153 | + | if current < 0 { | |||
| 154 | + | if step > 0 { | |||
| 155 | + | target = firstSelectableIndex(fs.displayRows) | |||
| 156 | + | } else { | |||
| 157 | + | target = lastSelectableIndex(fs.displayRows) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | fs.mu.RUnlock() | |||
| 161 | + | fs.selectIndex(target) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (fs *FavoritesSidebar) selectIndex(index int) { | |||
| 165 | + | if fs == nil || index < 0 { | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | fs.mu.RLock() | |||
| 169 | + | destroyed := fs.destroyed | |||
| 170 | + | listBox := fs.listBox | |||
| 171 | + | fs.mu.RUnlock() | |||
| 172 | + | if destroyed || listBox == nil { | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() { | |||
| 176 | + | listBox.SelectRow(row) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (fs *FavoritesSidebar) navigateToURL(url string) { | |||
| 181 | + | if fs == nil || fs.onNavigate == nil || url == "" { | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |||
| 185 | + | fs.mu.RLock() | |||
| 186 | + | destroyed := fs.destroyed | |||
| 187 | + | fs.mu.RUnlock() | |||
| 188 | + | if !destroyed { | |||
| 189 | + | fs.handleNavigationError(fs.onNavigate(fs.ctx, url)) | |||
| 190 | + | } | |||
| 191 | + | return false | |||
| 192 | + | })) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func (fs *FavoritesSidebar) navigateWithoutClosing(url string) { | |||
| 196 | + | if fs == nil || url == "" { | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | cb := fs.onNavigateKeepOpen | |||
| 200 | + | if cb == nil { | |||
| 201 | + | cb = fs.onNavigate | |||
| 202 | + | } | |||
| 203 | + | if cb == nil { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |||
| 207 | + | fs.mu.RLock() | |||
| 208 | + | destroyed := fs.destroyed | |||
| 209 | + | fs.mu.RUnlock() | |||
| 210 | + | if !destroyed { | |||
| 211 | + | fs.handleNavigationError(cb(fs.ctx, url)) | |||
| 212 | + | } | |||
| 213 | + | return false | |||
| 214 | + | })) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |||
| 218 | + | if fs == nil || fs.onOpenInNewPane == nil || url == "" { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |||
| 222 | + | fs.mu.RLock() | |||
| 223 | + | destroyed := fs.destroyed | |||
| 224 | + | fs.mu.RUnlock() | |||
| 225 | + | if !destroyed { | |||
| 226 | + | fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url)) | |||
| 227 | + | } | |||
| 228 | + | return false | |||
| 229 | + | })) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | |||
| 233 | + | ||||
| 234 | + | func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool { | |||
| 235 | + | if fs == nil || url == "" { | |||
| 236 | + | return false | |||
| 237 | + | } | |||
| 238 | + | if state&gdk.ShiftMaskValue != 0 { | |||
| 239 | + | fs.navigateToNewPane(url) | |||
| 240 | + | } else if state&gdk.ControlMaskValue != 0 { | |||
| 241 | + | fs.navigateWithoutClosing(url) | |||
| 242 | + | } else { | |||
| 243 | + | fs.navigateToURL(url) | |||
| 244 | + | } | |||
| 245 | + | return true | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (fs *FavoritesSidebar) handleNavigationError(err error) { | |||
| 249 | + | if fs == nil || err == nil { | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | fs.mu.Lock() | |||
| 253 | + | if fs.destroyed { | |||
| 254 | + | fs.mu.Unlock() | |||
| 255 | + | return | |||
| 256 | + | } | |||
| 257 | + | fs.notice = err.Error() | |||
| 258 | + | fs.mu.Unlock() | |||
| 259 | + | fs.rebuildList() | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func abs(v int) int { | |||
| 263 | + | if v < 0 { | |||
| 264 | + | return -v | |||
| 265 | + | } | |||
| 266 | + | return v | |||
| 267 | + | } | |||
A internal/ui/component/favorites_sidebar_rows.go +307 -0
| @@ -0,0 +1,307 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 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 | + | ||
| 12 | + | type favoriteSidebarDisplayRowKind int | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type 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 | + | ||
| 27 | + | func 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 | + | ||
| 44 | + | func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int { | |
| 45 | + | for i := range rows { | |
| 46 | + | if rows[i].Selectable { | |
| 47 | + | return i | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return -1 | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func 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 | + | ||
| 62 | + | func 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 | + | ||
| 74 | + | func (fs *FavoritesSidebar) renderTags() { | |
| 75 | + | if fs == nil || fs.tagBox == nil { | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | fs.mu.RLock() | |
| 79 | + | if fs.destroyed { | |
| 80 | + | fs.mu.RUnlock() | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | tags := append([]*entity.Tag(nil), fs.allTags...) | |
| 84 | + | selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |
| 85 | + | for id := range fs.selectedTagIDs { | |
| 86 | + | selectedTagIDs[id] = struct{}{} | |
| 87 | + | } | |
| 88 | + | tagBox := fs.tagBox | |
| 89 | + | fs.mu.RUnlock() | |
| 90 | + | ||
| 91 | + | clearBoxChildren(tagBox) | |
| 92 | + | callbacks := make([]interface{}, 0, len(tags)+1) | |
| 93 | + | ||
| 94 | + | allButton := gtk.NewButtonWithLabel("All") | |
| 95 | + | if allButton != nil { | |
| 96 | + | if len(selectedTagIDs) == 0 { | |
| 97 | + | allButton.AddCssClass("suggested-action") | |
| 98 | + | } | |
| 99 | + | cb := func(_ gtk.Button) { | |
| 100 | + | fs.mu.Lock() | |
| 101 | + | if fs.destroyed { | |
| 102 | + | fs.mu.Unlock() | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |
| 106 | + | fs.rebuildDisplayRowsLocked() | |
| 107 | + | fs.mu.Unlock() | |
| 108 | + | fs.renderTags() | |
| 109 | + | fs.rebuildList() | |
| 110 | + | } | |
| 111 | + | callbacks = append(callbacks, cb) | |
| 112 | + | allButton.ConnectClicked(&cb) | |
| 113 | + | tagBox.Append(&allButton.Widget) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | for _, tag := range tags { | |
| 117 | + | if tag == nil { | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | t := tag | |
| 121 | + | button := gtk.NewButtonWithLabel(t.Name) | |
| 122 | + | if button == nil { | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | if _, ok := selectedTagIDs[t.ID]; ok { | |
| 126 | + | button.AddCssClass("suggested-action") | |
| 127 | + | } | |
| 128 | + | cb := func(_ gtk.Button) { | |
| 129 | + | fs.toggleTag(t.ID) | |
| 130 | + | } | |
| 131 | + | callbacks = append(callbacks, cb) | |
| 132 | + | button.ConnectClicked(&cb) | |
| 133 | + | tagBox.Append(&button.Widget) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | fs.mu.Lock() | |
| 137 | + | if !fs.destroyed { | |
| 138 | + | fs.tagCallbacks = callbacks | |
| 139 | + | } | |
| 140 | + | fs.mu.Unlock() | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) { | |
| 144 | + | if fs == nil { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | fs.mu.Lock() | |
| 148 | + | if fs.destroyed { | |
| 149 | + | fs.mu.Unlock() | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | if fs.selectedTagIDs == nil { | |
| 153 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |
| 154 | + | } | |
| 155 | + | if _, ok := fs.selectedTagIDs[tagID]; ok { | |
| 156 | + | delete(fs.selectedTagIDs, tagID) | |
| 157 | + | } else { | |
| 158 | + | fs.selectedTagIDs[tagID] = struct{}{} | |
| 159 | + | } | |
| 160 | + | fs.rebuildDisplayRowsLocked() | |
| 161 | + | fs.mu.Unlock() | |
| 162 | + | fs.renderTags() | |
| 163 | + | fs.rebuildList() | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (fs *FavoritesSidebar) rebuildList() { | |
| 167 | + | if fs == nil { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | fs.mu.RLock() | |
| 171 | + | if fs.destroyed || fs.listBox == nil { | |
| 172 | + | fs.mu.RUnlock() | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | listBox := fs.listBox | |
| 176 | + | rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...) | |
| 177 | + | notice := fs.notice | |
| 178 | + | query := fs.currentQuery | |
| 179 | + | fs.mu.RUnlock() | |
| 180 | + | ||
| 181 | + | listBox.RemoveAll() | |
| 182 | + | if len(rows) == 0 { | |
| 183 | + | text := "No favorites" | |
| 184 | + | if notice != "" { | |
| 185 | + | text = notice | |
| 186 | + | } else if query != "" { | |
| 187 | + | text = noResultsText(query) | |
| 188 | + | } | |
| 189 | + | fs.appendNoticeRow(listBox, text) | |
| 190 | + | return | |
| 191 | + | } | |
| 192 | + | for _, row := range rows { | |
| 193 | + | fs.appendFavoriteRow(listBox, row.Favorite) | |
| 194 | + | } | |
| 195 | + | fs.ensureAtLeastOneSelectionInListBox(listBox) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | |
| 199 | + | label := gtk.NewLabel(&text) | |
| 200 | + | if label == nil { | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | label.AddCssClass("favorites-sidebar-empty") | |
| 204 | + | label.SetXalign(0.0) | |
| 205 | + | row := gtk.NewListBoxRow() | |
| 206 | + | if row == nil { | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | row.SetSelectable(false) | |
| 210 | + | row.SetCanFocus(false) | |
| 211 | + | row.SetActivatable(false) | |
| 212 | + | row.SetChild(&label.Widget) | |
| 213 | + | listBox.Append(&row.Widget) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.Favorite) { | |
| 217 | + | if fav == nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2) | |
| 221 | + | if rowBox == nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | rowBox.SetHexpand(true) | |
| 225 | + | ||
| 226 | + | title := gtk.NewLabel(nil) | |
| 227 | + | if title == nil { | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | title.AddCssClass("favorites-sidebar-row-title") | |
| 231 | + | title.SetText(safeSidebarString(fav.Title, fav.URL)) | |
| 232 | + | title.SetXalign(0.0) | |
| 233 | + | title.SetHexpand(true) | |
| 234 | + | title.SetEllipsize(pango.EllipsizeEndValue) | |
| 235 | + | rowBox.Append(&title.Widget) | |
| 236 | + | ||
| 237 | + | sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 238 | + | if sub == nil { | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | sub.SetHexpand(true) | |
| 242 | + | url := gtk.NewLabel(nil) | |
| 243 | + | if url == nil { | |
| 244 | + | return | |
| 245 | + | } | |
| 246 | + | url.AddCssClass("favorites-sidebar-row-subtitle") | |
| 247 | + | url.SetText(readableURL(fav.URL)) | |
| 248 | + | url.SetXalign(0.0) | |
| 249 | + | url.SetHexpand(true) | |
| 250 | + | url.SetEllipsize(pango.EllipsizeEndValue) | |
| 251 | + | sub.Append(&url.Widget) | |
| 252 | + | if fav.ShortcutKey != nil { | |
| 253 | + | badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey) | |
| 254 | + | badgeLabel := gtk.NewLabel(&badge) | |
| 255 | + | if badgeLabel != nil { | |
| 256 | + | badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge") | |
| 257 | + | sub.Append(&badgeLabel.Widget) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | rowBox.Append(&sub.Widget) | |
| 261 | + | ||
| 262 | + | if len(fav.Tags) > 0 { | |
| 263 | + | tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3) | |
| 264 | + | if tags != nil { | |
| 265 | + | for _, tag := range fav.Tags { | |
| 266 | + | name := tag.Name | |
| 267 | + | label := gtk.NewLabel(&name) | |
| 268 | + | if label != nil { | |
| 269 | + | label.AddCssClass("favorites-sidebar-tag-chip") | |
| 270 | + | tags.Append(&label.Widget) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | rowBox.Append(&tags.Widget) | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | row := gtk.NewListBoxRow() | |
| 278 | + | if row == nil { | |
| 279 | + | return | |
| 280 | + | } | |
| 281 | + | row.AddCssClass("favorites-sidebar-row") | |
| 282 | + | row.SetSelectable(true) | |
| 283 | + | row.SetActivatable(true) | |
| 284 | + | row.SetCanFocus(true) | |
| 285 | + | row.SetFocusOnClick(true) | |
| 286 | + | row.SetChild(&rowBox.Widget) | |
| 287 | + | listBox.Append(&row.Widget) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) { | |
| 291 | + | if listBox == nil || listBox.GetSelectedRow() != nil { | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | fs.mu.RLock() | |
| 295 | + | index := firstSelectableIndex(fs.displayRows) | |
| 296 | + | fs.mu.RUnlock() | |
| 297 | + | if row := listBox.GetRowAtIndex(index); row != nil { | |
| 298 | + | listBox.SelectRow(row) | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func (fs *FavoritesSidebar) rowURLAt(index int) string { | |
| 303 | + | if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable { | |
| 304 | + | return "" | |
| 305 | + | } | |
| 306 | + | return fs.displayRows[index].URL | |
| 307 | + | } | |
| @@ -0,0 +1,307 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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 | + | ||||
| 12 | + | type favoriteSidebarDisplayRowKind int | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type 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 | + | ||||
| 27 | + | func 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 | + | ||||
| 44 | + | func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int { | |||
| 45 | + | for i := range rows { | |||
| 46 | + | if rows[i].Selectable { | |||
| 47 | + | return i | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | return -1 | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func 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 | + | ||||
| 62 | + | func 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 | + | ||||
| 74 | + | func (fs *FavoritesSidebar) renderTags() { | |||
| 75 | + | if fs == nil || fs.tagBox == nil { | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | fs.mu.RLock() | |||
| 79 | + | if fs.destroyed { | |||
| 80 | + | fs.mu.RUnlock() | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | tags := append([]*entity.Tag(nil), fs.allTags...) | |||
| 84 | + | selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |||
| 85 | + | for id := range fs.selectedTagIDs { | |||
| 86 | + | selectedTagIDs[id] = struct{}{} | |||
| 87 | + | } | |||
| 88 | + | tagBox := fs.tagBox | |||
| 89 | + | fs.mu.RUnlock() | |||
| 90 | + | ||||
| 91 | + | clearBoxChildren(tagBox) | |||
| 92 | + | callbacks := make([]interface{}, 0, len(tags)+1) | |||
| 93 | + | ||||
| 94 | + | allButton := gtk.NewButtonWithLabel("All") | |||
| 95 | + | if allButton != nil { | |||
| 96 | + | if len(selectedTagIDs) == 0 { | |||
| 97 | + | allButton.AddCssClass("suggested-action") | |||
| 98 | + | } | |||
| 99 | + | cb := func(_ gtk.Button) { | |||
| 100 | + | fs.mu.Lock() | |||
| 101 | + | if fs.destroyed { | |||
| 102 | + | fs.mu.Unlock() | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |||
| 106 | + | fs.rebuildDisplayRowsLocked() | |||
| 107 | + | fs.mu.Unlock() | |||
| 108 | + | fs.renderTags() | |||
| 109 | + | fs.rebuildList() | |||
| 110 | + | } | |||
| 111 | + | callbacks = append(callbacks, cb) | |||
| 112 | + | allButton.ConnectClicked(&cb) | |||
| 113 | + | tagBox.Append(&allButton.Widget) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | for _, tag := range tags { | |||
| 117 | + | if tag == nil { | |||
| 118 | + | continue | |||
| 119 | + | } | |||
| 120 | + | t := tag | |||
| 121 | + | button := gtk.NewButtonWithLabel(t.Name) | |||
| 122 | + | if button == nil { | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | if _, ok := selectedTagIDs[t.ID]; ok { | |||
| 126 | + | button.AddCssClass("suggested-action") | |||
| 127 | + | } | |||
| 128 | + | cb := func(_ gtk.Button) { | |||
| 129 | + | fs.toggleTag(t.ID) | |||
| 130 | + | } | |||
| 131 | + | callbacks = append(callbacks, cb) | |||
| 132 | + | button.ConnectClicked(&cb) | |||
| 133 | + | tagBox.Append(&button.Widget) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | fs.mu.Lock() | |||
| 137 | + | if !fs.destroyed { | |||
| 138 | + | fs.tagCallbacks = callbacks | |||
| 139 | + | } | |||
| 140 | + | fs.mu.Unlock() | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) { | |||
| 144 | + | if fs == nil { | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | fs.mu.Lock() | |||
| 148 | + | if fs.destroyed { | |||
| 149 | + | fs.mu.Unlock() | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | if fs.selectedTagIDs == nil { | |||
| 153 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |||
| 154 | + | } | |||
| 155 | + | if _, ok := fs.selectedTagIDs[tagID]; ok { | |||
| 156 | + | delete(fs.selectedTagIDs, tagID) | |||
| 157 | + | } else { | |||
| 158 | + | fs.selectedTagIDs[tagID] = struct{}{} | |||
| 159 | + | } | |||
| 160 | + | fs.rebuildDisplayRowsLocked() | |||
| 161 | + | fs.mu.Unlock() | |||
| 162 | + | fs.renderTags() | |||
| 163 | + | fs.rebuildList() | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (fs *FavoritesSidebar) rebuildList() { | |||
| 167 | + | if fs == nil { | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | fs.mu.RLock() | |||
| 171 | + | if fs.destroyed || fs.listBox == nil { | |||
| 172 | + | fs.mu.RUnlock() | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | listBox := fs.listBox | |||
| 176 | + | rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...) | |||
| 177 | + | notice := fs.notice | |||
| 178 | + | query := fs.currentQuery | |||
| 179 | + | fs.mu.RUnlock() | |||
| 180 | + | ||||
| 181 | + | listBox.RemoveAll() | |||
| 182 | + | if len(rows) == 0 { | |||
| 183 | + | text := "No favorites" | |||
| 184 | + | if notice != "" { | |||
| 185 | + | text = notice | |||
| 186 | + | } else if query != "" { | |||
| 187 | + | text = noResultsText(query) | |||
| 188 | + | } | |||
| 189 | + | fs.appendNoticeRow(listBox, text) | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | for _, row := range rows { | |||
| 193 | + | fs.appendFavoriteRow(listBox, row.Favorite) | |||
| 194 | + | } | |||
| 195 | + | fs.ensureAtLeastOneSelectionInListBox(listBox) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | |||
| 199 | + | label := gtk.NewLabel(&text) | |||
| 200 | + | if label == nil { | |||
| 201 | + | return | |||
| 202 | + | } | |||
| 203 | + | label.AddCssClass("favorites-sidebar-empty") | |||
| 204 | + | label.SetXalign(0.0) | |||
| 205 | + | row := gtk.NewListBoxRow() | |||
| 206 | + | if row == nil { | |||
| 207 | + | return | |||
| 208 | + | } | |||
| 209 | + | row.SetSelectable(false) | |||
| 210 | + | row.SetCanFocus(false) | |||
| 211 | + | row.SetActivatable(false) | |||
| 212 | + | row.SetChild(&label.Widget) | |||
| 213 | + | listBox.Append(&row.Widget) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, fav *entity.Favorite) { | |||
| 217 | + | if fav == nil { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2) | |||
| 221 | + | if rowBox == nil { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | rowBox.SetHexpand(true) | |||
| 225 | + | ||||
| 226 | + | title := gtk.NewLabel(nil) | |||
| 227 | + | if title == nil { | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | title.AddCssClass("favorites-sidebar-row-title") | |||
| 231 | + | title.SetText(safeSidebarString(fav.Title, fav.URL)) | |||
| 232 | + | title.SetXalign(0.0) | |||
| 233 | + | title.SetHexpand(true) | |||
| 234 | + | title.SetEllipsize(pango.EllipsizeEndValue) | |||
| 235 | + | rowBox.Append(&title.Widget) | |||
| 236 | + | ||||
| 237 | + | sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |||
| 238 | + | if sub == nil { | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | sub.SetHexpand(true) | |||
| 242 | + | url := gtk.NewLabel(nil) | |||
| 243 | + | if url == nil { | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | url.AddCssClass("favorites-sidebar-row-subtitle") | |||
| 247 | + | url.SetText(readableURL(fav.URL)) | |||
| 248 | + | url.SetXalign(0.0) | |||
| 249 | + | url.SetHexpand(true) | |||
| 250 | + | url.SetEllipsize(pango.EllipsizeEndValue) | |||
| 251 | + | sub.Append(&url.Widget) | |||
| 252 | + | if fav.ShortcutKey != nil { | |||
| 253 | + | badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey) | |||
| 254 | + | badgeLabel := gtk.NewLabel(&badge) | |||
| 255 | + | if badgeLabel != nil { | |||
| 256 | + | badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge") | |||
| 257 | + | sub.Append(&badgeLabel.Widget) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | rowBox.Append(&sub.Widget) | |||
| 261 | + | ||||
| 262 | + | if len(fav.Tags) > 0 { | |||
| 263 | + | tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3) | |||
| 264 | + | if tags != nil { | |||
| 265 | + | for _, tag := range fav.Tags { | |||
| 266 | + | name := tag.Name | |||
| 267 | + | label := gtk.NewLabel(&name) | |||
| 268 | + | if label != nil { | |||
| 269 | + | label.AddCssClass("favorites-sidebar-tag-chip") | |||
| 270 | + | tags.Append(&label.Widget) | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | rowBox.Append(&tags.Widget) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | row := gtk.NewListBoxRow() | |||
| 278 | + | if row == nil { | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 281 | + | row.AddCssClass("favorites-sidebar-row") | |||
| 282 | + | row.SetSelectable(true) | |||
| 283 | + | row.SetActivatable(true) | |||
| 284 | + | row.SetCanFocus(true) | |||
| 285 | + | row.SetFocusOnClick(true) | |||
| 286 | + | row.SetChild(&rowBox.Widget) | |||
| 287 | + | listBox.Append(&row.Widget) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) { | |||
| 291 | + | if listBox == nil || listBox.GetSelectedRow() != nil { | |||
| 292 | + | return | |||
| 293 | + | } | |||
| 294 | + | fs.mu.RLock() | |||
| 295 | + | index := firstSelectableIndex(fs.displayRows) | |||
| 296 | + | fs.mu.RUnlock() | |||
| 297 | + | if row := listBox.GetRowAtIndex(index); row != nil { | |||
| 298 | + | listBox.SelectRow(row) | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func (fs *FavoritesSidebar) rowURLAt(index int) string { | |||
| 303 | + | if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable { | |||
| 304 | + | return "" | |||
| 305 | + | } | |||
| 306 | + | return fs.displayRows[index].URL | |||
| 307 | + | } | |||
A internal/ui/component/favorites_sidebar_test.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 12 | + | "github.com/bnema/puregotk/v4/glib" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type fakeFavoritesSidebarUC struct { | |
| 18 | + | favorites []*entity.Favorite | |
| 19 | + | tags []*entity.Tag | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) { | |
| 23 | + | return f.favorites, nil | |
| 24 | + | } | |
| 25 | + | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | |
| 26 | + | return f.tags, nil | |
| 27 | + | } | |
| 28 | + | func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 29 | + | return nil, nil | |
| 30 | + | } | |
| 31 | + | func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 32 | + | return nil, nil | |
| 33 | + | } | |
| 34 | + | func (f *fakeFavoritesSidebarUC) DeleteFavorite(context.Context, entity.FavoriteID) error { return nil } | |
| 35 | + | func (f *fakeFavoritesSidebarUC) SetShortcut(context.Context, entity.FavoriteID, *int) error { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { | |
| 46 | + | fs := &FavoritesSidebar{ | |
| 47 | + | favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags}, | |
| 48 | + | selectedTagIDs: make(map[entity.TagID]struct{}), | |
| 49 | + | ctx: context.Background(), | |
| 50 | + | idleScheduler: func(cb glib.SourceFunc) { | |
| 51 | + | cb(0) | |
| 52 | + | }, | |
| 53 | + | } | |
| 54 | + | return fs | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) { | |
| 58 | + | fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2} | |
| 59 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}}) | |
| 60 | + | fs.loadGen = 1 | |
| 61 | + | ||
| 62 | + | applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil) | |
| 63 | + | ||
| 64 | + | require.True(t, applied) | |
| 65 | + | assert.Len(t, fs.allFavorites, 1) | |
| 66 | + | assert.Len(t, fs.allTags, 1) | |
| 67 | + | require.Len(t, fs.displayRows, 1) | |
| 68 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |
| 69 | + | assert.Equal(t, "https://go.dev", fs.displayRows[0].URL) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) { | |
| 73 | + | devTag := entity.Tag{ID: 1, Name: "Dev"} | |
| 74 | + | newsTag := entity.Tag{ID: 2, Name: "News"} | |
| 75 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{ | |
| 76 | + | {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1}, | |
| 77 | + | {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2}, | |
| 78 | + | }, nil) | |
| 79 | + | fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites | |
| 80 | + | ||
| 81 | + | fs.currentQuery = "golang" | |
| 82 | + | fs.rebuildDisplayRowsLocked() | |
| 83 | + | require.Len(t, fs.displayRows, 1) | |
| 84 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |
| 85 | + | ||
| 86 | + | fs.currentQuery = "example.com" | |
| 87 | + | fs.rebuildDisplayRowsLocked() | |
| 88 | + | require.Len(t, fs.displayRows, 1) | |
| 89 | + | assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID) | |
| 90 | + | ||
| 91 | + | fs.currentQuery = "dev" | |
| 92 | + | fs.rebuildDisplayRowsLocked() | |
| 93 | + | require.Len(t, fs.displayRows, 1) | |
| 94 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) { | |
| 98 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{ | |
| 99 | + | {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}}, | |
| 100 | + | {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}}, | |
| 101 | + | {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}}, | |
| 102 | + | }, nil) | |
| 103 | + | fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites | |
| 104 | + | fs.selectedTagIDs[1] = struct{}{} | |
| 105 | + | fs.selectedTagIDs[2] = struct{}{} | |
| 106 | + | ||
| 107 | + | fs.rebuildDisplayRowsLocked() | |
| 108 | + | ||
| 109 | + | require.Len(t, fs.displayRows, 2) | |
| 110 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |
| 111 | + | assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestFavoritesSidebarActivationCallbacks(t *testing.T) { | |
| 115 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 116 | + | fs.ctx = context.Background() | |
| 117 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |
| 118 | + | var mu sync.Mutex | |
| 119 | + | called := []string{} | |
| 120 | + | fs.onNavigate = func(_ context.Context, url string) error { | |
| 121 | + | mu.Lock() | |
| 122 | + | defer mu.Unlock() | |
| 123 | + | called = append(called, "nav:"+url) | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | fs.onNavigateKeepOpen = func(_ context.Context, url string) error { | |
| 127 | + | mu.Lock() | |
| 128 | + | defer mu.Unlock() | |
| 129 | + | called = append(called, "keep:"+url) | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | fs.onOpenInNewPane = func(_ context.Context, url string) error { | |
| 133 | + | mu.Lock() | |
| 134 | + | defer mu.Unlock() | |
| 135 | + | called = append(called, "pane:"+url) | |
| 136 | + | return nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | fs.navigateToURL("https://a.test") | |
| 140 | + | fs.navigateWithoutClosing("https://b.test") | |
| 141 | + | fs.navigateToNewPane("https://c.test") | |
| 142 | + | ||
| 143 | + | assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestFavoritesSidebarStaleLoadRejected(t *testing.T) { | |
| 147 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 148 | + | fs.loadGen = 2 | |
| 149 | + | ||
| 150 | + | applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil) | |
| 151 | + | ||
| 152 | + | assert.False(t, applied) | |
| 153 | + | assert.Empty(t, fs.allFavorites) | |
| 154 | + | assert.Empty(t, fs.displayRows) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) { | |
| 158 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 159 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |
| 160 | + | called := false | |
| 161 | + | fs.onNavigate = func(context.Context, string) error { | |
| 162 | + | called = true | |
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | fs.destroyed = true | |
| 166 | + | ||
| 167 | + | fs.navigateToURL("https://example.test") | |
| 168 | + | fs.toggleTag(1) | |
| 169 | + | fs.onSearchChanged() | |
| 170 | + | ||
| 171 | + | assert.False(t, called) | |
| 172 | + | assert.Empty(t, fs.selectedTagIDs) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) { | |
| 176 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 177 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |
| 178 | + | fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") } | |
| 179 | + | ||
| 180 | + | fs.navigateToURL("https://example.test") | |
| 181 | + | ||
| 182 | + | assert.Equal(t, "navigation failed", fs.notice) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) { | |
| 186 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 187 | + | fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}} | |
| 188 | + | fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{ | |
| 189 | + | {ID: 1, URL: "https://go.dev", Title: "Go"}, | |
| 190 | + | {ID: 2, URL: "", Title: "Missing URL"}, | |
| 191 | + | }) | |
| 192 | + | ||
| 193 | + | assert.Equal(t, 2, len(fs.allTags)) | |
| 194 | + | require.Len(t, fs.displayRows, 2) | |
| 195 | + | assert.True(t, fs.displayRows[0].Selectable) | |
| 196 | + | assert.False(t, fs.displayRows[1].Selectable) | |
| 197 | + | fs.tagCallbacks = []interface{}{func() {}, func() {}} | |
| 198 | + | fs.tagCallbacks = nil | |
| 199 | + | assert.Empty(t, fs.tagCallbacks) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) { | |
| 203 | + | assert.True(t, shouldFocusSearchForSlash(false)) | |
| 204 | + | assert.False(t, shouldFocusSearchForSlash(true)) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) { | |
| 208 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 209 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |
| 210 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}} | |
| 211 | + | var called []string | |
| 212 | + | fs.onNavigate = func(_ context.Context, url string) error { | |
| 213 | + | called = append(called, "nav:"+url) | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | fs.onNavigateKeepOpen = func(_ context.Context, url string) error { | |
| 217 | + | called = append(called, "keep:"+url) | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | fs.onOpenInNewPane = func(_ context.Context, url string) error { | |
| 221 | + | called = append(called, "pane:"+url) | |
| 222 | + | return nil | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | assert.True(t, fs.handleEnterKey(0)) | |
| 226 | + | assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue)) | |
| 227 | + | assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue)) | |
| 228 | + | ||
| 229 | + | assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called) | |
| 230 | + | } | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 12 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type fakeFavoritesSidebarUC struct { | |||
| 18 | + | favorites []*entity.Favorite | |||
| 19 | + | tags []*entity.Tag | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) { | |||
| 23 | + | return f.favorites, nil | |||
| 24 | + | } | |||
| 25 | + | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | |||
| 26 | + | return f.tags, nil | |||
| 27 | + | } | |||
| 28 | + | func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 29 | + | return nil, nil | |||
| 30 | + | } | |||
| 31 | + | func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 32 | + | return nil, nil | |||
| 33 | + | } | |||
| 34 | + | func (f *fakeFavoritesSidebarUC) DeleteFavorite(context.Context, entity.FavoriteID) error { return nil } | |||
| 35 | + | func (f *fakeFavoritesSidebarUC) SetShortcut(context.Context, entity.FavoriteID, *int) error { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | |||
| 39 | + | return nil | |||
| 40 | + | } | |||
| 41 | + | func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { | |||
| 46 | + | fs := &FavoritesSidebar{ | |||
| 47 | + | favoritesUC: &fakeFavoritesSidebarUC{favorites: favorites, tags: tags}, | |||
| 48 | + | selectedTagIDs: make(map[entity.TagID]struct{}), | |||
| 49 | + | ctx: context.Background(), | |||
| 50 | + | idleScheduler: func(cb glib.SourceFunc) { | |||
| 51 | + | cb(0) | |||
| 52 | + | }, | |||
| 53 | + | } | |||
| 54 | + | return fs | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestFavoritesSidebarInitialLoadRenderingModelBehavior(t *testing.T) { | |||
| 58 | + | fav := &entity.Favorite{ID: 1, URL: "https://go.dev", Title: "Go", Position: 2} | |||
| 59 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{{ID: 10, Name: "dev"}}) | |||
| 60 | + | fs.loadGen = 1 | |||
| 61 | + | ||||
| 62 | + | applied := fs.applyLoadedData(fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites, fs.favoritesUC.(*fakeFavoritesSidebarUC).tags, 1, nil) | |||
| 63 | + | ||||
| 64 | + | require.True(t, applied) | |||
| 65 | + | assert.Len(t, fs.allFavorites, 1) | |||
| 66 | + | assert.Len(t, fs.allTags, 1) | |||
| 67 | + | require.Len(t, fs.displayRows, 1) | |||
| 68 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |||
| 69 | + | assert.Equal(t, "https://go.dev", fs.displayRows[0].URL) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestFavoritesSidebarSearchFilteringByTitleURLAndTag(t *testing.T) { | |||
| 73 | + | devTag := entity.Tag{ID: 1, Name: "Dev"} | |||
| 74 | + | newsTag := entity.Tag{ID: 2, Name: "News"} | |||
| 75 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{ | |||
| 76 | + | {ID: 1, URL: "https://go.dev/doc", Title: "Golang Docs", Tags: []entity.Tag{devTag}, Position: 1}, | |||
| 77 | + | {ID: 2, URL: "https://example.com/news", Title: "Daily", Tags: []entity.Tag{newsTag}, Position: 2}, | |||
| 78 | + | }, nil) | |||
| 79 | + | fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites | |||
| 80 | + | ||||
| 81 | + | fs.currentQuery = "golang" | |||
| 82 | + | fs.rebuildDisplayRowsLocked() | |||
| 83 | + | require.Len(t, fs.displayRows, 1) | |||
| 84 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |||
| 85 | + | ||||
| 86 | + | fs.currentQuery = "example.com" | |||
| 87 | + | fs.rebuildDisplayRowsLocked() | |||
| 88 | + | require.Len(t, fs.displayRows, 1) | |||
| 89 | + | assert.Equal(t, entity.FavoriteID(2), fs.displayRows[0].FavoriteID) | |||
| 90 | + | ||||
| 91 | + | fs.currentQuery = "dev" | |||
| 92 | + | fs.rebuildDisplayRowsLocked() | |||
| 93 | + | require.Len(t, fs.displayRows, 1) | |||
| 94 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestFavoritesSidebarTagFilteringMatchAny(t *testing.T) { | |||
| 98 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{ | |||
| 99 | + | {ID: 1, URL: "https://a.test", Title: "A", Tags: []entity.Tag{{ID: 1, Name: "one"}}}, | |||
| 100 | + | {ID: 2, URL: "https://b.test", Title: "B", Tags: []entity.Tag{{ID: 2, Name: "two"}}}, | |||
| 101 | + | {ID: 3, URL: "https://c.test", Title: "C", Tags: []entity.Tag{{ID: 3, Name: "three"}}}, | |||
| 102 | + | }, nil) | |||
| 103 | + | fs.allFavorites = fs.favoritesUC.(*fakeFavoritesSidebarUC).favorites | |||
| 104 | + | fs.selectedTagIDs[1] = struct{}{} | |||
| 105 | + | fs.selectedTagIDs[2] = struct{}{} | |||
| 106 | + | ||||
| 107 | + | fs.rebuildDisplayRowsLocked() | |||
| 108 | + | ||||
| 109 | + | require.Len(t, fs.displayRows, 2) | |||
| 110 | + | assert.Equal(t, entity.FavoriteID(1), fs.displayRows[0].FavoriteID) | |||
| 111 | + | assert.Equal(t, entity.FavoriteID(2), fs.displayRows[1].FavoriteID) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestFavoritesSidebarActivationCallbacks(t *testing.T) { | |||
| 115 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 116 | + | fs.ctx = context.Background() | |||
| 117 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |||
| 118 | + | var mu sync.Mutex | |||
| 119 | + | called := []string{} | |||
| 120 | + | fs.onNavigate = func(_ context.Context, url string) error { | |||
| 121 | + | mu.Lock() | |||
| 122 | + | defer mu.Unlock() | |||
| 123 | + | called = append(called, "nav:"+url) | |||
| 124 | + | return nil | |||
| 125 | + | } | |||
| 126 | + | fs.onNavigateKeepOpen = func(_ context.Context, url string) error { | |||
| 127 | + | mu.Lock() | |||
| 128 | + | defer mu.Unlock() | |||
| 129 | + | called = append(called, "keep:"+url) | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | fs.onOpenInNewPane = func(_ context.Context, url string) error { | |||
| 133 | + | mu.Lock() | |||
| 134 | + | defer mu.Unlock() | |||
| 135 | + | called = append(called, "pane:"+url) | |||
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | fs.navigateToURL("https://a.test") | |||
| 140 | + | fs.navigateWithoutClosing("https://b.test") | |||
| 141 | + | fs.navigateToNewPane("https://c.test") | |||
| 142 | + | ||||
| 143 | + | assert.Equal(t, []string{"nav:https://a.test", "keep:https://b.test", "pane:https://c.test"}, called) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestFavoritesSidebarStaleLoadRejected(t *testing.T) { | |||
| 147 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 148 | + | fs.loadGen = 2 | |||
| 149 | + | ||||
| 150 | + | applied := fs.applyLoadedData([]*entity.Favorite{{ID: 1, URL: "https://stale.test"}}, nil, 1, nil) | |||
| 151 | + | ||||
| 152 | + | assert.False(t, applied) | |||
| 153 | + | assert.Empty(t, fs.allFavorites) | |||
| 154 | + | assert.Empty(t, fs.displayRows) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestFavoritesSidebarDestroyedCallbacksNoOp(t *testing.T) { | |||
| 158 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 159 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |||
| 160 | + | called := false | |||
| 161 | + | fs.onNavigate = func(context.Context, string) error { | |||
| 162 | + | called = true | |||
| 163 | + | return nil | |||
| 164 | + | } | |||
| 165 | + | fs.destroyed = true | |||
| 166 | + | ||||
| 167 | + | fs.navigateToURL("https://example.test") | |||
| 168 | + | fs.toggleTag(1) | |||
| 169 | + | fs.onSearchChanged() | |||
| 170 | + | ||||
| 171 | + | assert.False(t, called) | |||
| 172 | + | assert.Empty(t, fs.selectedTagIDs) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestFavoritesSidebarNavigationErrorSetsNotice(t *testing.T) { | |||
| 176 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 177 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |||
| 178 | + | fs.onNavigate = func(context.Context, string) error { return errors.New("navigation failed") } | |||
| 179 | + | ||||
| 180 | + | fs.navigateToURL("https://example.test") | |||
| 181 | + | ||||
| 182 | + | assert.Equal(t, "navigation failed", fs.notice) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testing.T) { | |||
| 186 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 187 | + | fs.allTags = []*entity.Tag{{ID: 1, Name: "dev"}, {ID: 2, Name: "docs"}} | |||
| 188 | + | fs.displayRows = buildFavoriteSidebarDisplayRows([]*entity.Favorite{ | |||
| 189 | + | {ID: 1, URL: "https://go.dev", Title: "Go"}, | |||
| 190 | + | {ID: 2, URL: "", Title: "Missing URL"}, | |||
| 191 | + | }) | |||
| 192 | + | ||||
| 193 | + | assert.Equal(t, 2, len(fs.allTags)) | |||
| 194 | + | require.Len(t, fs.displayRows, 2) | |||
| 195 | + | assert.True(t, fs.displayRows[0].Selectable) | |||
| 196 | + | assert.False(t, fs.displayRows[1].Selectable) | |||
| 197 | + | fs.tagCallbacks = []interface{}{func() {}, func() {}} | |||
| 198 | + | fs.tagCallbacks = nil | |||
| 199 | + | assert.Empty(t, fs.tagCallbacks) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestFavoritesSidebarSlashFocusBehavior(t *testing.T) { | |||
| 203 | + | assert.True(t, shouldFocusSearchForSlash(false)) | |||
| 204 | + | assert.False(t, shouldFocusSearchForSlash(true)) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) { | |||
| 208 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 209 | + | fs.idleScheduler = func(cb glib.SourceFunc) { cb(0) } | |||
| 210 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://go.dev", Selectable: true}} | |||
| 211 | + | var called []string | |||
| 212 | + | fs.onNavigate = func(_ context.Context, url string) error { | |||
| 213 | + | called = append(called, "nav:"+url) | |||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | fs.onNavigateKeepOpen = func(_ context.Context, url string) error { | |||
| 217 | + | called = append(called, "keep:"+url) | |||
| 218 | + | return nil | |||
| 219 | + | } | |||
| 220 | + | fs.onOpenInNewPane = func(_ context.Context, url string) error { | |||
| 221 | + | called = append(called, "pane:"+url) | |||
| 222 | + | return nil | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | assert.True(t, fs.handleEnterKey(0)) | |||
| 226 | + | assert.True(t, fs.handleEnterKey(gdk.ControlMaskValue)) | |||
| 227 | + | assert.True(t, fs.handleEnterKey(gdk.ShiftMaskValue)) | |||
| 228 | + | ||||
| 229 | + | assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called) | |||
| 230 | + | } | |||