feat(favorites): manage bookmarks from sidebar
- commit
- 3857325407c2e9c452c812009b8e02a2b8b93e32
- author
- brice · · 2026-06-28 15:53 UTC
- committer
- brice · · 2026-06-28 16:03 UTC
- parent
- 5b8fa27
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +886 -19
M internal/ui/component/favorites_sidebar.go +23 -0
| @@ -29,6 +29,17 @@ type FavoritesSidebar struct { | |||
| 29 | 29 | tagBox *gtk.Box | |
| 30 | 30 | scrolledWin *gtk.ScrolledWindow | |
| 31 | 31 | listBox *gtk.ListBox | |
| 32 | + | formBox *gtk.Box | |
| 33 | + | ||
| 34 | + | formURLEntry *gtk.SearchEntry | |
| 35 | + | formTitleEntry *gtk.SearchEntry | |
| 36 | + | formTagsEntry *gtk.SearchEntry | |
| 37 | + | formShortcutEntry *gtk.SearchEntry | |
| 38 | + | formSaveButton *gtk.Button | |
| 39 | + | formURL string | |
| 40 | + | formTitle string | |
| 41 | + | formTags string | |
| 42 | + | formShortcut string | |
| 32 | 43 | ||
| 33 | 44 | favoritesUC port.FavoritesSidebarFavorites | |
| 34 | 45 | onNavigate func(ctx context.Context, url string) error | |
| @@ -45,6 +56,10 @@ type FavoritesSidebar struct { | |||
| 45 | 56 | loadGen uint64 | |
| 46 | 57 | destroyed bool | |
| 47 | 58 | visible bool | |
| 59 | + | focusZone favoritesSidebarFocusZone | |
| 60 | + | mode favoritesSidebarMode | |
| 61 | + | editingID entity.FavoriteID | |
| 62 | + | confirmDelete bool | |
| 48 | 63 | ||
| 49 | 64 | retainedCallbacks []interface{} | |
| 50 | 65 | tagCallbacks []interface{} | |
| @@ -189,6 +204,14 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||
| 189 | 204 | ||
| 190 | 205 | fs.scrolledWin.SetChild(&fs.listBox.Widget) | |
| 191 | 206 | fs.outerBox.Append(&fs.scrolledWin.Widget) | |
| 207 | + | ||
| 208 | + | fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4) | |
| 209 | + | if fs.formBox == nil { | |
| 210 | + | return fmt.Errorf("favorites sidebar: form box creation failed") | |
| 211 | + | } | |
| 212 | + | fs.formBox.AddCssClass("favorites-sidebar-form") | |
| 213 | + | fs.formBox.SetVisible(false) | |
| 214 | + | fs.outerBox.Append(&fs.formBox.Widget) | |
| 192 | 215 | return nil | |
| 193 | 216 | } | |
| 194 | 217 | ||
| @@ -29,6 +29,17 @@ type FavoritesSidebar struct { | |||||
| 29 | tagBox *gtk.Box | 29 | tagBox *gtk.Box | ||
| 30 | scrolledWin *gtk.ScrolledWindow | 30 | scrolledWin *gtk.ScrolledWindow | ||
| 31 | listBox *gtk.ListBox | 31 | listBox *gtk.ListBox | ||
| 32 | + | formBox *gtk.Box | |||
| 33 | + | ||||
| 34 | + | formURLEntry *gtk.SearchEntry | |||
| 35 | + | formTitleEntry *gtk.SearchEntry | |||
| 36 | + | formTagsEntry *gtk.SearchEntry | |||
| 37 | + | formShortcutEntry *gtk.SearchEntry | |||
| 38 | + | formSaveButton *gtk.Button | |||
| 39 | + | formURL string | |||
| 40 | + | formTitle string | |||
| 41 | + | formTags string | |||
| 42 | + | formShortcut string | |||
| 32 | 43 | ||||
| 33 | favoritesUC port.FavoritesSidebarFavorites | 44 | favoritesUC port.FavoritesSidebarFavorites | ||
| 34 | onNavigate func(ctx context.Context, url string) error | 45 | onNavigate func(ctx context.Context, url string) error | ||
| @@ -45,6 +56,10 @@ type FavoritesSidebar struct { | |||||
| 45 | loadGen uint64 | 56 | loadGen uint64 | ||
| 46 | destroyed bool | 57 | destroyed bool | ||
| 47 | visible bool | 58 | visible bool | ||
| 59 | + | focusZone favoritesSidebarFocusZone | |||
| 60 | + | mode favoritesSidebarMode | |||
| 61 | + | editingID entity.FavoriteID | |||
| 62 | + | confirmDelete bool | |||
| 48 | 63 | ||||
| 49 | retainedCallbacks []interface{} | 64 | retainedCallbacks []interface{} | ||
| 50 | tagCallbacks []interface{} | 65 | tagCallbacks []interface{} | ||
| @@ -189,6 +204,14 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||||
| 189 | 204 | ||||
| 190 | fs.scrolledWin.SetChild(&fs.listBox.Widget) | 205 | fs.scrolledWin.SetChild(&fs.listBox.Widget) | ||
| 191 | fs.outerBox.Append(&fs.scrolledWin.Widget) | 206 | fs.outerBox.Append(&fs.scrolledWin.Widget) | ||
| 207 | + | ||||
| 208 | + | fs.formBox = gtk.NewBox(gtk.OrientationVerticalValue, 4) | |||
| 209 | + | if fs.formBox == nil { | |||
| 210 | + | return fmt.Errorf("favorites sidebar: form box creation failed") | |||
| 211 | + | } | |||
| 212 | + | fs.formBox.AddCssClass("favorites-sidebar-form") | |||
| 213 | + | fs.formBox.SetVisible(false) | |||
| 214 | + | fs.outerBox.Append(&fs.formBox.Widget) | |||
| 192 | return nil | 215 | return nil | ||
| 193 | } | 216 | } | ||
| 194 | 217 | ||||
A internal/ui/component/favorites_sidebar_forms.go +433 -0
| @@ -0,0 +1,433 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/puregotk/v4/glib" | |
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type favoritesSidebarMode int | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | favoritesSidebarModeNone favoritesSidebarMode = iota | |
| 19 | + | favoritesSidebarModeAdd | |
| 20 | + | favoritesSidebarModeEdit | |
| 21 | + | favoritesSidebarModeTag | |
| 22 | + | favoritesSidebarModeShortcut | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | type favoritesSidebarFocusZone int | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | favoritesSidebarFocusSearch favoritesSidebarFocusZone = iota | |
| 29 | + | favoritesSidebarFocusTags | |
| 30 | + | favoritesSidebarFocusList | |
| 31 | + | favoritesSidebarFocusForm | |
| 32 | + | favoritesSidebarFocusConfirm | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | func (fs *FavoritesSidebar) beginAddForm() { | |
| 36 | + | fs.mu.Lock() | |
| 37 | + | if fs.destroyed { | |
| 38 | + | fs.mu.Unlock() | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | fs.mode = favoritesSidebarModeAdd | |
| 42 | + | fs.editingID = 0 | |
| 43 | + | fs.confirmDelete = false | |
| 44 | + | fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save." | |
| 45 | + | fs.mu.Unlock() | |
| 46 | + | fs.renderForm(nil) | |
| 47 | + | fs.rebuildList() | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (fs *FavoritesSidebar) beginEditForm() { | |
| 51 | + | fav := fs.selectedFavorite() | |
| 52 | + | if fav == nil { | |
| 53 | + | fs.setNotice("Select a favorite to edit") | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | fs.mu.Lock() | |
| 57 | + | if fs.destroyed { | |
| 58 | + | fs.mu.Unlock() | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | fs.mode = favoritesSidebarModeEdit | |
| 62 | + | fs.editingID = fav.ID | |
| 63 | + | fs.confirmDelete = false | |
| 64 | + | fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save." | |
| 65 | + | fs.mu.Unlock() | |
| 66 | + | fs.renderForm(fav) | |
| 67 | + | fs.rebuildList() | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |
| 71 | + | if fs == nil || fs.formBox == nil { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | clearBoxChildren(fs.formBox) | |
| 75 | + | fs.formURLEntry = gtk.NewSearchEntry() | |
| 76 | + | fs.formTitleEntry = gtk.NewSearchEntry() | |
| 77 | + | fs.formTagsEntry = nil | |
| 78 | + | if fav == nil { | |
| 79 | + | fs.formTagsEntry = gtk.NewSearchEntry() | |
| 80 | + | } | |
| 81 | + | fs.formShortcutEntry = gtk.NewSearchEntry() | |
| 82 | + | entries := []struct { | |
| 83 | + | entry *gtk.SearchEntry | |
| 84 | + | label string | |
| 85 | + | }{ | |
| 86 | + | {entry: fs.formURLEntry, label: "URL"}, | |
| 87 | + | {entry: fs.formTitleEntry, label: "Title"}, | |
| 88 | + | {entry: fs.formTagsEntry, label: "Tag IDs"}, | |
| 89 | + | {entry: fs.formShortcutEntry, label: "Shortcut"}, | |
| 90 | + | } | |
| 91 | + | for _, item := range entries { | |
| 92 | + | if item.entry == nil { | |
| 93 | + | continue | |
| 94 | + | } | |
| 95 | + | placeholder := item.label | |
| 96 | + | item.entry.SetPlaceholderText(&placeholder) | |
| 97 | + | fs.formBox.Append(&item.entry.Widget) | |
| 98 | + | } | |
| 99 | + | fs.formSaveButton = gtk.NewButtonWithLabel("Save") | |
| 100 | + | if fs.formSaveButton != nil { | |
| 101 | + | cb := func(_ gtk.Button) { | |
| 102 | + | fs.submitForm() | |
| 103 | + | } | |
| 104 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |
| 105 | + | fs.formSaveButton.ConnectClicked(&cb) | |
| 106 | + | fs.formBox.Append(&fs.formSaveButton.Widget) | |
| 107 | + | } | |
| 108 | + | if fav != nil { | |
| 109 | + | fs.formURL = fav.URL | |
| 110 | + | fs.formTitle = fav.Title | |
| 111 | + | fs.formTags = joinFavoriteTagIDs(fav.Tags) | |
| 112 | + | fs.formShortcut = "" | |
| 113 | + | if fav.ShortcutKey != nil { | |
| 114 | + | fs.formShortcut = strconv.Itoa(*fav.ShortcutKey) | |
| 115 | + | } | |
| 116 | + | if fs.formURLEntry != nil { | |
| 117 | + | fs.formURLEntry.SetText(fs.formURL) | |
| 118 | + | fs.formURLEntry.SetEditable(false) | |
| 119 | + | } | |
| 120 | + | if fs.formTitleEntry != nil { | |
| 121 | + | fs.formTitleEntry.SetText(fs.formTitle) | |
| 122 | + | } | |
| 123 | + | if fs.formTagsEntry != nil { | |
| 124 | + | fs.formTagsEntry.SetText(fs.formTags) | |
| 125 | + | } | |
| 126 | + | if fs.formShortcutEntry != nil { | |
| 127 | + | fs.formShortcutEntry.SetText(fs.formShortcut) | |
| 128 | + | } | |
| 129 | + | } else { | |
| 130 | + | fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", "" | |
| 131 | + | } | |
| 132 | + | fs.formBox.SetVisible(true) | |
| 133 | + | fs.focusForm() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (fs *FavoritesSidebar) focusForm() { | |
| 137 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| 138 | + | if fs != nil && fs.formURLEntry != nil { | |
| 139 | + | fs.formURLEntry.GrabFocus() | |
| 140 | + | } | |
| 141 | + | return false | |
| 142 | + | })) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (fs *FavoritesSidebar) cancelManagement() bool { | |
| 146 | + | fs.mu.Lock() | |
| 147 | + | if fs.destroyed { | |
| 148 | + | fs.mu.Unlock() | |
| 149 | + | return false | |
| 150 | + | } | |
| 151 | + | active := fs.mode != favoritesSidebarModeNone || fs.confirmDelete | |
| 152 | + | fs.mode = favoritesSidebarModeNone | |
| 153 | + | fs.editingID = 0 | |
| 154 | + | fs.confirmDelete = false | |
| 155 | + | if active { | |
| 156 | + | fs.notice = "" | |
| 157 | + | } | |
| 158 | + | fs.mu.Unlock() | |
| 159 | + | if fs.formBox != nil { | |
| 160 | + | fs.formBox.SetVisible(false) | |
| 161 | + | clearBoxChildren(fs.formBox) | |
| 162 | + | } | |
| 163 | + | fs.rebuildList() | |
| 164 | + | return active | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (fs *FavoritesSidebar) submitForm() bool { | |
| 168 | + | if fs == nil { | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | fs.mu.RLock() | |
| 172 | + | mode := fs.mode | |
| 173 | + | id := fs.editingID | |
| 174 | + | uc := fs.favoritesUC | |
| 175 | + | ctx := fs.ctx | |
| 176 | + | fs.mu.RUnlock() | |
| 177 | + | if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) { | |
| 178 | + | return false | |
| 179 | + | } | |
| 180 | + | url, title, tagsText, shortcutText := fs.formValues() | |
| 181 | + | if mode == favoritesSidebarModeAdd { | |
| 182 | + | tags, err := parseTagIDs(tagsText) | |
| 183 | + | if err != nil { | |
| 184 | + | fs.setNotice(err.Error()) | |
| 185 | + | return true | |
| 186 | + | } | |
| 187 | + | key, err := parseShortcut(shortcutText) | |
| 188 | + | if err != nil { | |
| 189 | + | fs.setNotice(err.Error()) | |
| 190 | + | return true | |
| 191 | + | } | |
| 192 | + | input := dto.FavoriteCreateInput{URL: url, Title: title, Tags: tags} | |
| 193 | + | fav, err := uc.AddFavorite(ctx, input) | |
| 194 | + | if err != nil { | |
| 195 | + | fs.setNotice(err.Error()) | |
| 196 | + | return true | |
| 197 | + | } | |
| 198 | + | if key != nil && fav != nil { | |
| 199 | + | if err := uc.SetShortcut(ctx, fav.ID, key); err != nil { | |
| 200 | + | fs.setNotice(err.Error()) | |
| 201 | + | return true | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } else { | |
| 205 | + | key, err := parseShortcut(shortcutText) | |
| 206 | + | if err != nil { | |
| 207 | + | fs.setNotice(err.Error()) | |
| 208 | + | return true | |
| 209 | + | } | |
| 210 | + | _, err = uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ID: id, Title: title, ShortcutKey: key, ShortcutKeySet: shortcutText != ""}) | |
| 211 | + | if err != nil { | |
| 212 | + | fs.setNotice(err.Error()) | |
| 213 | + | return true | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | fs.cancelManagement() | |
| 217 | + | fs.startLoad() | |
| 218 | + | return true | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (fs *FavoritesSidebar) enterTagMode() { | |
| 222 | + | fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite") | |
| 223 | + | } | |
| 224 | + | func (fs *FavoritesSidebar) enterShortcutMode() { | |
| 225 | + | fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice string) { | |
| 229 | + | fs.mu.Lock() | |
| 230 | + | if !fs.destroyed { | |
| 231 | + | fs.mode = mode | |
| 232 | + | fs.confirmDelete = false | |
| 233 | + | fs.notice = notice | |
| 234 | + | } | |
| 235 | + | fs.mu.Unlock() | |
| 236 | + | fs.rebuildList() | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool { | |
| 240 | + | fav := fs.selectedFavorite() | |
| 241 | + | if fav == nil || ord < 1 { | |
| 242 | + | fs.setNotice("Select a favorite and tag 1-9") | |
| 243 | + | return true | |
| 244 | + | } | |
| 245 | + | fs.mu.RLock() | |
| 246 | + | if ord > len(fs.allTags) || fs.favoritesUC == nil { | |
| 247 | + | fs.mu.RUnlock() | |
| 248 | + | fs.setNotice("No tag for key") | |
| 249 | + | return true | |
| 250 | + | } | |
| 251 | + | tagID := fs.allTags[ord-1].ID | |
| 252 | + | uc := fs.favoritesUC | |
| 253 | + | ctx := fs.ctx | |
| 254 | + | fs.mu.RUnlock() | |
| 255 | + | has := false | |
| 256 | + | for _, tag := range fav.Tags { | |
| 257 | + | if tag.ID == tagID { | |
| 258 | + | has = true | |
| 259 | + | break | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | var err error | |
| 263 | + | if has { | |
| 264 | + | err = uc.UntagFavorite(ctx, fav.ID, tagID) | |
| 265 | + | } else { | |
| 266 | + | err = uc.TagFavorite(ctx, fav.ID, tagID) | |
| 267 | + | } | |
| 268 | + | if err != nil { | |
| 269 | + | fs.setNotice(err.Error()) | |
| 270 | + | return true | |
| 271 | + | } | |
| 272 | + | fs.startLoad() | |
| 273 | + | return true | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (fs *FavoritesSidebar) setShortcutKey(key *int) bool { | |
| 277 | + | fav := fs.selectedFavorite() | |
| 278 | + | if fav == nil { | |
| 279 | + | fs.setNotice("Select a favorite for shortcut") | |
| 280 | + | return true | |
| 281 | + | } | |
| 282 | + | fs.mu.RLock() | |
| 283 | + | uc := fs.favoritesUC | |
| 284 | + | ctx := fs.ctx | |
| 285 | + | fs.mu.RUnlock() | |
| 286 | + | if uc == nil { | |
| 287 | + | return true | |
| 288 | + | } | |
| 289 | + | if err := uc.SetShortcut(ctx, fav.ID, key); err != nil { | |
| 290 | + | fs.setNotice(err.Error()) | |
| 291 | + | return true | |
| 292 | + | } | |
| 293 | + | fs.startLoad() | |
| 294 | + | return true | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func (fs *FavoritesSidebar) requestDeleteConfirmation() bool { | |
| 298 | + | if fs.selectedFavorite() == nil { | |
| 299 | + | fs.setNotice("Select a favorite to delete") | |
| 300 | + | return true | |
| 301 | + | } | |
| 302 | + | fs.mu.Lock() | |
| 303 | + | if !fs.destroyed { | |
| 304 | + | fs.confirmDelete = true | |
| 305 | + | fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel." | |
| 306 | + | } | |
| 307 | + | fs.mu.Unlock() | |
| 308 | + | fs.rebuildList() | |
| 309 | + | return true | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (fs *FavoritesSidebar) formModeActive() bool { | |
| 313 | + | if fs == nil { | |
| 314 | + | return false | |
| 315 | + | } | |
| 316 | + | fs.mu.RLock() | |
| 317 | + | defer fs.mu.RUnlock() | |
| 318 | + | return fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (fs *FavoritesSidebar) confirmDeleteActive() bool { | |
| 322 | + | if fs == nil { | |
| 323 | + | return false | |
| 324 | + | } | |
| 325 | + | fs.mu.RLock() | |
| 326 | + | defer fs.mu.RUnlock() | |
| 327 | + | return fs.confirmDelete | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func (fs *FavoritesSidebar) confirmDeleteFavorite() bool { | |
| 331 | + | fav := fs.selectedFavorite() | |
| 332 | + | if fav == nil { | |
| 333 | + | return fs.requestDeleteConfirmation() | |
| 334 | + | } | |
| 335 | + | fs.mu.RLock() | |
| 336 | + | uc := fs.favoritesUC | |
| 337 | + | ctx := fs.ctx | |
| 338 | + | fs.mu.RUnlock() | |
| 339 | + | if uc == nil { | |
| 340 | + | return true | |
| 341 | + | } | |
| 342 | + | if err := uc.DeleteFavorite(ctx, fav.ID); err != nil { | |
| 343 | + | fs.setNotice(err.Error()) | |
| 344 | + | return true | |
| 345 | + | } | |
| 346 | + | fs.cancelManagement() | |
| 347 | + | fs.startLoad() | |
| 348 | + | return true | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite { | |
| 352 | + | fs.mu.RLock() | |
| 353 | + | defer fs.mu.RUnlock() | |
| 354 | + | if len(fs.displayRows) == 1 { | |
| 355 | + | return fs.displayRows[0].Favorite | |
| 356 | + | } | |
| 357 | + | idx := firstSelectableIndex(fs.displayRows) | |
| 358 | + | if fs.listBox != nil { | |
| 359 | + | if row := fs.listBox.GetSelectedRow(); row != nil { | |
| 360 | + | idx = row.GetIndex() | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | if idx >= 0 && idx < len(fs.displayRows) { | |
| 364 | + | return fs.displayRows[idx].Favorite | |
| 365 | + | } | |
| 366 | + | return nil | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func (fs *FavoritesSidebar) setNotice(notice string) { | |
| 370 | + | fs.mu.Lock() | |
| 371 | + | if !fs.destroyed { | |
| 372 | + | fs.notice = notice | |
| 373 | + | } | |
| 374 | + | fs.mu.Unlock() | |
| 375 | + | fs.rebuildList() | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func (fs *FavoritesSidebar) formValues() (string, string, string, string) { | |
| 379 | + | url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut | |
| 380 | + | if fs.formURLEntry != nil { | |
| 381 | + | url = fs.formURLEntry.GetText() | |
| 382 | + | } | |
| 383 | + | if fs.formTitleEntry != nil { | |
| 384 | + | title = fs.formTitleEntry.GetText() | |
| 385 | + | } | |
| 386 | + | if fs.formTagsEntry != nil { | |
| 387 | + | tagsText = fs.formTagsEntry.GetText() | |
| 388 | + | } | |
| 389 | + | if fs.formShortcutEntry != nil { | |
| 390 | + | shortcut = fs.formShortcutEntry.GetText() | |
| 391 | + | } | |
| 392 | + | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func parseTagIDs(text string) ([]entity.TagID, error) { | |
| 396 | + | if text == "" { | |
| 397 | + | return nil, nil | |
| 398 | + | } | |
| 399 | + | parts := strings.Split(text, ",") | |
| 400 | + | ids := make([]entity.TagID, 0, len(parts)) | |
| 401 | + | for _, part := range parts { | |
| 402 | + | trimmed := strings.TrimSpace(part) | |
| 403 | + | if trimmed == "" { | |
| 404 | + | return nil, fmt.Errorf("invalid tag ID %q", part) | |
| 405 | + | } | |
| 406 | + | v, err := strconv.Atoi(trimmed) | |
| 407 | + | if err != nil || v <= 0 { | |
| 408 | + | return nil, fmt.Errorf("invalid tag ID %q", trimmed) | |
| 409 | + | } | |
| 410 | + | ids = append(ids, entity.TagID(v)) | |
| 411 | + | } | |
| 412 | + | return ids, nil | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func parseShortcut(text string) (*int, error) { | |
| 416 | + | text = strings.TrimSpace(text) | |
| 417 | + | if text == "" { | |
| 418 | + | return nil, nil | |
| 419 | + | } | |
| 420 | + | v, err := strconv.Atoi(text) | |
| 421 | + | if err != nil || v < 1 || v > 9 { | |
| 422 | + | return nil, fmt.Errorf("invalid shortcut %q (use 1-9)", text) | |
| 423 | + | } | |
| 424 | + | return &v, nil | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func joinFavoriteTagIDs(tags []entity.Tag) string { | |
| 428 | + | parts := make([]string, 0, len(tags)) | |
| 429 | + | for _, tag := range tags { | |
| 430 | + | parts = append(parts, fmt.Sprint(tag.ID)) | |
| 431 | + | } | |
| 432 | + | return strings.Join(parts, ",") | |
| 433 | + | } | |
| @@ -0,0 +1,433 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type favoritesSidebarMode int | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | favoritesSidebarModeNone favoritesSidebarMode = iota | |||
| 19 | + | favoritesSidebarModeAdd | |||
| 20 | + | favoritesSidebarModeEdit | |||
| 21 | + | favoritesSidebarModeTag | |||
| 22 | + | favoritesSidebarModeShortcut | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | type favoritesSidebarFocusZone int | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | favoritesSidebarFocusSearch favoritesSidebarFocusZone = iota | |||
| 29 | + | favoritesSidebarFocusTags | |||
| 30 | + | favoritesSidebarFocusList | |||
| 31 | + | favoritesSidebarFocusForm | |||
| 32 | + | favoritesSidebarFocusConfirm | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | func (fs *FavoritesSidebar) beginAddForm() { | |||
| 36 | + | fs.mu.Lock() | |||
| 37 | + | if fs.destroyed { | |||
| 38 | + | fs.mu.Unlock() | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | fs.mode = favoritesSidebarModeAdd | |||
| 42 | + | fs.editingID = 0 | |||
| 43 | + | fs.confirmDelete = false | |||
| 44 | + | fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save." | |||
| 45 | + | fs.mu.Unlock() | |||
| 46 | + | fs.renderForm(nil) | |||
| 47 | + | fs.rebuildList() | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (fs *FavoritesSidebar) beginEditForm() { | |||
| 51 | + | fav := fs.selectedFavorite() | |||
| 52 | + | if fav == nil { | |||
| 53 | + | fs.setNotice("Select a favorite to edit") | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | fs.mu.Lock() | |||
| 57 | + | if fs.destroyed { | |||
| 58 | + | fs.mu.Unlock() | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | fs.mode = favoritesSidebarModeEdit | |||
| 62 | + | fs.editingID = fav.ID | |||
| 63 | + | fs.confirmDelete = false | |||
| 64 | + | fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save." | |||
| 65 | + | fs.mu.Unlock() | |||
| 66 | + | fs.renderForm(fav) | |||
| 67 | + | fs.rebuildList() | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 71 | + | if fs == nil || fs.formBox == nil { | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | clearBoxChildren(fs.formBox) | |||
| 75 | + | fs.formURLEntry = gtk.NewSearchEntry() | |||
| 76 | + | fs.formTitleEntry = gtk.NewSearchEntry() | |||
| 77 | + | fs.formTagsEntry = nil | |||
| 78 | + | if fav == nil { | |||
| 79 | + | fs.formTagsEntry = gtk.NewSearchEntry() | |||
| 80 | + | } | |||
| 81 | + | fs.formShortcutEntry = gtk.NewSearchEntry() | |||
| 82 | + | entries := []struct { | |||
| 83 | + | entry *gtk.SearchEntry | |||
| 84 | + | label string | |||
| 85 | + | }{ | |||
| 86 | + | {entry: fs.formURLEntry, label: "URL"}, | |||
| 87 | + | {entry: fs.formTitleEntry, label: "Title"}, | |||
| 88 | + | {entry: fs.formTagsEntry, label: "Tag IDs"}, | |||
| 89 | + | {entry: fs.formShortcutEntry, label: "Shortcut"}, | |||
| 90 | + | } | |||
| 91 | + | for _, item := range entries { | |||
| 92 | + | if item.entry == nil { | |||
| 93 | + | continue | |||
| 94 | + | } | |||
| 95 | + | placeholder := item.label | |||
| 96 | + | item.entry.SetPlaceholderText(&placeholder) | |||
| 97 | + | fs.formBox.Append(&item.entry.Widget) | |||
| 98 | + | } | |||
| 99 | + | fs.formSaveButton = gtk.NewButtonWithLabel("Save") | |||
| 100 | + | if fs.formSaveButton != nil { | |||
| 101 | + | cb := func(_ gtk.Button) { | |||
| 102 | + | fs.submitForm() | |||
| 103 | + | } | |||
| 104 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |||
| 105 | + | fs.formSaveButton.ConnectClicked(&cb) | |||
| 106 | + | fs.formBox.Append(&fs.formSaveButton.Widget) | |||
| 107 | + | } | |||
| 108 | + | if fav != nil { | |||
| 109 | + | fs.formURL = fav.URL | |||
| 110 | + | fs.formTitle = fav.Title | |||
| 111 | + | fs.formTags = joinFavoriteTagIDs(fav.Tags) | |||
| 112 | + | fs.formShortcut = "" | |||
| 113 | + | if fav.ShortcutKey != nil { | |||
| 114 | + | fs.formShortcut = strconv.Itoa(*fav.ShortcutKey) | |||
| 115 | + | } | |||
| 116 | + | if fs.formURLEntry != nil { | |||
| 117 | + | fs.formURLEntry.SetText(fs.formURL) | |||
| 118 | + | fs.formURLEntry.SetEditable(false) | |||
| 119 | + | } | |||
| 120 | + | if fs.formTitleEntry != nil { | |||
| 121 | + | fs.formTitleEntry.SetText(fs.formTitle) | |||
| 122 | + | } | |||
| 123 | + | if fs.formTagsEntry != nil { | |||
| 124 | + | fs.formTagsEntry.SetText(fs.formTags) | |||
| 125 | + | } | |||
| 126 | + | if fs.formShortcutEntry != nil { | |||
| 127 | + | fs.formShortcutEntry.SetText(fs.formShortcut) | |||
| 128 | + | } | |||
| 129 | + | } else { | |||
| 130 | + | fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", "" | |||
| 131 | + | } | |||
| 132 | + | fs.formBox.SetVisible(true) | |||
| 133 | + | fs.focusForm() | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (fs *FavoritesSidebar) focusForm() { | |||
| 137 | + | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |||
| 138 | + | if fs != nil && fs.formURLEntry != nil { | |||
| 139 | + | fs.formURLEntry.GrabFocus() | |||
| 140 | + | } | |||
| 141 | + | return false | |||
| 142 | + | })) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (fs *FavoritesSidebar) cancelManagement() bool { | |||
| 146 | + | fs.mu.Lock() | |||
| 147 | + | if fs.destroyed { | |||
| 148 | + | fs.mu.Unlock() | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | active := fs.mode != favoritesSidebarModeNone || fs.confirmDelete | |||
| 152 | + | fs.mode = favoritesSidebarModeNone | |||
| 153 | + | fs.editingID = 0 | |||
| 154 | + | fs.confirmDelete = false | |||
| 155 | + | if active { | |||
| 156 | + | fs.notice = "" | |||
| 157 | + | } | |||
| 158 | + | fs.mu.Unlock() | |||
| 159 | + | if fs.formBox != nil { | |||
| 160 | + | fs.formBox.SetVisible(false) | |||
| 161 | + | clearBoxChildren(fs.formBox) | |||
| 162 | + | } | |||
| 163 | + | fs.rebuildList() | |||
| 164 | + | return active | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (fs *FavoritesSidebar) submitForm() bool { | |||
| 168 | + | if fs == nil { | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | fs.mu.RLock() | |||
| 172 | + | mode := fs.mode | |||
| 173 | + | id := fs.editingID | |||
| 174 | + | uc := fs.favoritesUC | |||
| 175 | + | ctx := fs.ctx | |||
| 176 | + | fs.mu.RUnlock() | |||
| 177 | + | if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) { | |||
| 178 | + | return false | |||
| 179 | + | } | |||
| 180 | + | url, title, tagsText, shortcutText := fs.formValues() | |||
| 181 | + | if mode == favoritesSidebarModeAdd { | |||
| 182 | + | tags, err := parseTagIDs(tagsText) | |||
| 183 | + | if err != nil { | |||
| 184 | + | fs.setNotice(err.Error()) | |||
| 185 | + | return true | |||
| 186 | + | } | |||
| 187 | + | key, err := parseShortcut(shortcutText) | |||
| 188 | + | if err != nil { | |||
| 189 | + | fs.setNotice(err.Error()) | |||
| 190 | + | return true | |||
| 191 | + | } | |||
| 192 | + | input := dto.FavoriteCreateInput{URL: url, Title: title, Tags: tags} | |||
| 193 | + | fav, err := uc.AddFavorite(ctx, input) | |||
| 194 | + | if err != nil { | |||
| 195 | + | fs.setNotice(err.Error()) | |||
| 196 | + | return true | |||
| 197 | + | } | |||
| 198 | + | if key != nil && fav != nil { | |||
| 199 | + | if err := uc.SetShortcut(ctx, fav.ID, key); err != nil { | |||
| 200 | + | fs.setNotice(err.Error()) | |||
| 201 | + | return true | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | } else { | |||
| 205 | + | key, err := parseShortcut(shortcutText) | |||
| 206 | + | if err != nil { | |||
| 207 | + | fs.setNotice(err.Error()) | |||
| 208 | + | return true | |||
| 209 | + | } | |||
| 210 | + | _, err = uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ID: id, Title: title, ShortcutKey: key, ShortcutKeySet: shortcutText != ""}) | |||
| 211 | + | if err != nil { | |||
| 212 | + | fs.setNotice(err.Error()) | |||
| 213 | + | return true | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | fs.cancelManagement() | |||
| 217 | + | fs.startLoad() | |||
| 218 | + | return true | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (fs *FavoritesSidebar) enterTagMode() { | |||
| 222 | + | fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite") | |||
| 223 | + | } | |||
| 224 | + | func (fs *FavoritesSidebar) enterShortcutMode() { | |||
| 225 | + | fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice string) { | |||
| 229 | + | fs.mu.Lock() | |||
| 230 | + | if !fs.destroyed { | |||
| 231 | + | fs.mode = mode | |||
| 232 | + | fs.confirmDelete = false | |||
| 233 | + | fs.notice = notice | |||
| 234 | + | } | |||
| 235 | + | fs.mu.Unlock() | |||
| 236 | + | fs.rebuildList() | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool { | |||
| 240 | + | fav := fs.selectedFavorite() | |||
| 241 | + | if fav == nil || ord < 1 { | |||
| 242 | + | fs.setNotice("Select a favorite and tag 1-9") | |||
| 243 | + | return true | |||
| 244 | + | } | |||
| 245 | + | fs.mu.RLock() | |||
| 246 | + | if ord > len(fs.allTags) || fs.favoritesUC == nil { | |||
| 247 | + | fs.mu.RUnlock() | |||
| 248 | + | fs.setNotice("No tag for key") | |||
| 249 | + | return true | |||
| 250 | + | } | |||
| 251 | + | tagID := fs.allTags[ord-1].ID | |||
| 252 | + | uc := fs.favoritesUC | |||
| 253 | + | ctx := fs.ctx | |||
| 254 | + | fs.mu.RUnlock() | |||
| 255 | + | has := false | |||
| 256 | + | for _, tag := range fav.Tags { | |||
| 257 | + | if tag.ID == tagID { | |||
| 258 | + | has = true | |||
| 259 | + | break | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | var err error | |||
| 263 | + | if has { | |||
| 264 | + | err = uc.UntagFavorite(ctx, fav.ID, tagID) | |||
| 265 | + | } else { | |||
| 266 | + | err = uc.TagFavorite(ctx, fav.ID, tagID) | |||
| 267 | + | } | |||
| 268 | + | if err != nil { | |||
| 269 | + | fs.setNotice(err.Error()) | |||
| 270 | + | return true | |||
| 271 | + | } | |||
| 272 | + | fs.startLoad() | |||
| 273 | + | return true | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (fs *FavoritesSidebar) setShortcutKey(key *int) bool { | |||
| 277 | + | fav := fs.selectedFavorite() | |||
| 278 | + | if fav == nil { | |||
| 279 | + | fs.setNotice("Select a favorite for shortcut") | |||
| 280 | + | return true | |||
| 281 | + | } | |||
| 282 | + | fs.mu.RLock() | |||
| 283 | + | uc := fs.favoritesUC | |||
| 284 | + | ctx := fs.ctx | |||
| 285 | + | fs.mu.RUnlock() | |||
| 286 | + | if uc == nil { | |||
| 287 | + | return true | |||
| 288 | + | } | |||
| 289 | + | if err := uc.SetShortcut(ctx, fav.ID, key); err != nil { | |||
| 290 | + | fs.setNotice(err.Error()) | |||
| 291 | + | return true | |||
| 292 | + | } | |||
| 293 | + | fs.startLoad() | |||
| 294 | + | return true | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (fs *FavoritesSidebar) requestDeleteConfirmation() bool { | |||
| 298 | + | if fs.selectedFavorite() == nil { | |||
| 299 | + | fs.setNotice("Select a favorite to delete") | |||
| 300 | + | return true | |||
| 301 | + | } | |||
| 302 | + | fs.mu.Lock() | |||
| 303 | + | if !fs.destroyed { | |||
| 304 | + | fs.confirmDelete = true | |||
| 305 | + | fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel." | |||
| 306 | + | } | |||
| 307 | + | fs.mu.Unlock() | |||
| 308 | + | fs.rebuildList() | |||
| 309 | + | return true | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func (fs *FavoritesSidebar) formModeActive() bool { | |||
| 313 | + | if fs == nil { | |||
| 314 | + | return false | |||
| 315 | + | } | |||
| 316 | + | fs.mu.RLock() | |||
| 317 | + | defer fs.mu.RUnlock() | |||
| 318 | + | return fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func (fs *FavoritesSidebar) confirmDeleteActive() bool { | |||
| 322 | + | if fs == nil { | |||
| 323 | + | return false | |||
| 324 | + | } | |||
| 325 | + | fs.mu.RLock() | |||
| 326 | + | defer fs.mu.RUnlock() | |||
| 327 | + | return fs.confirmDelete | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func (fs *FavoritesSidebar) confirmDeleteFavorite() bool { | |||
| 331 | + | fav := fs.selectedFavorite() | |||
| 332 | + | if fav == nil { | |||
| 333 | + | return fs.requestDeleteConfirmation() | |||
| 334 | + | } | |||
| 335 | + | fs.mu.RLock() | |||
| 336 | + | uc := fs.favoritesUC | |||
| 337 | + | ctx := fs.ctx | |||
| 338 | + | fs.mu.RUnlock() | |||
| 339 | + | if uc == nil { | |||
| 340 | + | return true | |||
| 341 | + | } | |||
| 342 | + | if err := uc.DeleteFavorite(ctx, fav.ID); err != nil { | |||
| 343 | + | fs.setNotice(err.Error()) | |||
| 344 | + | return true | |||
| 345 | + | } | |||
| 346 | + | fs.cancelManagement() | |||
| 347 | + | fs.startLoad() | |||
| 348 | + | return true | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite { | |||
| 352 | + | fs.mu.RLock() | |||
| 353 | + | defer fs.mu.RUnlock() | |||
| 354 | + | if len(fs.displayRows) == 1 { | |||
| 355 | + | return fs.displayRows[0].Favorite | |||
| 356 | + | } | |||
| 357 | + | idx := firstSelectableIndex(fs.displayRows) | |||
| 358 | + | if fs.listBox != nil { | |||
| 359 | + | if row := fs.listBox.GetSelectedRow(); row != nil { | |||
| 360 | + | idx = row.GetIndex() | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | if idx >= 0 && idx < len(fs.displayRows) { | |||
| 364 | + | return fs.displayRows[idx].Favorite | |||
| 365 | + | } | |||
| 366 | + | return nil | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func (fs *FavoritesSidebar) setNotice(notice string) { | |||
| 370 | + | fs.mu.Lock() | |||
| 371 | + | if !fs.destroyed { | |||
| 372 | + | fs.notice = notice | |||
| 373 | + | } | |||
| 374 | + | fs.mu.Unlock() | |||
| 375 | + | fs.rebuildList() | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func (fs *FavoritesSidebar) formValues() (string, string, string, string) { | |||
| 379 | + | url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut | |||
| 380 | + | if fs.formURLEntry != nil { | |||
| 381 | + | url = fs.formURLEntry.GetText() | |||
| 382 | + | } | |||
| 383 | + | if fs.formTitleEntry != nil { | |||
| 384 | + | title = fs.formTitleEntry.GetText() | |||
| 385 | + | } | |||
| 386 | + | if fs.formTagsEntry != nil { | |||
| 387 | + | tagsText = fs.formTagsEntry.GetText() | |||
| 388 | + | } | |||
| 389 | + | if fs.formShortcutEntry != nil { | |||
| 390 | + | shortcut = fs.formShortcutEntry.GetText() | |||
| 391 | + | } | |||
| 392 | + | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func parseTagIDs(text string) ([]entity.TagID, error) { | |||
| 396 | + | if text == "" { | |||
| 397 | + | return nil, nil | |||
| 398 | + | } | |||
| 399 | + | parts := strings.Split(text, ",") | |||
| 400 | + | ids := make([]entity.TagID, 0, len(parts)) | |||
| 401 | + | for _, part := range parts { | |||
| 402 | + | trimmed := strings.TrimSpace(part) | |||
| 403 | + | if trimmed == "" { | |||
| 404 | + | return nil, fmt.Errorf("invalid tag ID %q", part) | |||
| 405 | + | } | |||
| 406 | + | v, err := strconv.Atoi(trimmed) | |||
| 407 | + | if err != nil || v <= 0 { | |||
| 408 | + | return nil, fmt.Errorf("invalid tag ID %q", trimmed) | |||
| 409 | + | } | |||
| 410 | + | ids = append(ids, entity.TagID(v)) | |||
| 411 | + | } | |||
| 412 | + | return ids, nil | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func parseShortcut(text string) (*int, error) { | |||
| 416 | + | text = strings.TrimSpace(text) | |||
| 417 | + | if text == "" { | |||
| 418 | + | return nil, nil | |||
| 419 | + | } | |||
| 420 | + | v, err := strconv.Atoi(text) | |||
| 421 | + | if err != nil || v < 1 || v > 9 { | |||
| 422 | + | return nil, fmt.Errorf("invalid shortcut %q (use 1-9)", text) | |||
| 423 | + | } | |||
| 424 | + | return &v, nil | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func joinFavoriteTagIDs(tags []entity.Tag) string { | |||
| 428 | + | parts := make([]string, 0, len(tags)) | |||
| 429 | + | for _, tag := range tags { | |||
| 430 | + | parts = append(parts, fmt.Sprint(tag.ID)) | |||
| 431 | + | } | |||
| 432 | + | return strings.Join(parts, ",") | |||
| 433 | + | } | |||
M internal/ui/component/favorites_sidebar_keyboard.go +190 -2
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "github.com/bnema/puregotk/v4/gdk" | |
| 5 | 5 | "github.com/bnema/puregotk/v4/glib" | |
| 6 | 6 | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |
| @@ -27,6 +29,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 27 | 29 | ||
| 28 | 30 | switch keyval { | |
| 29 | 31 | case uint(gdk.KEY_Escape): | |
| 32 | + | if fs.cancelManagement() { | |
| 33 | + | return true | |
| 34 | + | } | |
| 30 | 35 | if searchEntry != nil && searchEntry.GetText() != "" { | |
| 31 | 36 | searchEntry.SetText("") | |
| 32 | 37 | return true | |
| @@ -36,7 +41,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 36 | 41 | } | |
| 37 | 42 | return true | |
| 38 | 43 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 39 | - | return fs.handleEnterKey(state) | |
| 44 | + | return fs.handleReturnKey(state) | |
| 45 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |
| 46 | + | fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0) | |
| 47 | + | return true | |
| 40 | 48 | case uint(gdk.KEY_Page_Up): | |
| 41 | 49 | fs.selectAdjacentRow(-5) | |
| 42 | 50 | return true | |
| @@ -62,6 +70,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 62 | 70 | fs.selectAdjacentRow(1) | |
| 63 | 71 | return true | |
| 64 | 72 | case uint(gdk.KEY_slash): | |
| 73 | + | if fs.inTextEditContext() { | |
| 74 | + | return false | |
| 75 | + | } | |
| 65 | 76 | searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus() | |
| 66 | 77 | if !shouldFocusSearchForSlash(searchFocused) { | |
| 67 | 78 | return false | |
| @@ -69,13 +80,30 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 69 | 80 | fs.focusSearch() | |
| 70 | 81 | return true | |
| 71 | 82 | } | |
| 72 | - | return false | |
| 83 | + | return fs.handleSingleKeyCommand(keyval) | |
| 73 | 84 | } | |
| 74 | 85 | fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb) | |
| 75 | 86 | keyController.ConnectKeyPressed(&keyPressedCb) | |
| 76 | 87 | fs.outerBox.AddController(&keyController.EventController) | |
| 77 | 88 | } | |
| 78 | 89 | ||
| 90 | + | func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool { | |
| 91 | + | if fs == nil { | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | if fs.confirmDeleteActive() { | |
| 95 | + | return fs.confirmDeleteFavorite() | |
| 96 | + | } | |
| 97 | + | formActive := fs.formModeActive() | |
| 98 | + | if state&gdk.ControlMaskValue != 0 && fs.submitForm() { | |
| 99 | + | return true | |
| 100 | + | } | |
| 101 | + | if formActive { | |
| 102 | + | return false | |
| 103 | + | } | |
| 104 | + | return fs.handleEnterKey(state) | |
| 105 | + | } | |
| 106 | + | ||
| 79 | 107 | func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool { | |
| 80 | 108 | if fs == nil { | |
| 81 | 109 | return false | |
| @@ -231,6 +259,166 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |||
| 231 | 259 | ||
| 232 | 260 | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | |
| 233 | 261 | ||
| 262 | + | func (fs *FavoritesSidebar) inTextEditContext() bool { | |
| 263 | + | if fs == nil { | |
| 264 | + | return false | |
| 265 | + | } | |
| 266 | + | if fs.searchEntry != nil && fs.searchEntry.Widget.HasFocus() { | |
| 267 | + | return true | |
| 268 | + | } | |
| 269 | + | for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} { | |
| 270 | + | if entry != nil && entry.Widget.HasFocus() { | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | return false | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint) bool { | |
| 278 | + | if fs == nil || fs.inTextEditContext() { | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | fs.mu.RLock() | |
| 282 | + | mode := fs.mode | |
| 283 | + | confirm := fs.confirmDelete | |
| 284 | + | fs.mu.RUnlock() | |
| 285 | + | if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |
| 286 | + | return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0))) | |
| 287 | + | } | |
| 288 | + | if mode == favoritesSidebarModeShortcut { | |
| 289 | + | if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |
| 290 | + | v := int(keyval - uint(gdk.KEY_0)) | |
| 291 | + | return fs.setShortcutKey(&v) | |
| 292 | + | } | |
| 293 | + | if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) { | |
| 294 | + | return fs.setShortcutKey(nil) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | if keyval == uint(gdk.KEY_Delete) { | |
| 298 | + | if confirm { | |
| 299 | + | return fs.confirmDeleteFavorite() | |
| 300 | + | } | |
| 301 | + | return fs.requestDeleteConfirmation() | |
| 302 | + | } | |
| 303 | + | switch keyval { | |
| 304 | + | case uint(gdk.KEY_a): | |
| 305 | + | fs.beginAddForm() | |
| 306 | + | return true | |
| 307 | + | case uint(gdk.KEY_e): | |
| 308 | + | fs.beginEditForm() | |
| 309 | + | return true | |
| 310 | + | case uint(gdk.KEY_t): | |
| 311 | + | fs.enterTagMode() | |
| 312 | + | return true | |
| 313 | + | case uint(gdk.KEY_s): | |
| 314 | + | fs.enterShortcutMode() | |
| 315 | + | return true | |
| 316 | + | case uint(gdk.KEY_r): | |
| 317 | + | fs.startLoad() | |
| 318 | + | return true | |
| 319 | + | case uint(gdk.KEY_c): | |
| 320 | + | fs.clearQueryAndTags() | |
| 321 | + | return true | |
| 322 | + | } | |
| 323 | + | return false | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (fs *FavoritesSidebar) clearQueryAndTags() { | |
| 327 | + | if fs == nil { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | if fs.searchEntry != nil { | |
| 331 | + | fs.searchEntry.SetText("") | |
| 332 | + | } | |
| 333 | + | fs.mu.Lock() | |
| 334 | + | if !fs.destroyed { | |
| 335 | + | fs.currentQuery = "" | |
| 336 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |
| 337 | + | fs.rebuildDisplayRowsLocked() | |
| 338 | + | } | |
| 339 | + | fs.mu.Unlock() | |
| 340 | + | fs.renderTags() | |
| 341 | + | fs.rebuildList() | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |
| 345 | + | if fs == nil { | |
| 346 | + | return | |
| 347 | + | } | |
| 348 | + | zones := fs.availableFocusZones() | |
| 349 | + | if len(zones) == 0 { | |
| 350 | + | return | |
| 351 | + | } | |
| 352 | + | fs.mu.RLock() | |
| 353 | + | current := fs.focusZone | |
| 354 | + | fs.mu.RUnlock() | |
| 355 | + | idx := -1 | |
| 356 | + | for i, zone := range zones { | |
| 357 | + | if zone == current { | |
| 358 | + | idx = i | |
| 359 | + | break | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | if reverse { | |
| 363 | + | idx-- | |
| 364 | + | if idx < 0 { | |
| 365 | + | idx = len(zones) - 1 | |
| 366 | + | } | |
| 367 | + | } else { | |
| 368 | + | idx++ | |
| 369 | + | if idx >= len(zones) { | |
| 370 | + | idx = 0 | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | fs.focusZoneWidget(zones[idx]) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone { | |
| 377 | + | zones := []favoritesSidebarFocusZone{} | |
| 378 | + | if fs.searchEntry != nil { | |
| 379 | + | zones = append(zones, favoritesSidebarFocusSearch) | |
| 380 | + | } | |
| 381 | + | if fs.tagBox != nil { | |
| 382 | + | zones = append(zones, favoritesSidebarFocusTags) | |
| 383 | + | } | |
| 384 | + | if fs.listBox != nil { | |
| 385 | + | zones = append(zones, favoritesSidebarFocusList) | |
| 386 | + | } | |
| 387 | + | fs.mu.RLock() | |
| 388 | + | formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit | |
| 389 | + | confirmActive := fs.confirmDelete | |
| 390 | + | fs.mu.RUnlock() | |
| 391 | + | if formActive && fs.formBox != nil { | |
| 392 | + | zones = append(zones, favoritesSidebarFocusForm) | |
| 393 | + | } | |
| 394 | + | if confirmActive { | |
| 395 | + | zones = append(zones, favoritesSidebarFocusConfirm) | |
| 396 | + | } | |
| 397 | + | return zones | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) { | |
| 401 | + | fs.mu.Lock() | |
| 402 | + | if !fs.destroyed { | |
| 403 | + | fs.focusZone = zone | |
| 404 | + | } | |
| 405 | + | fs.mu.Unlock() | |
| 406 | + | switch zone { | |
| 407 | + | case favoritesSidebarFocusSearch: | |
| 408 | + | fs.focusSearch() | |
| 409 | + | case favoritesSidebarFocusTags: | |
| 410 | + | if fs.tagBox != nil { | |
| 411 | + | fs.tagBox.GrabFocus() | |
| 412 | + | } | |
| 413 | + | case favoritesSidebarFocusList, favoritesSidebarFocusConfirm: | |
| 414 | + | if fs.listBox != nil { | |
| 415 | + | fs.listBox.GrabFocus() | |
| 416 | + | } | |
| 417 | + | case favoritesSidebarFocusForm: | |
| 418 | + | fs.focusForm() | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 234 | 422 | func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool { | |
| 235 | 423 | if fs == nil || url == "" { | |
| 236 | 424 | return false | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "github.com/bnema/puregotk/v4/gdk" | 4 | "github.com/bnema/puregotk/v4/gdk" | ||
| 5 | "github.com/bnema/puregotk/v4/glib" | 5 | "github.com/bnema/puregotk/v4/glib" | ||
| 6 | "github.com/bnema/puregotk/v4/gtk" | 6 | "github.com/bnema/puregotk/v4/gtk" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | func (fs *FavoritesSidebar) setupKeyboardNavigation() { | 11 | func (fs *FavoritesSidebar) setupKeyboardNavigation() { | ||
| @@ -27,6 +29,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 27 | 29 | ||||
| 28 | switch keyval { | 30 | switch keyval { | ||
| 29 | case uint(gdk.KEY_Escape): | 31 | case uint(gdk.KEY_Escape): | ||
| 32 | + | if fs.cancelManagement() { | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 30 | if searchEntry != nil && searchEntry.GetText() != "" { | 35 | if searchEntry != nil && searchEntry.GetText() != "" { | ||
| 31 | searchEntry.SetText("") | 36 | searchEntry.SetText("") | ||
| 32 | return true | 37 | return true | ||
| @@ -36,7 +41,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 36 | } | 41 | } | ||
| 37 | return true | 42 | return true | ||
| 38 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | 43 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | ||
| 39 | - | return fs.handleEnterKey(state) | 44 | + | return fs.handleReturnKey(state) |
| 45 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |||
| 46 | + | fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0) | |||
| 47 | + | return true | |||
| 40 | case uint(gdk.KEY_Page_Up): | 48 | case uint(gdk.KEY_Page_Up): | ||
| 41 | fs.selectAdjacentRow(-5) | 49 | fs.selectAdjacentRow(-5) | ||
| 42 | return true | 50 | return true | ||
| @@ -62,6 +70,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 62 | fs.selectAdjacentRow(1) | 70 | fs.selectAdjacentRow(1) | ||
| 63 | return true | 71 | return true | ||
| 64 | case uint(gdk.KEY_slash): | 72 | case uint(gdk.KEY_slash): | ||
| 73 | + | if fs.inTextEditContext() { | |||
| 74 | + | return false | |||
| 75 | + | } | |||
| 65 | searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus() | 76 | searchFocused := searchEntry != nil && searchEntry.Widget.HasFocus() | ||
| 66 | if !shouldFocusSearchForSlash(searchFocused) { | 77 | if !shouldFocusSearchForSlash(searchFocused) { | ||
| 67 | return false | 78 | return false | ||
| @@ -69,13 +80,30 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 69 | fs.focusSearch() | 80 | fs.focusSearch() | ||
| 70 | return true | 81 | return true | ||
| 71 | } | 82 | } | ||
| 72 | - | return false | 83 | + | return fs.handleSingleKeyCommand(keyval) |
| 73 | } | 84 | } | ||
| 74 | fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb) | 85 | fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb) | ||
| 75 | keyController.ConnectKeyPressed(&keyPressedCb) | 86 | keyController.ConnectKeyPressed(&keyPressedCb) | ||
| 76 | fs.outerBox.AddController(&keyController.EventController) | 87 | fs.outerBox.AddController(&keyController.EventController) | ||
| 77 | } | 88 | } | ||
| 78 | 89 | ||||
| 90 | + | func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool { | |||
| 91 | + | if fs == nil { | |||
| 92 | + | return false | |||
| 93 | + | } | |||
| 94 | + | if fs.confirmDeleteActive() { | |||
| 95 | + | return fs.confirmDeleteFavorite() | |||
| 96 | + | } | |||
| 97 | + | formActive := fs.formModeActive() | |||
| 98 | + | if state&gdk.ControlMaskValue != 0 && fs.submitForm() { | |||
| 99 | + | return true | |||
| 100 | + | } | |||
| 101 | + | if formActive { | |||
| 102 | + | return false | |||
| 103 | + | } | |||
| 104 | + | return fs.handleEnterKey(state) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 79 | func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool { | 107 | func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool { | ||
| 80 | if fs == nil { | 108 | if fs == nil { | ||
| 81 | return false | 109 | return false | ||
| @@ -231,6 +259,166 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |||||
| 231 | 259 | ||||
| 232 | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | 260 | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | ||
| 233 | 261 | ||||
| 262 | + | func (fs *FavoritesSidebar) inTextEditContext() bool { | |||
| 263 | + | if fs == nil { | |||
| 264 | + | return false | |||
| 265 | + | } | |||
| 266 | + | if fs.searchEntry != nil && fs.searchEntry.Widget.HasFocus() { | |||
| 267 | + | return true | |||
| 268 | + | } | |||
| 269 | + | for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} { | |||
| 270 | + | if entry != nil && entry.Widget.HasFocus() { | |||
| 271 | + | return true | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | return false | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint) bool { | |||
| 278 | + | if fs == nil || fs.inTextEditContext() { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | fs.mu.RLock() | |||
| 282 | + | mode := fs.mode | |||
| 283 | + | confirm := fs.confirmDelete | |||
| 284 | + | fs.mu.RUnlock() | |||
| 285 | + | if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |||
| 286 | + | return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0))) | |||
| 287 | + | } | |||
| 288 | + | if mode == favoritesSidebarModeShortcut { | |||
| 289 | + | if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |||
| 290 | + | v := int(keyval - uint(gdk.KEY_0)) | |||
| 291 | + | return fs.setShortcutKey(&v) | |||
| 292 | + | } | |||
| 293 | + | if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) { | |||
| 294 | + | return fs.setShortcutKey(nil) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | if keyval == uint(gdk.KEY_Delete) { | |||
| 298 | + | if confirm { | |||
| 299 | + | return fs.confirmDeleteFavorite() | |||
| 300 | + | } | |||
| 301 | + | return fs.requestDeleteConfirmation() | |||
| 302 | + | } | |||
| 303 | + | switch keyval { | |||
| 304 | + | case uint(gdk.KEY_a): | |||
| 305 | + | fs.beginAddForm() | |||
| 306 | + | return true | |||
| 307 | + | case uint(gdk.KEY_e): | |||
| 308 | + | fs.beginEditForm() | |||
| 309 | + | return true | |||
| 310 | + | case uint(gdk.KEY_t): | |||
| 311 | + | fs.enterTagMode() | |||
| 312 | + | return true | |||
| 313 | + | case uint(gdk.KEY_s): | |||
| 314 | + | fs.enterShortcutMode() | |||
| 315 | + | return true | |||
| 316 | + | case uint(gdk.KEY_r): | |||
| 317 | + | fs.startLoad() | |||
| 318 | + | return true | |||
| 319 | + | case uint(gdk.KEY_c): | |||
| 320 | + | fs.clearQueryAndTags() | |||
| 321 | + | return true | |||
| 322 | + | } | |||
| 323 | + | return false | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (fs *FavoritesSidebar) clearQueryAndTags() { | |||
| 327 | + | if fs == nil { | |||
| 328 | + | return | |||
| 329 | + | } | |||
| 330 | + | if fs.searchEntry != nil { | |||
| 331 | + | fs.searchEntry.SetText("") | |||
| 332 | + | } | |||
| 333 | + | fs.mu.Lock() | |||
| 334 | + | if !fs.destroyed { | |||
| 335 | + | fs.currentQuery = "" | |||
| 336 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |||
| 337 | + | fs.rebuildDisplayRowsLocked() | |||
| 338 | + | } | |||
| 339 | + | fs.mu.Unlock() | |||
| 340 | + | fs.renderTags() | |||
| 341 | + | fs.rebuildList() | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||
| 345 | + | if fs == nil { | |||
| 346 | + | return | |||
| 347 | + | } | |||
| 348 | + | zones := fs.availableFocusZones() | |||
| 349 | + | if len(zones) == 0 { | |||
| 350 | + | return | |||
| 351 | + | } | |||
| 352 | + | fs.mu.RLock() | |||
| 353 | + | current := fs.focusZone | |||
| 354 | + | fs.mu.RUnlock() | |||
| 355 | + | idx := -1 | |||
| 356 | + | for i, zone := range zones { | |||
| 357 | + | if zone == current { | |||
| 358 | + | idx = i | |||
| 359 | + | break | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | if reverse { | |||
| 363 | + | idx-- | |||
| 364 | + | if idx < 0 { | |||
| 365 | + | idx = len(zones) - 1 | |||
| 366 | + | } | |||
| 367 | + | } else { | |||
| 368 | + | idx++ | |||
| 369 | + | if idx >= len(zones) { | |||
| 370 | + | idx = 0 | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | fs.focusZoneWidget(zones[idx]) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone { | |||
| 377 | + | zones := []favoritesSidebarFocusZone{} | |||
| 378 | + | if fs.searchEntry != nil { | |||
| 379 | + | zones = append(zones, favoritesSidebarFocusSearch) | |||
| 380 | + | } | |||
| 381 | + | if fs.tagBox != nil { | |||
| 382 | + | zones = append(zones, favoritesSidebarFocusTags) | |||
| 383 | + | } | |||
| 384 | + | if fs.listBox != nil { | |||
| 385 | + | zones = append(zones, favoritesSidebarFocusList) | |||
| 386 | + | } | |||
| 387 | + | fs.mu.RLock() | |||
| 388 | + | formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit | |||
| 389 | + | confirmActive := fs.confirmDelete | |||
| 390 | + | fs.mu.RUnlock() | |||
| 391 | + | if formActive && fs.formBox != nil { | |||
| 392 | + | zones = append(zones, favoritesSidebarFocusForm) | |||
| 393 | + | } | |||
| 394 | + | if confirmActive { | |||
| 395 | + | zones = append(zones, favoritesSidebarFocusConfirm) | |||
| 396 | + | } | |||
| 397 | + | return zones | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) { | |||
| 401 | + | fs.mu.Lock() | |||
| 402 | + | if !fs.destroyed { | |||
| 403 | + | fs.focusZone = zone | |||
| 404 | + | } | |||
| 405 | + | fs.mu.Unlock() | |||
| 406 | + | switch zone { | |||
| 407 | + | case favoritesSidebarFocusSearch: | |||
| 408 | + | fs.focusSearch() | |||
| 409 | + | case favoritesSidebarFocusTags: | |||
| 410 | + | if fs.tagBox != nil { | |||
| 411 | + | fs.tagBox.GrabFocus() | |||
| 412 | + | } | |||
| 413 | + | case favoritesSidebarFocusList, favoritesSidebarFocusConfirm: | |||
| 414 | + | if fs.listBox != nil { | |||
| 415 | + | fs.listBox.GrabFocus() | |||
| 416 | + | } | |||
| 417 | + | case favoritesSidebarFocusForm: | |||
| 418 | + | fs.focusForm() | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | ||||
| 234 | func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool { | 422 | func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool { | ||
| 235 | if fs == nil || url == "" { | 423 | if fs == nil || url == "" { | ||
| 236 | return false | 424 | return false | ||
M internal/ui/component/favorites_sidebar_rows.go +18 -6
| @@ -180,21 +180,33 @@ func (fs *FavoritesSidebar) rebuildList() { | |||
| 180 | 180 | ||
| 181 | 181 | listBox.RemoveAll() | |
| 182 | 182 | if len(rows) == 0 { | |
| 183 | - | text := "No favorites" | |
| 184 | - | if notice != "" { | |
| 185 | - | text = notice | |
| 186 | - | } else if query != "" { | |
| 187 | - | text = noResultsText(query) | |
| 183 | + | for _, text := range favoriteSidebarNoticeRows(notice, query, false) { | |
| 184 | + | fs.appendNoticeRow(listBox, text) | |
| 188 | 185 | } | |
| 189 | - | fs.appendNoticeRow(listBox, text) | |
| 190 | 186 | return | |
| 191 | 187 | } | |
| 192 | 188 | for _, row := range rows { | |
| 193 | 189 | fs.appendFavoriteRow(listBox, row.Favorite) | |
| 194 | 190 | } | |
| 191 | + | for _, text := range favoriteSidebarNoticeRows(notice, query, true) { | |
| 192 | + | fs.appendNoticeRow(listBox, text) | |
| 193 | + | } | |
| 195 | 194 | fs.ensureAtLeastOneSelectionInListBox(listBox) | |
| 196 | 195 | } | |
| 197 | 196 | ||
| 197 | + | func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string { | |
| 198 | + | if notice != "" { | |
| 199 | + | return []string{notice} | |
| 200 | + | } | |
| 201 | + | if hasRows { | |
| 202 | + | return nil | |
| 203 | + | } | |
| 204 | + | if query != "" { | |
| 205 | + | return []string{noResultsText(query)} | |
| 206 | + | } | |
| 207 | + | return []string{"No favorites"} | |
| 208 | + | } | |
| 209 | + | ||
| 198 | 210 | func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | |
| 199 | 211 | label := gtk.NewLabel(&text) | |
| 200 | 212 | if label == nil { | |
| @@ -180,21 +180,33 @@ func (fs *FavoritesSidebar) rebuildList() { | |||||
| 180 | 180 | ||||
| 181 | listBox.RemoveAll() | 181 | listBox.RemoveAll() | ||
| 182 | if len(rows) == 0 { | 182 | if len(rows) == 0 { | ||
| 183 | - | text := "No favorites" | 183 | + | for _, text := range favoriteSidebarNoticeRows(notice, query, false) { |
| 184 | - | if notice != "" { | 184 | + | fs.appendNoticeRow(listBox, text) |
| 185 | - | text = notice | |||
| 186 | - | } else if query != "" { | |||
| 187 | - | text = noResultsText(query) | |||
| 188 | } | 185 | } | ||
| 189 | - | fs.appendNoticeRow(listBox, text) | |||
| 190 | return | 186 | return | ||
| 191 | } | 187 | } | ||
| 192 | for _, row := range rows { | 188 | for _, row := range rows { | ||
| 193 | fs.appendFavoriteRow(listBox, row.Favorite) | 189 | fs.appendFavoriteRow(listBox, row.Favorite) | ||
| 194 | } | 190 | } | ||
| 191 | + | for _, text := range favoriteSidebarNoticeRows(notice, query, true) { | |||
| 192 | + | fs.appendNoticeRow(listBox, text) | |||
| 193 | + | } | |||
| 195 | fs.ensureAtLeastOneSelectionInListBox(listBox) | 194 | fs.ensureAtLeastOneSelectionInListBox(listBox) | ||
| 196 | } | 195 | } | ||
| 197 | 196 | ||||
| 197 | + | func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string { | |||
| 198 | + | if notice != "" { | |||
| 199 | + | return []string{notice} | |||
| 200 | + | } | |||
| 201 | + | if hasRows { | |||
| 202 | + | return nil | |||
| 203 | + | } | |||
| 204 | + | if query != "" { | |||
| 205 | + | return []string{noResultsText(query)} | |||
| 206 | + | } | |||
| 207 | + | return []string{"No favorites"} | |||
| 208 | + | } | |||
| 209 | + | ||||
| 198 | func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | 210 | func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | ||
| 199 | label := gtk.NewLabel(&text) | 211 | label := gtk.NewLabel(&text) | ||
| 200 | if label == nil { | 212 | if label == nil { | ||
M internal/ui/component/favorites_sidebar_test.go +222 -11
| @@ -17,6 +17,23 @@ import ( | |||
| 17 | 17 | type fakeFavoritesSidebarUC struct { | |
| 18 | 18 | favorites []*entity.Favorite | |
| 19 | 19 | tags []*entity.Tag | |
| 20 | + | ||
| 21 | + | addInputs []dto.FavoriteCreateInput | |
| 22 | + | updateInputs []dto.FavoriteUpdateInput | |
| 23 | + | deletedIDs []entity.FavoriteID | |
| 24 | + | shortcuts []struct { | |
| 25 | + | id entity.FavoriteID | |
| 26 | + | key *int | |
| 27 | + | } | |
| 28 | + | tagged []struct { | |
| 29 | + | favID entity.FavoriteID | |
| 30 | + | tagID entity.TagID | |
| 31 | + | } | |
| 32 | + | untagged []struct { | |
| 33 | + | favID entity.FavoriteID | |
| 34 | + | tagID entity.TagID | |
| 35 | + | } | |
| 36 | + | err error | |
| 20 | 37 | } | |
| 21 | 38 | ||
| 22 | 39 | func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) { | |
| @@ -25,21 +42,44 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er | |||
| 25 | 42 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | |
| 26 | 43 | return f.tags, nil | |
| 27 | 44 | } | |
| 28 | - | func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 29 | - | return nil, nil | |
| 45 | + | func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 46 | + | f.addInputs = append(f.addInputs, input) | |
| 47 | + | if f.err != nil { | |
| 48 | + | return nil, f.err | |
| 49 | + | } | |
| 50 | + | return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil | |
| 51 | + | } | |
| 52 | + | func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 53 | + | f.updateInputs = append(f.updateInputs, input) | |
| 54 | + | if f.err != nil { | |
| 55 | + | return nil, f.err | |
| 56 | + | } | |
| 57 | + | return &entity.Favorite{ID: input.ID, Title: input.Title}, nil | |
| 30 | 58 | } | |
| 31 | - | func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 32 | - | return nil, nil | |
| 59 | + | func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error { | |
| 60 | + | f.deletedIDs = append(f.deletedIDs, id) | |
| 61 | + | return f.err | |
| 33 | 62 | } | |
| 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 | |
| 63 | + | func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error { | |
| 64 | + | f.shortcuts = append(f.shortcuts, struct { | |
| 65 | + | id entity.FavoriteID | |
| 66 | + | key *int | |
| 67 | + | }{id: id, key: key}) | |
| 68 | + | return f.err | |
| 37 | 69 | } | |
| 38 | - | func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | |
| 39 | - | return nil | |
| 70 | + | func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error { | |
| 71 | + | f.tagged = append(f.tagged, struct { | |
| 72 | + | favID entity.FavoriteID | |
| 73 | + | tagID entity.TagID | |
| 74 | + | }{favID: favID, tagID: tagID}) | |
| 75 | + | return f.err | |
| 40 | 76 | } | |
| 41 | - | func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | |
| 42 | - | return nil | |
| 77 | + | func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error { | |
| 78 | + | f.untagged = append(f.untagged, struct { | |
| 79 | + | favID entity.FavoriteID | |
| 80 | + | tagID entity.TagID | |
| 81 | + | }{favID: favID, tagID: tagID}) | |
| 82 | + | return f.err | |
| 43 | 83 | } | |
| 44 | 84 | ||
| 45 | 85 | func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { | |
| @@ -194,6 +234,8 @@ func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testin | |||
| 194 | 234 | require.Len(t, fs.displayRows, 2) | |
| 195 | 235 | assert.True(t, fs.displayRows[0].Selectable) | |
| 196 | 236 | assert.False(t, fs.displayRows[1].Selectable) | |
| 237 | + | assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true)) | |
| 238 | + | assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false)) | |
| 197 | 239 | fs.tagCallbacks = []interface{}{func() {}, func() {}} | |
| 198 | 240 | fs.tagCallbacks = nil | |
| 199 | 241 | assert.Empty(t, fs.tagCallbacks) | |
| @@ -228,3 +270,172 @@ func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) { | |||
| 228 | 270 | ||
| 229 | 271 | assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called) | |
| 230 | 272 | } | |
| 273 | + | ||
| 274 | + | func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) { | |
| 275 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 276 | + | fs.searchEntry = nil | |
| 277 | + | fs.tagBox = nil | |
| 278 | + | fs.listBox = nil | |
| 279 | + | assert.Empty(t, fs.availableFocusZones()) | |
| 280 | + | ||
| 281 | + | fs.formBox = nil | |
| 282 | + | fs.mode = favoritesSidebarModeAdd | |
| 283 | + | fs.confirmDelete = true | |
| 284 | + | assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones()) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestFavoritesSidebarSingleKeyCommandsAreGatedInTextEditContext(t *testing.T) { | |
| 288 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 289 | + | fs.searchEntry = nil | |
| 290 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}} | |
| 291 | + | ||
| 292 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a))) | |
| 293 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| 294 | + | ||
| 295 | + | fs.mode = favoritesSidebarModeNone | |
| 296 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e))) | |
| 297 | + | assert.Equal(t, favoritesSidebarModeEdit, fs.mode) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |
| 301 | + | uc := &fakeFavoritesSidebarUC{} | |
| 302 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 303 | + | fs.favoritesUC = uc | |
| 304 | + | fs.formURL = "https://add.test" | |
| 305 | + | fs.formTitle = "Added" | |
| 306 | + | fs.formTags = "1, 2" | |
| 307 | + | fs.formShortcut = "3" | |
| 308 | + | fs.mode = favoritesSidebarModeAdd | |
| 309 | + | ||
| 310 | + | assert.True(t, fs.submitForm()) | |
| 311 | + | require.Len(t, uc.addInputs, 1) | |
| 312 | + | assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0]) | |
| 313 | + | require.Len(t, uc.shortcuts, 1) | |
| 314 | + | assert.Equal(t, 3, *uc.shortcuts[0].key) | |
| 315 | + | ||
| 316 | + | fs.mode = favoritesSidebarModeEdit | |
| 317 | + | fs.editingID = 7 | |
| 318 | + | fs.formTitle = "Edited" | |
| 319 | + | fs.formTags = "999" | |
| 320 | + | fs.formShortcut = "4" | |
| 321 | + | assert.True(t, fs.submitForm()) | |
| 322 | + | require.Len(t, uc.updateInputs, 1) | |
| 323 | + | assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID) | |
| 324 | + | assert.Equal(t, "Edited", uc.updateInputs[0].Title) | |
| 325 | + | require.NotNil(t, uc.updateInputs[0].ShortcutKey) | |
| 326 | + | assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | |
| 330 | + | uc := &fakeFavoritesSidebarUC{} | |
| 331 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 332 | + | fs.favoritesUC = uc | |
| 333 | + | fs.formURL = "https://add.test" | |
| 334 | + | fs.formTitle = "Added" | |
| 335 | + | fs.formTags = "1, nope" | |
| 336 | + | fs.mode = favoritesSidebarModeAdd | |
| 337 | + | ||
| 338 | + | assert.True(t, fs.submitForm()) | |
| 339 | + | assert.Empty(t, uc.addInputs) | |
| 340 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| 341 | + | assert.Contains(t, fs.notice, "invalid tag ID") | |
| 342 | + | assert.Equal(t, "1, nope", fs.formTags) | |
| 343 | + | ||
| 344 | + | fs.formTags = "1" | |
| 345 | + | fs.formShortcut = "10" | |
| 346 | + | assert.True(t, fs.submitForm()) | |
| 347 | + | assert.Empty(t, uc.addInputs) | |
| 348 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| 349 | + | assert.Contains(t, fs.notice, "invalid shortcut") | |
| 350 | + | assert.Equal(t, "10", fs.formShortcut) | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { | |
| 354 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} | |
| 355 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) | |
| 356 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 357 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | |
| 358 | + | fs.confirmDelete = true | |
| 359 | + | ||
| 360 | + | assert.True(t, fs.confirmDeleteActive()) | |
| 361 | + | assert.True(t, fs.confirmDeleteFavorite()) | |
| 362 | + | assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) { | |
| 366 | + | uc := &fakeFavoritesSidebarUC{} | |
| 367 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 368 | + | fs.favoritesUC = uc | |
| 369 | + | fs.formURL = "https://add.test" | |
| 370 | + | fs.formTitle = "Added" | |
| 371 | + | fs.mode = favoritesSidebarModeAdd | |
| 372 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}} | |
| 373 | + | fs.onNavigate = func(context.Context, string) error { | |
| 374 | + | t.Fatal("plain Enter in form mode must not activate selected row") | |
| 375 | + | return nil | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | assert.False(t, fs.handleReturnKey(0)) | |
| 379 | + | assert.Empty(t, uc.addInputs) | |
| 380 | + | fs.mode = favoritesSidebarModeAdd | |
| 381 | + | assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue)) | |
| 382 | + | require.Len(t, uc.addInputs, 1) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |
| 386 | + | uc := &fakeFavoritesSidebarUC{} | |
| 387 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 388 | + | fs.favoritesUC = uc | |
| 389 | + | fs.mode = favoritesSidebarModeEdit | |
| 390 | + | fs.editingID = 7 | |
| 391 | + | fs.formTitle = "Edited" | |
| 392 | + | fs.formTags = "999" | |
| 393 | + | ||
| 394 | + | assert.True(t, fs.submitForm()) | |
| 395 | + | require.Len(t, uc.updateInputs, 1) | |
| 396 | + | assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID) | |
| 397 | + | assert.Empty(t, uc.tagged) | |
| 398 | + | assert.Empty(t, uc.untagged) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) { | |
| 402 | + | dev := entity.Tag{ID: 10, Name: "dev"} | |
| 403 | + | news := entity.Tag{ID: 11, Name: "news"} | |
| 404 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |
| 405 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |
| 406 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 407 | + | fs.allTags = uc.tags | |
| 408 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | |
| 409 | + | ||
| 410 | + | fs.mode = favoritesSidebarModeTag | |
| 411 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1))) | |
| 412 | + | require.Len(t, uc.untagged, 1) | |
| 413 | + | assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID) | |
| 414 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2))) | |
| 415 | + | require.Len(t, uc.tagged, 1) | |
| 416 | + | assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID) | |
| 417 | + | ||
| 418 | + | fs.mode = favoritesSidebarModeShortcut | |
| 419 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9))) | |
| 420 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace))) | |
| 421 | + | require.GreaterOrEqual(t, len(uc.shortcuts), 2) | |
| 422 | + | assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key) | |
| 423 | + | assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key) | |
| 424 | + | ||
| 425 | + | fs.mode = favoritesSidebarModeNone | |
| 426 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete))) | |
| 427 | + | assert.True(t, fs.confirmDelete) | |
| 428 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete))) | |
| 429 | + | assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs) | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) { | |
| 433 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 434 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 435 | + | uc.err = errors.New("add failed") | |
| 436 | + | fs.mode = favoritesSidebarModeAdd | |
| 437 | + | ||
| 438 | + | assert.True(t, fs.submitForm()) | |
| 439 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| 440 | + | assert.Equal(t, "add failed", fs.notice) | |
| 441 | + | } | |
| @@ -17,6 +17,23 @@ import ( | |||||
| 17 | type fakeFavoritesSidebarUC struct { | 17 | type fakeFavoritesSidebarUC struct { | ||
| 18 | favorites []*entity.Favorite | 18 | favorites []*entity.Favorite | ||
| 19 | tags []*entity.Tag | 19 | tags []*entity.Tag | ||
| 20 | + | ||||
| 21 | + | addInputs []dto.FavoriteCreateInput | |||
| 22 | + | updateInputs []dto.FavoriteUpdateInput | |||
| 23 | + | deletedIDs []entity.FavoriteID | |||
| 24 | + | shortcuts []struct { | |||
| 25 | + | id entity.FavoriteID | |||
| 26 | + | key *int | |||
| 27 | + | } | |||
| 28 | + | tagged []struct { | |||
| 29 | + | favID entity.FavoriteID | |||
| 30 | + | tagID entity.TagID | |||
| 31 | + | } | |||
| 32 | + | untagged []struct { | |||
| 33 | + | favID entity.FavoriteID | |||
| 34 | + | tagID entity.TagID | |||
| 35 | + | } | |||
| 36 | + | err error | |||
| 20 | } | 37 | } | ||
| 21 | 38 | ||||
| 22 | func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) { | 39 | func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, error) { | ||
| @@ -25,21 +42,44 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er | |||||
| 25 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | 42 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | ||
| 26 | return f.tags, nil | 43 | return f.tags, nil | ||
| 27 | } | 44 | } | ||
| 28 | - | func (f *fakeFavoritesSidebarUC) AddFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) { | 45 | + | func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { |
| 29 | - | return nil, nil | 46 | + | f.addInputs = append(f.addInputs, input) |
| 47 | + | if f.err != nil { | |||
| 48 | + | return nil, f.err | |||
| 49 | + | } | |||
| 50 | + | return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil | |||
| 51 | + | } | |||
| 52 | + | func (f *fakeFavoritesSidebarUC) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 53 | + | f.updateInputs = append(f.updateInputs, input) | |||
| 54 | + | if f.err != nil { | |||
| 55 | + | return nil, f.err | |||
| 56 | + | } | |||
| 57 | + | return &entity.Favorite{ID: input.ID, Title: input.Title}, nil | |||
| 30 | } | 58 | } | ||
| 31 | - | func (f *fakeFavoritesSidebarUC) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) { | 59 | + | func (f *fakeFavoritesSidebarUC) DeleteFavorite(_ context.Context, id entity.FavoriteID) error { |
| 32 | - | return nil, nil | 60 | + | f.deletedIDs = append(f.deletedIDs, id) |
| 61 | + | return f.err | |||
| 33 | } | 62 | } | ||
| 34 | - | func (f *fakeFavoritesSidebarUC) DeleteFavorite(context.Context, entity.FavoriteID) error { return nil } | 63 | + | func (f *fakeFavoritesSidebarUC) SetShortcut(_ context.Context, id entity.FavoriteID, key *int) error { |
| 35 | - | func (f *fakeFavoritesSidebarUC) SetShortcut(context.Context, entity.FavoriteID, *int) error { | 64 | + | f.shortcuts = append(f.shortcuts, struct { |
| 36 | - | return nil | 65 | + | id entity.FavoriteID |
| 66 | + | key *int | |||
| 67 | + | }{id: id, key: key}) | |||
| 68 | + | return f.err | |||
| 37 | } | 69 | } | ||
| 38 | - | func (f *fakeFavoritesSidebarUC) TagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | 70 | + | func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error { |
| 39 | - | return nil | 71 | + | f.tagged = append(f.tagged, struct { |
| 72 | + | favID entity.FavoriteID | |||
| 73 | + | tagID entity.TagID | |||
| 74 | + | }{favID: favID, tagID: tagID}) | |||
| 75 | + | return f.err | |||
| 40 | } | 76 | } | ||
| 41 | - | func (f *fakeFavoritesSidebarUC) UntagFavorite(context.Context, entity.FavoriteID, entity.TagID) error { | 77 | + | func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error { |
| 42 | - | return nil | 78 | + | f.untagged = append(f.untagged, struct { |
| 79 | + | favID entity.FavoriteID | |||
| 80 | + | tagID entity.TagID | |||
| 81 | + | }{favID: favID, tagID: tagID}) | |||
| 82 | + | return f.err | |||
| 43 | } | 83 | } | ||
| 44 | 84 | ||||
| 45 | func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { | 85 | func newFavoritesSidebarHarness(favorites []*entity.Favorite, tags []*entity.Tag) *FavoritesSidebar { | ||
| @@ -194,6 +234,8 @@ func TestFavoritesSidebarRenderingModelTagCallbackRetentionAndListRows(t *testin | |||||
| 194 | require.Len(t, fs.displayRows, 2) | 234 | require.Len(t, fs.displayRows, 2) | ||
| 195 | assert.True(t, fs.displayRows[0].Selectable) | 235 | assert.True(t, fs.displayRows[0].Selectable) | ||
| 196 | assert.False(t, fs.displayRows[1].Selectable) | 236 | assert.False(t, fs.displayRows[1].Selectable) | ||
| 237 | + | assert.Equal(t, []string{"saved"}, favoriteSidebarNoticeRows("saved", "", true)) | |||
| 238 | + | assert.Equal(t, []string{"No favorites"}, favoriteSidebarNoticeRows("", "", false)) | |||
| 197 | fs.tagCallbacks = []interface{}{func() {}, func() {}} | 239 | fs.tagCallbacks = []interface{}{func() {}, func() {}} | ||
| 198 | fs.tagCallbacks = nil | 240 | fs.tagCallbacks = nil | ||
| 199 | assert.Empty(t, fs.tagCallbacks) | 241 | assert.Empty(t, fs.tagCallbacks) | ||
| @@ -228,3 +270,172 @@ func TestFavoritesSidebarHandleEnterKeyDispatchesModifiers(t *testing.T) { | |||||
| 228 | 270 | ||||
| 229 | assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called) | 271 | assert.Equal(t, []string{"nav:https://go.dev", "keep:https://go.dev", "pane:https://go.dev"}, called) | ||
| 230 | } | 272 | } | ||
| 273 | + | ||||
| 274 | + | func TestFavoritesSidebarFocusZoneCyclingSkipsAbsentZones(t *testing.T) { | |||
| 275 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 276 | + | fs.searchEntry = nil | |||
| 277 | + | fs.tagBox = nil | |||
| 278 | + | fs.listBox = nil | |||
| 279 | + | assert.Empty(t, fs.availableFocusZones()) | |||
| 280 | + | ||||
| 281 | + | fs.formBox = nil | |||
| 282 | + | fs.mode = favoritesSidebarModeAdd | |||
| 283 | + | fs.confirmDelete = true | |||
| 284 | + | assert.Equal(t, []favoritesSidebarFocusZone{favoritesSidebarFocusConfirm}, fs.availableFocusZones()) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestFavoritesSidebarSingleKeyCommandsAreGatedInTextEditContext(t *testing.T) { | |||
| 288 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 289 | + | fs.searchEntry = nil | |||
| 290 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}} | |||
| 291 | + | ||||
| 292 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_a))) | |||
| 293 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |||
| 294 | + | ||||
| 295 | + | fs.mode = favoritesSidebarModeNone | |||
| 296 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e))) | |||
| 297 | + | assert.Equal(t, favoritesSidebarModeEdit, fs.mode) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||
| 301 | + | uc := &fakeFavoritesSidebarUC{} | |||
| 302 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 303 | + | fs.favoritesUC = uc | |||
| 304 | + | fs.formURL = "https://add.test" | |||
| 305 | + | fs.formTitle = "Added" | |||
| 306 | + | fs.formTags = "1, 2" | |||
| 307 | + | fs.formShortcut = "3" | |||
| 308 | + | fs.mode = favoritesSidebarModeAdd | |||
| 309 | + | ||||
| 310 | + | assert.True(t, fs.submitForm()) | |||
| 311 | + | require.Len(t, uc.addInputs, 1) | |||
| 312 | + | assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0]) | |||
| 313 | + | require.Len(t, uc.shortcuts, 1) | |||
| 314 | + | assert.Equal(t, 3, *uc.shortcuts[0].key) | |||
| 315 | + | ||||
| 316 | + | fs.mode = favoritesSidebarModeEdit | |||
| 317 | + | fs.editingID = 7 | |||
| 318 | + | fs.formTitle = "Edited" | |||
| 319 | + | fs.formTags = "999" | |||
| 320 | + | fs.formShortcut = "4" | |||
| 321 | + | assert.True(t, fs.submitForm()) | |||
| 322 | + | require.Len(t, uc.updateInputs, 1) | |||
| 323 | + | assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID) | |||
| 324 | + | assert.Equal(t, "Edited", uc.updateInputs[0].Title) | |||
| 325 | + | require.NotNil(t, uc.updateInputs[0].ShortcutKey) | |||
| 326 | + | assert.Equal(t, 4, *uc.updateInputs[0].ShortcutKey) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | |||
| 330 | + | uc := &fakeFavoritesSidebarUC{} | |||
| 331 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 332 | + | fs.favoritesUC = uc | |||
| 333 | + | fs.formURL = "https://add.test" | |||
| 334 | + | fs.formTitle = "Added" | |||
| 335 | + | fs.formTags = "1, nope" | |||
| 336 | + | fs.mode = favoritesSidebarModeAdd | |||
| 337 | + | ||||
| 338 | + | assert.True(t, fs.submitForm()) | |||
| 339 | + | assert.Empty(t, uc.addInputs) | |||
| 340 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |||
| 341 | + | assert.Contains(t, fs.notice, "invalid tag ID") | |||
| 342 | + | assert.Equal(t, "1, nope", fs.formTags) | |||
| 343 | + | ||||
| 344 | + | fs.formTags = "1" | |||
| 345 | + | fs.formShortcut = "10" | |||
| 346 | + | assert.True(t, fs.submitForm()) | |||
| 347 | + | assert.Empty(t, uc.addInputs) | |||
| 348 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |||
| 349 | + | assert.Contains(t, fs.notice, "invalid shortcut") | |||
| 350 | + | assert.Equal(t, "10", fs.formShortcut) | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { | |||
| 354 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} | |||
| 355 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) | |||
| 356 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 357 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | |||
| 358 | + | fs.confirmDelete = true | |||
| 359 | + | ||||
| 360 | + | assert.True(t, fs.confirmDeleteActive()) | |||
| 361 | + | assert.True(t, fs.confirmDeleteFavorite()) | |||
| 362 | + | assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs) | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func TestFavoritesSidebarCtrlEnterSubmitsFormPlainEnterDoesNot(t *testing.T) { | |||
| 366 | + | uc := &fakeFavoritesSidebarUC{} | |||
| 367 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 368 | + | fs.favoritesUC = uc | |||
| 369 | + | fs.formURL = "https://add.test" | |||
| 370 | + | fs.formTitle = "Added" | |||
| 371 | + | fs.mode = favoritesSidebarModeAdd | |||
| 372 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, URL: "https://existing.test", Selectable: true}} | |||
| 373 | + | fs.onNavigate = func(context.Context, string) error { | |||
| 374 | + | t.Fatal("plain Enter in form mode must not activate selected row") | |||
| 375 | + | return nil | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | assert.False(t, fs.handleReturnKey(0)) | |||
| 379 | + | assert.Empty(t, uc.addInputs) | |||
| 380 | + | fs.mode = favoritesSidebarModeAdd | |||
| 381 | + | assert.True(t, fs.handleReturnKey(gdk.ControlMaskValue)) | |||
| 382 | + | require.Len(t, uc.addInputs, 1) | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||
| 386 | + | uc := &fakeFavoritesSidebarUC{} | |||
| 387 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 388 | + | fs.favoritesUC = uc | |||
| 389 | + | fs.mode = favoritesSidebarModeEdit | |||
| 390 | + | fs.editingID = 7 | |||
| 391 | + | fs.formTitle = "Edited" | |||
| 392 | + | fs.formTags = "999" | |||
| 393 | + | ||||
| 394 | + | assert.True(t, fs.submitForm()) | |||
| 395 | + | require.Len(t, uc.updateInputs, 1) | |||
| 396 | + | assert.Equal(t, entity.FavoriteID(7), uc.updateInputs[0].ID) | |||
| 397 | + | assert.Empty(t, uc.tagged) | |||
| 398 | + | assert.Empty(t, uc.untagged) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) { | |||
| 402 | + | dev := entity.Tag{ID: 10, Name: "dev"} | |||
| 403 | + | news := entity.Tag{ID: 11, Name: "news"} | |||
| 404 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |||
| 405 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |||
| 406 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 407 | + | fs.allTags = uc.tags | |||
| 408 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | |||
| 409 | + | ||||
| 410 | + | fs.mode = favoritesSidebarModeTag | |||
| 411 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1))) | |||
| 412 | + | require.Len(t, uc.untagged, 1) | |||
| 413 | + | assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID) | |||
| 414 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2))) | |||
| 415 | + | require.Len(t, uc.tagged, 1) | |||
| 416 | + | assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID) | |||
| 417 | + | ||||
| 418 | + | fs.mode = favoritesSidebarModeShortcut | |||
| 419 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9))) | |||
| 420 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace))) | |||
| 421 | + | require.GreaterOrEqual(t, len(uc.shortcuts), 2) | |||
| 422 | + | assert.Equal(t, 9, *uc.shortcuts[len(uc.shortcuts)-2].key) | |||
| 423 | + | assert.Nil(t, uc.shortcuts[len(uc.shortcuts)-1].key) | |||
| 424 | + | ||||
| 425 | + | fs.mode = favoritesSidebarModeNone | |||
| 426 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete))) | |||
| 427 | + | assert.True(t, fs.confirmDelete) | |||
| 428 | + | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_Delete))) | |||
| 429 | + | assert.Equal(t, []entity.FavoriteID{5}, uc.deletedIDs) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestFavoritesSidebarErrorsPreserveStateAndShowNotice(t *testing.T) { | |||
| 433 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 434 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 435 | + | uc.err = errors.New("add failed") | |||
| 436 | + | fs.mode = favoritesSidebarModeAdd | |||
| 437 | + | ||||
| 438 | + | assert.True(t, fs.submitForm()) | |||
| 439 | + | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |||
| 440 | + | assert.Equal(t, "add failed", fs.notice) | |||
| 441 | + | } | |||