refactor(favorites): unify native sidebar interactions
- commit
- 965ed59378b8a635a30f9a2fc25f96714830fc69
- author
- brice · · 2026-08-12 05:45 UTC
- committer
- brice · · 2026-08-12 05:52 UTC
- parent
- 14bdae6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +1227 -351
M docs/reference/keybindings.md +1 -1
| @@ -117,7 +117,7 @@ These work outside modal modes: | |||
| 117 | 117 | - `Alt+F` is the only floating-pane shortcut enabled by default. | |
| 118 | 118 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | |
| 119 | 119 | - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`. | |
| 120 | - | - `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` cycle sidebar zones; `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `t` opens tag mode, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters. | |
| 120 | + | - `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` traverse search, every tag filter, and the favorites list; arrow keys navigate favorites once the list is focused. The `+` beside `All` creates a named tag. With a favorite selected in the list, `+` opens its tag binding controls. `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters. | |
| 121 | 121 | - `Ctrl+D` toggles the active page as a favorite/bookmark. Favorite shortcut metadata can be assigned in the sidebar, but global `Alt+1..9` remains tab switching. | |
| 122 | 122 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | |
| 123 | 123 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | |
| @@ -117,7 +117,7 @@ These work outside modal modes: | |||||
| 117 | - `Alt+F` is the only floating-pane shortcut enabled by default. | 117 | - `Alt+F` is the only floating-pane shortcut enabled by default. | ||
| 118 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | 118 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | ||
| 119 | - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`. | 119 | - `Ctrl+H` toggles the native GTK history sidebar. The sidebar shows browsing history grouped by day with search/filter, keyboard navigation (arrows, Home/End, Ctrl+arrows for day jumps), and activation modes (Enter to navigate while keeping the sidebar open, Ctrl+Enter to navigate while keeping the sidebar open, Shift+Enter to open in a new split). If the native sidebar is unavailable, the shortcut returns an error instead of falling back to `dumb://history`. | ||
| 120 | - | - `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` cycle sidebar zones; `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `t` opens tag mode, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters. | 120 | + | - `Ctrl+B` toggles the native GTK Favorites sidebar. Search matches favorite titles, URLs, and tag names. `Tab`/`Shift+Tab` traverse search, every tag filter, and the favorites list; arrow keys navigate favorites once the list is focused. The `+` beside `All` creates a named tag. With a favorite selected in the list, `+` opens its tag binding controls. `Enter` and `Ctrl+Enter` open the selected favorite in the current pane while keeping the sidebar open; `Shift+Enter` opens it in a new split. Inside the sidebar, `a` adds, `e` edits, `s` opens shortcut mode, `Delete` starts delete confirmation, `/` focuses search, `Esc` clears/cancels/closes, `r` reloads, and `c` clears search and filters. |
| 121 | - `Ctrl+D` toggles the active page as a favorite/bookmark. Favorite shortcut metadata can be assigned in the sidebar, but global `Alt+1..9` remains tab switching. | 121 | - `Ctrl+D` toggles the active page as a favorite/bookmark. Favorite shortcut metadata can be assigned in the sidebar, but global `Alt+1..9` remains tab switching. | ||
| 122 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | 122 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | ||
| 123 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | 123 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | ||
M internal/application/port/favorites_sidebar.go +1 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | type FavoritesSidebarFavorites interface { | |
| 12 | 12 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | |
| 13 | 13 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | |
| 14 | + | AddTag(ctx context.Context, name, color string) (*entity.Tag, error) | |
| 14 | 15 | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | |
| 15 | 16 | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | |
| 16 | 17 | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | type FavoritesSidebarFavorites interface { | 11 | type FavoritesSidebarFavorites interface { | ||
| 12 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | 12 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | ||
| 13 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | 13 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | ||
| 14 | + | AddTag(ctx context.Context, name, color string) (*entity.Tag, error) | |||
| 14 | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | 15 | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | ||
| 15 | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | 16 | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | ||
| 16 | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | 17 | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | ||
M internal/ui/component/favorites_sidebar.go +140 -35
| @@ -13,8 +13,6 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | const favoritesSidebarOuterSpacing = 6 | |
| 17 | - | ||
| 18 | 16 | // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar. | |
| 19 | 17 | type FavoritesSidebarConfig struct { | |
| 20 | 18 | FavoritesUC port.FavoritesSidebarFavorites | |
| @@ -26,22 +24,27 @@ type FavoritesSidebarConfig struct { | |||
| 26 | 24 | ||
| 27 | 25 | // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites. | |
| 28 | 26 | type FavoritesSidebar struct { | |
| 29 | - | outerBox *gtk.Box | |
| 30 | - | searchEntry *gtk.SearchEntry | |
| 31 | - | tagBox *gtk.Box | |
| 32 | - | scrolledWin *gtk.ScrolledWindow | |
| 33 | - | listBox *gtk.ListBox | |
| 34 | - | formBox *gtk.Box | |
| 27 | + | outerBox *gtk.Box | |
| 28 | + | searchEntry *gtk.SearchEntry | |
| 29 | + | tagBox *gtk.Box | |
| 30 | + | tagControls []*gtk.Button | |
| 31 | + | tagPromptBox *gtk.Box | |
| 32 | + | tagNameEntry *gtk.Entry | |
| 33 | + | tagPromptSaveBtn *gtk.Button | |
| 34 | + | scrolledWin *gtk.ScrolledWindow | |
| 35 | + | listBox *gtk.ListBox | |
| 36 | + | formBox *gtk.Box | |
| 35 | 37 | ||
| 36 | 38 | formURLEntry *gtk.SearchEntry | |
| 37 | 39 | formTitleEntry *gtk.SearchEntry | |
| 38 | - | formTagsEntry *gtk.SearchEntry | |
| 40 | + | formTagSearch *gtk.SearchEntry | |
| 41 | + | formTagMatches *gtk.Box | |
| 39 | 42 | formShortcutEntry *gtk.SearchEntry | |
| 40 | 43 | formSaveButton *gtk.Button | |
| 41 | 44 | formURL string | |
| 42 | 45 | formTitle string | |
| 43 | - | formTags string | |
| 44 | 46 | formShortcut string | |
| 47 | + | formTagIDs map[entity.TagID]struct{} | |
| 45 | 48 | ||
| 46 | 49 | favoritesUC port.FavoritesSidebarFavorites | |
| 47 | 50 | onNavigate func(ctx context.Context, url string) error | |
| @@ -60,6 +63,7 @@ type FavoritesSidebar struct { | |||
| 60 | 63 | destroyed bool | |
| 61 | 64 | visible bool | |
| 62 | 65 | focusZone favoritesSidebarFocusZone | |
| 66 | + | textInputWidget *gtk.Widget | |
| 63 | 67 | mode favoritesSidebarMode | |
| 64 | 68 | editingID entity.FavoriteID | |
| 65 | 69 | confirmDelete bool | |
| @@ -157,33 +161,12 @@ func (fs *FavoritesSidebar) Destroy() { | |||
| 157 | 161 | } | |
| 158 | 162 | ||
| 159 | 163 | func (fs *FavoritesSidebar) createWidgets() error { | |
| 160 | - | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing) | |
| 161 | - | if fs.outerBox == nil { | |
| 162 | - | return fmt.Errorf("favorites sidebar: outer box creation failed") | |
| 164 | + | if err := fs.createOuterAndSearch(); err != nil { | |
| 165 | + | return err | |
| 163 | 166 | } | |
| 164 | - | fs.outerBox.AddCssClass("favorites-sidebar-outer") | |
| 165 | - | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |
| 166 | - | fs.outerBox.SetHexpand(false) | |
| 167 | - | fs.outerBox.SetVexpand(true) | |
| 168 | - | fs.outerBox.SetVisible(false) | |
| 169 | - | ||
| 170 | - | fs.searchEntry = gtk.NewSearchEntry() | |
| 171 | - | if fs.searchEntry == nil { | |
| 172 | - | return fmt.Errorf("favorites sidebar: search entry creation failed") | |
| 167 | + | if err := fs.createTagControls(); err != nil { | |
| 168 | + | return err | |
| 173 | 169 | } | |
| 174 | - | fs.searchEntry.AddCssClass("favorites-sidebar-search") | |
| 175 | - | fs.searchEntry.SetHexpand(true) | |
| 176 | - | placeholder := "Search favorites" | |
| 177 | - | fs.searchEntry.SetPlaceholderText(&placeholder) | |
| 178 | - | fs.outerBox.Append(&fs.searchEntry.Widget) | |
| 179 | - | ||
| 180 | - | fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 181 | - | if fs.tagBox == nil { | |
| 182 | - | return fmt.Errorf("favorites sidebar: tag box creation failed") | |
| 183 | - | } | |
| 184 | - | fs.tagBox.AddCssClass("favorites-sidebar-tags") | |
| 185 | - | fs.tagBox.SetHexpand(true) | |
| 186 | - | fs.outerBox.Append(&fs.tagBox.Widget) | |
| 187 | 170 | ||
| 188 | 171 | fs.scrolledWin = gtk.NewScrolledWindow() | |
| 189 | 172 | if fs.scrolledWin == nil { | |
| @@ -192,12 +175,15 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||
| 192 | 175 | fs.scrolledWin.SetVexpand(true) | |
| 193 | 176 | fs.scrolledWin.SetHexpand(true) | |
| 194 | 177 | fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 178 | + | fs.scrolledWin.AddCssClass("sidebar-list") | |
| 195 | 179 | ||
| 196 | 180 | fs.listBox = gtk.NewListBox() | |
| 197 | 181 | if fs.listBox == nil { | |
| 198 | 182 | return fmt.Errorf("favorites sidebar: list box creation failed") | |
| 199 | 183 | } | |
| 184 | + | fs.listBox.AddCssClass("sidebar-list") | |
| 200 | 185 | fs.listBox.AddCssClass("favorites-sidebar-list") | |
| 186 | + | fs.trackListFocus(&fs.listBox.Widget) | |
| 201 | 187 | fs.listBox.SetActivateOnSingleClick(true) | |
| 202 | 188 | fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 203 | 189 | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| @@ -220,6 +206,125 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||
| 220 | 206 | return nil | |
| 221 | 207 | } | |
| 222 | 208 | ||
| 209 | + | func (fs *FavoritesSidebar) createOuterAndSearch() error { | |
| 210 | + | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 211 | + | if fs.outerBox == nil { | |
| 212 | + | return fmt.Errorf("favorites sidebar: outer box creation failed") | |
| 213 | + | } | |
| 214 | + | fs.outerBox.AddCssClass("sidebar-outer") | |
| 215 | + | fs.outerBox.AddCssClass("favorites-sidebar-outer") | |
| 216 | + | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |
| 217 | + | fs.outerBox.SetHexpand(false) | |
| 218 | + | fs.outerBox.SetVexpand(true) | |
| 219 | + | fs.outerBox.SetVisible(false) | |
| 220 | + | ||
| 221 | + | searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 222 | + | if searchBox == nil { | |
| 223 | + | return fmt.Errorf("favorites sidebar: search box creation failed") | |
| 224 | + | } | |
| 225 | + | searchBox.AddCssClass("sidebar-search-box") | |
| 226 | + | searchBox.SetHexpand(true) | |
| 227 | + | fs.searchEntry = gtk.NewSearchEntry() | |
| 228 | + | if fs.searchEntry == nil { | |
| 229 | + | return fmt.Errorf("favorites sidebar: search entry creation failed") | |
| 230 | + | } | |
| 231 | + | fs.searchEntry.AddCssClass("sidebar-search") | |
| 232 | + | fs.searchEntry.AddCssClass("favorites-sidebar-search") | |
| 233 | + | fs.trackTextInputFocus(&fs.searchEntry.Widget) | |
| 234 | + | fs.searchEntry.SetHexpand(true) | |
| 235 | + | placeholder := "Search favorites..." | |
| 236 | + | fs.searchEntry.SetPlaceholderText(&placeholder) | |
| 237 | + | searchBox.Append(&fs.searchEntry.Widget) | |
| 238 | + | fs.outerBox.Append(&searchBox.Widget) | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func (fs *FavoritesSidebar) createTagControls() error { | |
| 243 | + | fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 244 | + | if fs.tagBox == nil { | |
| 245 | + | return fmt.Errorf("favorites sidebar: tag box creation failed") | |
| 246 | + | } | |
| 247 | + | fs.tagBox.AddCssClass("favorites-sidebar-tags") | |
| 248 | + | fs.tagBox.SetHexpand(true) | |
| 249 | + | fs.outerBox.Append(&fs.tagBox.Widget) | |
| 250 | + | fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 251 | + | if fs.tagPromptBox == nil { | |
| 252 | + | return fmt.Errorf("favorites sidebar: tag prompt creation failed") | |
| 253 | + | } | |
| 254 | + | fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt") | |
| 255 | + | fs.tagPromptBox.SetVisible(false) | |
| 256 | + | fs.outerBox.Append(&fs.tagPromptBox.Widget) | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget) { | |
| 261 | + | if fs == nil || widget == nil { | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | controller := gtk.NewEventControllerFocus() | |
| 265 | + | if controller == nil { | |
| 266 | + | return | |
| 267 | + | } | |
| 268 | + | entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) } | |
| 269 | + | left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) } | |
| 270 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, entered, left) | |
| 271 | + | controller.ConnectEnter(&entered) | |
| 272 | + | controller.ConnectLeave(&left) | |
| 273 | + | widget.AddController(&controller.EventController) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) { | |
| 277 | + | if fs == nil || widget == nil { | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | fs.mu.Lock() | |
| 281 | + | if !fs.destroyed { | |
| 282 | + | fs.textInputWidget = widget | |
| 283 | + | if fs.searchEntry != nil && widget == &fs.searchEntry.Widget { | |
| 284 | + | fs.focusZone = favoritesSidebarFocusSearch | |
| 285 | + | } else { | |
| 286 | + | fs.focusZone = favoritesSidebarFocusForm | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | fs.mu.Unlock() | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) { | |
| 293 | + | if fs == nil || widget == nil { | |
| 294 | + | return | |
| 295 | + | } | |
| 296 | + | controller := gtk.NewEventControllerFocus() | |
| 297 | + | if controller == nil { | |
| 298 | + | return | |
| 299 | + | } | |
| 300 | + | entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) } | |
| 301 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, entered) | |
| 302 | + | controller.ConnectEnter(&entered) | |
| 303 | + | widget.AddController(&controller.EventController) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) { | |
| 307 | + | if fs == nil { | |
| 308 | + | return | |
| 309 | + | } | |
| 310 | + | fs.mu.Lock() | |
| 311 | + | if !fs.destroyed { | |
| 312 | + | fs.focusZone = zone | |
| 313 | + | } | |
| 314 | + | fs.mu.Unlock() | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) { | |
| 318 | + | if fs == nil || widget == nil { | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | fs.mu.Lock() | |
| 322 | + | if !fs.destroyed && fs.textInputWidget == widget { | |
| 323 | + | fs.textInputWidget = nil | |
| 324 | + | } | |
| 325 | + | fs.mu.Unlock() | |
| 326 | + | } | |
| 327 | + | ||
| 223 | 328 | func (fs *FavoritesSidebar) focusSearch() { | |
| 224 | 329 | if fs == nil { | |
| 225 | 330 | return | |
| @@ -13,8 +13,6 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | - | const favoritesSidebarOuterSpacing = 6 | |||
| 17 | - | ||||
| 18 | // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar. | 16 | // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar. | ||
| 19 | type FavoritesSidebarConfig struct { | 17 | type FavoritesSidebarConfig struct { | ||
| 20 | FavoritesUC port.FavoritesSidebarFavorites | 18 | FavoritesUC port.FavoritesSidebarFavorites | ||
| @@ -26,22 +24,27 @@ type FavoritesSidebarConfig struct { | |||||
| 26 | 24 | ||||
| 27 | // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites. | 25 | // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites. | ||
| 28 | type FavoritesSidebar struct { | 26 | type FavoritesSidebar struct { | ||
| 29 | - | outerBox *gtk.Box | 27 | + | outerBox *gtk.Box |
| 30 | - | searchEntry *gtk.SearchEntry | 28 | + | searchEntry *gtk.SearchEntry |
| 31 | - | tagBox *gtk.Box | 29 | + | tagBox *gtk.Box |
| 32 | - | scrolledWin *gtk.ScrolledWindow | 30 | + | tagControls []*gtk.Button |
| 33 | - | listBox *gtk.ListBox | 31 | + | tagPromptBox *gtk.Box |
| 34 | - | formBox *gtk.Box | 32 | + | tagNameEntry *gtk.Entry |
| 33 | + | tagPromptSaveBtn *gtk.Button | |||
| 34 | + | scrolledWin *gtk.ScrolledWindow | |||
| 35 | + | listBox *gtk.ListBox | |||
| 36 | + | formBox *gtk.Box | |||
| 35 | 37 | ||||
| 36 | formURLEntry *gtk.SearchEntry | 38 | formURLEntry *gtk.SearchEntry | ||
| 37 | formTitleEntry *gtk.SearchEntry | 39 | formTitleEntry *gtk.SearchEntry | ||
| 38 | - | formTagsEntry *gtk.SearchEntry | 40 | + | formTagSearch *gtk.SearchEntry |
| 41 | + | formTagMatches *gtk.Box | |||
| 39 | formShortcutEntry *gtk.SearchEntry | 42 | formShortcutEntry *gtk.SearchEntry | ||
| 40 | formSaveButton *gtk.Button | 43 | formSaveButton *gtk.Button | ||
| 41 | formURL string | 44 | formURL string | ||
| 42 | formTitle string | 45 | formTitle string | ||
| 43 | - | formTags string | |||
| 44 | formShortcut string | 46 | formShortcut string | ||
| 47 | + | formTagIDs map[entity.TagID]struct{} | |||
| 45 | 48 | ||||
| 46 | favoritesUC port.FavoritesSidebarFavorites | 49 | favoritesUC port.FavoritesSidebarFavorites | ||
| 47 | onNavigate func(ctx context.Context, url string) error | 50 | onNavigate func(ctx context.Context, url string) error | ||
| @@ -60,6 +63,7 @@ type FavoritesSidebar struct { | |||||
| 60 | destroyed bool | 63 | destroyed bool | ||
| 61 | visible bool | 64 | visible bool | ||
| 62 | focusZone favoritesSidebarFocusZone | 65 | focusZone favoritesSidebarFocusZone | ||
| 66 | + | textInputWidget *gtk.Widget | |||
| 63 | mode favoritesSidebarMode | 67 | mode favoritesSidebarMode | ||
| 64 | editingID entity.FavoriteID | 68 | editingID entity.FavoriteID | ||
| 65 | confirmDelete bool | 69 | confirmDelete bool | ||
| @@ -157,33 +161,12 @@ func (fs *FavoritesSidebar) Destroy() { | |||||
| 157 | } | 161 | } | ||
| 158 | 162 | ||||
| 159 | func (fs *FavoritesSidebar) createWidgets() error { | 163 | func (fs *FavoritesSidebar) createWidgets() error { | ||
| 160 | - | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing) | 164 | + | if err := fs.createOuterAndSearch(); err != nil { |
| 161 | - | if fs.outerBox == nil { | 165 | + | return err |
| 162 | - | return fmt.Errorf("favorites sidebar: outer box creation failed") | |||
| 163 | } | 166 | } | ||
| 164 | - | fs.outerBox.AddCssClass("favorites-sidebar-outer") | 167 | + | if err := fs.createTagControls(); err != nil { |
| 165 | - | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | 168 | + | return err |
| 166 | - | fs.outerBox.SetHexpand(false) | |||
| 167 | - | fs.outerBox.SetVexpand(true) | |||
| 168 | - | fs.outerBox.SetVisible(false) | |||
| 169 | - | ||||
| 170 | - | fs.searchEntry = gtk.NewSearchEntry() | |||
| 171 | - | if fs.searchEntry == nil { | |||
| 172 | - | return fmt.Errorf("favorites sidebar: search entry creation failed") | |||
| 173 | } | 169 | } | ||
| 174 | - | fs.searchEntry.AddCssClass("favorites-sidebar-search") | |||
| 175 | - | fs.searchEntry.SetHexpand(true) | |||
| 176 | - | placeholder := "Search favorites" | |||
| 177 | - | fs.searchEntry.SetPlaceholderText(&placeholder) | |||
| 178 | - | fs.outerBox.Append(&fs.searchEntry.Widget) | |||
| 179 | - | ||||
| 180 | - | fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |||
| 181 | - | if fs.tagBox == nil { | |||
| 182 | - | return fmt.Errorf("favorites sidebar: tag box creation failed") | |||
| 183 | - | } | |||
| 184 | - | fs.tagBox.AddCssClass("favorites-sidebar-tags") | |||
| 185 | - | fs.tagBox.SetHexpand(true) | |||
| 186 | - | fs.outerBox.Append(&fs.tagBox.Widget) | |||
| 187 | 170 | ||||
| 188 | fs.scrolledWin = gtk.NewScrolledWindow() | 171 | fs.scrolledWin = gtk.NewScrolledWindow() | ||
| 189 | if fs.scrolledWin == nil { | 172 | if fs.scrolledWin == nil { | ||
| @@ -192,12 +175,15 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||||
| 192 | fs.scrolledWin.SetVexpand(true) | 175 | fs.scrolledWin.SetVexpand(true) | ||
| 193 | fs.scrolledWin.SetHexpand(true) | 176 | fs.scrolledWin.SetHexpand(true) | ||
| 194 | fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | 177 | fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | ||
| 178 | + | fs.scrolledWin.AddCssClass("sidebar-list") | |||
| 195 | 179 | ||||
| 196 | fs.listBox = gtk.NewListBox() | 180 | fs.listBox = gtk.NewListBox() | ||
| 197 | if fs.listBox == nil { | 181 | if fs.listBox == nil { | ||
| 198 | return fmt.Errorf("favorites sidebar: list box creation failed") | 182 | return fmt.Errorf("favorites sidebar: list box creation failed") | ||
| 199 | } | 183 | } | ||
| 184 | + | fs.listBox.AddCssClass("sidebar-list") | |||
| 200 | fs.listBox.AddCssClass("favorites-sidebar-list") | 185 | fs.listBox.AddCssClass("favorites-sidebar-list") | ||
| 186 | + | fs.trackListFocus(&fs.listBox.Widget) | |||
| 201 | fs.listBox.SetActivateOnSingleClick(true) | 187 | fs.listBox.SetActivateOnSingleClick(true) | ||
| 202 | fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 188 | fs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 203 | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 189 | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | ||
| @@ -220,6 +206,125 @@ func (fs *FavoritesSidebar) createWidgets() error { | |||||
| 220 | return nil | 206 | return nil | ||
| 221 | } | 207 | } | ||
| 222 | 208 | ||||
| 209 | + | func (fs *FavoritesSidebar) createOuterAndSearch() error { | |||
| 210 | + | fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 211 | + | if fs.outerBox == nil { | |||
| 212 | + | return fmt.Errorf("favorites sidebar: outer box creation failed") | |||
| 213 | + | } | |||
| 214 | + | fs.outerBox.AddCssClass("sidebar-outer") | |||
| 215 | + | fs.outerBox.AddCssClass("favorites-sidebar-outer") | |||
| 216 | + | fs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |||
| 217 | + | fs.outerBox.SetHexpand(false) | |||
| 218 | + | fs.outerBox.SetVexpand(true) | |||
| 219 | + | fs.outerBox.SetVisible(false) | |||
| 220 | + | ||||
| 221 | + | searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |||
| 222 | + | if searchBox == nil { | |||
| 223 | + | return fmt.Errorf("favorites sidebar: search box creation failed") | |||
| 224 | + | } | |||
| 225 | + | searchBox.AddCssClass("sidebar-search-box") | |||
| 226 | + | searchBox.SetHexpand(true) | |||
| 227 | + | fs.searchEntry = gtk.NewSearchEntry() | |||
| 228 | + | if fs.searchEntry == nil { | |||
| 229 | + | return fmt.Errorf("favorites sidebar: search entry creation failed") | |||
| 230 | + | } | |||
| 231 | + | fs.searchEntry.AddCssClass("sidebar-search") | |||
| 232 | + | fs.searchEntry.AddCssClass("favorites-sidebar-search") | |||
| 233 | + | fs.trackTextInputFocus(&fs.searchEntry.Widget) | |||
| 234 | + | fs.searchEntry.SetHexpand(true) | |||
| 235 | + | placeholder := "Search favorites..." | |||
| 236 | + | fs.searchEntry.SetPlaceholderText(&placeholder) | |||
| 237 | + | searchBox.Append(&fs.searchEntry.Widget) | |||
| 238 | + | fs.outerBox.Append(&searchBox.Widget) | |||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func (fs *FavoritesSidebar) createTagControls() error { | |||
| 243 | + | fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |||
| 244 | + | if fs.tagBox == nil { | |||
| 245 | + | return fmt.Errorf("favorites sidebar: tag box creation failed") | |||
| 246 | + | } | |||
| 247 | + | fs.tagBox.AddCssClass("favorites-sidebar-tags") | |||
| 248 | + | fs.tagBox.SetHexpand(true) | |||
| 249 | + | fs.outerBox.Append(&fs.tagBox.Widget) | |||
| 250 | + | fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |||
| 251 | + | if fs.tagPromptBox == nil { | |||
| 252 | + | return fmt.Errorf("favorites sidebar: tag prompt creation failed") | |||
| 253 | + | } | |||
| 254 | + | fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt") | |||
| 255 | + | fs.tagPromptBox.SetVisible(false) | |||
| 256 | + | fs.outerBox.Append(&fs.tagPromptBox.Widget) | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget) { | |||
| 261 | + | if fs == nil || widget == nil { | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | controller := gtk.NewEventControllerFocus() | |||
| 265 | + | if controller == nil { | |||
| 266 | + | return | |||
| 267 | + | } | |||
| 268 | + | entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) } | |||
| 269 | + | left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) } | |||
| 270 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, entered, left) | |||
| 271 | + | controller.ConnectEnter(&entered) | |||
| 272 | + | controller.ConnectLeave(&left) | |||
| 273 | + | widget.AddController(&controller.EventController) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) { | |||
| 277 | + | if fs == nil || widget == nil { | |||
| 278 | + | return | |||
| 279 | + | } | |||
| 280 | + | fs.mu.Lock() | |||
| 281 | + | if !fs.destroyed { | |||
| 282 | + | fs.textInputWidget = widget | |||
| 283 | + | if fs.searchEntry != nil && widget == &fs.searchEntry.Widget { | |||
| 284 | + | fs.focusZone = favoritesSidebarFocusSearch | |||
| 285 | + | } else { | |||
| 286 | + | fs.focusZone = favoritesSidebarFocusForm | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | fs.mu.Unlock() | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) { | |||
| 293 | + | if fs == nil || widget == nil { | |||
| 294 | + | return | |||
| 295 | + | } | |||
| 296 | + | controller := gtk.NewEventControllerFocus() | |||
| 297 | + | if controller == nil { | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 300 | + | entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) } | |||
| 301 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, entered) | |||
| 302 | + | controller.ConnectEnter(&entered) | |||
| 303 | + | widget.AddController(&controller.EventController) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) { | |||
| 307 | + | if fs == nil { | |||
| 308 | + | return | |||
| 309 | + | } | |||
| 310 | + | fs.mu.Lock() | |||
| 311 | + | if !fs.destroyed { | |||
| 312 | + | fs.focusZone = zone | |||
| 313 | + | } | |||
| 314 | + | fs.mu.Unlock() | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) { | |||
| 318 | + | if fs == nil || widget == nil { | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | fs.mu.Lock() | |||
| 322 | + | if !fs.destroyed && fs.textInputWidget == widget { | |||
| 323 | + | fs.textInputWidget = nil | |||
| 324 | + | } | |||
| 325 | + | fs.mu.Unlock() | |||
| 326 | + | } | |||
| 327 | + | ||||
| 223 | func (fs *FavoritesSidebar) focusSearch() { | 328 | func (fs *FavoritesSidebar) focusSearch() { | ||
| 224 | if fs == nil { | 329 | if fs == nil { | ||
| 225 | return | 330 | return | ||
A internal/ui/component/favorites_sidebar_form_tags.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func favoriteTags(fav *entity.Favorite) []entity.Tag { | |
| 12 | + | if fav == nil { | |
| 13 | + | return nil | |
| 14 | + | } | |
| 15 | + | return fav.Tags | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func tagIDSet(tags []entity.Tag) map[entity.TagID]struct{} { | |
| 19 | + | ids := make(map[entity.TagID]struct{}, len(tags)) | |
| 20 | + | for _, tag := range tags { | |
| 21 | + | ids[tag.ID] = struct{}{} | |
| 22 | + | } | |
| 23 | + | return ids | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (fs *FavoritesSidebar) setupFormTagSearch() { | |
| 27 | + | if fs == nil || fs.formTagSearch == nil { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | changed := func(_ gtk.SearchEntry) { fs.renderFormTagMatches(fs.formTagSearch.GetText()) } | |
| 31 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, changed) | |
| 32 | + | fs.formTagSearch.ConnectSearchChanged(&changed) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func formTagCandidates(tags []*entity.Tag, selected map[entity.TagID]struct{}, query string) []*entity.Tag { | |
| 36 | + | query = strings.ToLower(strings.TrimSpace(query)) | |
| 37 | + | matches := make([]*entity.Tag, 0, len(tags)) | |
| 38 | + | for _, tag := range tags { | |
| 39 | + | if tag == nil { | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | if query == "" { | |
| 43 | + | if _, ok := selected[tag.ID]; ok { | |
| 44 | + | matches = append(matches, tag) | |
| 45 | + | } | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | if strings.Contains(strings.ToLower(tag.Name), query) { | |
| 49 | + | matches = append(matches, tag) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | return matches | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (fs *FavoritesSidebar) renderFormTagMatches(query string) { | |
| 56 | + | if fs == nil || fs.formTagMatches == nil { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | fs.mu.RLock() | |
| 60 | + | tags := append([]*entity.Tag(nil), fs.allTags...) | |
| 61 | + | selected := make(map[entity.TagID]struct{}, len(fs.formTagIDs)) | |
| 62 | + | for id := range fs.formTagIDs { | |
| 63 | + | selected[id] = struct{}{} | |
| 64 | + | } | |
| 65 | + | fs.mu.RUnlock() | |
| 66 | + | ||
| 67 | + | clearBoxChildren(fs.formTagMatches) | |
| 68 | + | for _, tag := range formTagCandidates(tags, selected, query) { | |
| 69 | + | t := *tag | |
| 70 | + | label := t.Name | |
| 71 | + | if _, ok := selected[t.ID]; ok { | |
| 72 | + | label = "✓ " + label | |
| 73 | + | } | |
| 74 | + | button := gtk.NewButtonWithLabel(label) | |
| 75 | + | if button == nil { | |
| 76 | + | continue | |
| 77 | + | } | |
| 78 | + | button.AddCssClass("favorites-sidebar-form-tag-match") | |
| 79 | + | callback := func(_ gtk.Button) { fs.toggleFormTag(t.ID) } | |
| 80 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, callback) | |
| 81 | + | button.ConnectClicked(&callback) | |
| 82 | + | fs.formTagMatches.Append(&button.Widget) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (fs *FavoritesSidebar) toggleFormTag(tagID entity.TagID) { | |
| 87 | + | if fs == nil { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | fs.mu.Lock() | |
| 91 | + | if fs.destroyed { | |
| 92 | + | fs.mu.Unlock() | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | if fs.formTagIDs == nil { | |
| 96 | + | fs.formTagIDs = make(map[entity.TagID]struct{}) | |
| 97 | + | } | |
| 98 | + | if _, ok := fs.formTagIDs[tagID]; ok { | |
| 99 | + | delete(fs.formTagIDs, tagID) | |
| 100 | + | } else { | |
| 101 | + | fs.formTagIDs[tagID] = struct{}{} | |
| 102 | + | } | |
| 103 | + | fs.mu.Unlock() | |
| 104 | + | query := "" | |
| 105 | + | if fs.formTagSearch != nil { | |
| 106 | + | query = fs.formTagSearch.GetText() | |
| 107 | + | } | |
| 108 | + | fs.renderFormTagMatches(query) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (fs *FavoritesSidebar) formTagIDsSnapshot() []entity.TagID { | |
| 112 | + | if fs == nil { | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | fs.mu.RLock() | |
| 116 | + | defer fs.mu.RUnlock() | |
| 117 | + | ids := make([]entity.TagID, 0, len(fs.formTagIDs)) | |
| 118 | + | for id := range fs.formTagIDs { | |
| 119 | + | ids = append(ids, id) | |
| 120 | + | } | |
| 121 | + | return ids | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wanted []entity.TagID) bool { | |
| 125 | + | if fs == nil { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | fs.mu.RLock() | |
| 129 | + | uc, ctx := fs.favoritesUC, fs.ctx | |
| 130 | + | var current []entity.Tag | |
| 131 | + | for _, fav := range fs.allFavorites { | |
| 132 | + | if fav != nil && fav.ID == favoriteID { | |
| 133 | + | current = append(current, fav.Tags...) | |
| 134 | + | break | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | fs.mu.RUnlock() | |
| 138 | + | if uc == nil { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | wantedSet := make(map[entity.TagID]struct{}, len(wanted)) | |
| 142 | + | for _, id := range wanted { | |
| 143 | + | wantedSet[id] = struct{}{} | |
| 144 | + | } | |
| 145 | + | currentSet := tagIDSet(current) | |
| 146 | + | for id := range currentSet { | |
| 147 | + | if _, ok := wantedSet[id]; ok { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil { | |
| 151 | + | fs.setNotice(err.Error()) | |
| 152 | + | return false | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | for id := range wantedSet { | |
| 156 | + | if _, ok := currentSet[id]; ok { | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | if err := uc.TagFavorite(ctx, favoriteID, id); err != nil { | |
| 160 | + | fs.setNotice(err.Error()) | |
| 161 | + | return false | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return true | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func favoriteTags(fav *entity.Favorite) []entity.Tag { | |||
| 12 | + | if fav == nil { | |||
| 13 | + | return nil | |||
| 14 | + | } | |||
| 15 | + | return fav.Tags | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func tagIDSet(tags []entity.Tag) map[entity.TagID]struct{} { | |||
| 19 | + | ids := make(map[entity.TagID]struct{}, len(tags)) | |||
| 20 | + | for _, tag := range tags { | |||
| 21 | + | ids[tag.ID] = struct{}{} | |||
| 22 | + | } | |||
| 23 | + | return ids | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (fs *FavoritesSidebar) setupFormTagSearch() { | |||
| 27 | + | if fs == nil || fs.formTagSearch == nil { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | changed := func(_ gtk.SearchEntry) { fs.renderFormTagMatches(fs.formTagSearch.GetText()) } | |||
| 31 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, changed) | |||
| 32 | + | fs.formTagSearch.ConnectSearchChanged(&changed) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func formTagCandidates(tags []*entity.Tag, selected map[entity.TagID]struct{}, query string) []*entity.Tag { | |||
| 36 | + | query = strings.ToLower(strings.TrimSpace(query)) | |||
| 37 | + | matches := make([]*entity.Tag, 0, len(tags)) | |||
| 38 | + | for _, tag := range tags { | |||
| 39 | + | if tag == nil { | |||
| 40 | + | continue | |||
| 41 | + | } | |||
| 42 | + | if query == "" { | |||
| 43 | + | if _, ok := selected[tag.ID]; ok { | |||
| 44 | + | matches = append(matches, tag) | |||
| 45 | + | } | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | if strings.Contains(strings.ToLower(tag.Name), query) { | |||
| 49 | + | matches = append(matches, tag) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | return matches | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (fs *FavoritesSidebar) renderFormTagMatches(query string) { | |||
| 56 | + | if fs == nil || fs.formTagMatches == nil { | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | fs.mu.RLock() | |||
| 60 | + | tags := append([]*entity.Tag(nil), fs.allTags...) | |||
| 61 | + | selected := make(map[entity.TagID]struct{}, len(fs.formTagIDs)) | |||
| 62 | + | for id := range fs.formTagIDs { | |||
| 63 | + | selected[id] = struct{}{} | |||
| 64 | + | } | |||
| 65 | + | fs.mu.RUnlock() | |||
| 66 | + | ||||
| 67 | + | clearBoxChildren(fs.formTagMatches) | |||
| 68 | + | for _, tag := range formTagCandidates(tags, selected, query) { | |||
| 69 | + | t := *tag | |||
| 70 | + | label := t.Name | |||
| 71 | + | if _, ok := selected[t.ID]; ok { | |||
| 72 | + | label = "✓ " + label | |||
| 73 | + | } | |||
| 74 | + | button := gtk.NewButtonWithLabel(label) | |||
| 75 | + | if button == nil { | |||
| 76 | + | continue | |||
| 77 | + | } | |||
| 78 | + | button.AddCssClass("favorites-sidebar-form-tag-match") | |||
| 79 | + | callback := func(_ gtk.Button) { fs.toggleFormTag(t.ID) } | |||
| 80 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, callback) | |||
| 81 | + | button.ConnectClicked(&callback) | |||
| 82 | + | fs.formTagMatches.Append(&button.Widget) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (fs *FavoritesSidebar) toggleFormTag(tagID entity.TagID) { | |||
| 87 | + | if fs == nil { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | fs.mu.Lock() | |||
| 91 | + | if fs.destroyed { | |||
| 92 | + | fs.mu.Unlock() | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | if fs.formTagIDs == nil { | |||
| 96 | + | fs.formTagIDs = make(map[entity.TagID]struct{}) | |||
| 97 | + | } | |||
| 98 | + | if _, ok := fs.formTagIDs[tagID]; ok { | |||
| 99 | + | delete(fs.formTagIDs, tagID) | |||
| 100 | + | } else { | |||
| 101 | + | fs.formTagIDs[tagID] = struct{}{} | |||
| 102 | + | } | |||
| 103 | + | fs.mu.Unlock() | |||
| 104 | + | query := "" | |||
| 105 | + | if fs.formTagSearch != nil { | |||
| 106 | + | query = fs.formTagSearch.GetText() | |||
| 107 | + | } | |||
| 108 | + | fs.renderFormTagMatches(query) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (fs *FavoritesSidebar) formTagIDsSnapshot() []entity.TagID { | |||
| 112 | + | if fs == nil { | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | fs.mu.RLock() | |||
| 116 | + | defer fs.mu.RUnlock() | |||
| 117 | + | ids := make([]entity.TagID, 0, len(fs.formTagIDs)) | |||
| 118 | + | for id := range fs.formTagIDs { | |||
| 119 | + | ids = append(ids, id) | |||
| 120 | + | } | |||
| 121 | + | return ids | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wanted []entity.TagID) bool { | |||
| 125 | + | if fs == nil { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | fs.mu.RLock() | |||
| 129 | + | uc, ctx := fs.favoritesUC, fs.ctx | |||
| 130 | + | var current []entity.Tag | |||
| 131 | + | for _, fav := range fs.allFavorites { | |||
| 132 | + | if fav != nil && fav.ID == favoriteID { | |||
| 133 | + | current = append(current, fav.Tags...) | |||
| 134 | + | break | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | fs.mu.RUnlock() | |||
| 138 | + | if uc == nil { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | wantedSet := make(map[entity.TagID]struct{}, len(wanted)) | |||
| 142 | + | for _, id := range wanted { | |||
| 143 | + | wantedSet[id] = struct{}{} | |||
| 144 | + | } | |||
| 145 | + | currentSet := tagIDSet(current) | |||
| 146 | + | for id := range currentSet { | |||
| 147 | + | if _, ok := wantedSet[id]; ok { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil { | |||
| 151 | + | fs.setNotice(err.Error()) | |||
| 152 | + | return false | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | for id := range wantedSet { | |||
| 156 | + | if _, ok := currentSet[id]; ok { | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | if err := uc.TagFavorite(ctx, favoriteID, id); err != nil { | |||
| 160 | + | fs.setNotice(err.Error()) | |||
| 161 | + | return false | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return true | |||
| 165 | + | } | |||
M internal/ui/component/favorites_sidebar_forms.go +30 -93
| @@ -18,7 +18,8 @@ const ( | |||
| 18 | 18 | favoritesSidebarModeNone favoritesSidebarMode = iota | |
| 19 | 19 | favoritesSidebarModeAdd | |
| 20 | 20 | favoritesSidebarModeEdit | |
| 21 | - | favoritesSidebarModeTag | |
| 21 | + | favoritesSidebarModeCreateTag | |
| 22 | + | favoritesSidebarModeBindTag | |
| 22 | 23 | favoritesSidebarModeShortcut | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| @@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() { | |||
| 42 | 43 | fs.editingID = 0 | |
| 43 | 44 | fs.confirmDelete = false | |
| 44 | 45 | fs.confirmDeleteID = 0 | |
| 45 | - | fs.setNoticeLocked("Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save.") | |
| 46 | + | fs.setNoticeLocked("Add favorite: URL, title, tags, shortcut 1-9. Press Ctrl+Enter or Save.") | |
| 46 | 47 | fs.mu.Unlock() | |
| 47 | 48 | fs.renderForm(nil) | |
| 48 | 49 | fs.rebuildList() | |
| @@ -63,7 +64,7 @@ func (fs *FavoritesSidebar) beginEditForm() { | |||
| 63 | 64 | fs.editingID = fav.ID | |
| 64 | 65 | fs.confirmDelete = false | |
| 65 | 66 | fs.confirmDeleteID = 0 | |
| 66 | - | fs.setNoticeLocked("Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save.") | |
| 67 | + | fs.setNoticeLocked("Edit favorite: URL is read-only; use + on the selected favorite to manage tags. Press Ctrl+Enter or Save.") | |
| 67 | 68 | fs.mu.Unlock() | |
| 68 | 69 | fs.renderForm(fav) | |
| 69 | 70 | fs.rebuildList() | |
| @@ -76,18 +77,17 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 76 | 77 | clearBoxChildren(fs.formBox) | |
| 77 | 78 | fs.formURLEntry = gtk.NewSearchEntry() | |
| 78 | 79 | fs.formTitleEntry = gtk.NewSearchEntry() | |
| 79 | - | fs.formTagsEntry = nil | |
| 80 | - | if fav == nil { | |
| 81 | - | fs.formTagsEntry = gtk.NewSearchEntry() | |
| 82 | - | } | |
| 80 | + | fs.formTagSearch = gtk.NewSearchEntry() | |
| 81 | + | fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3) | |
| 83 | 82 | fs.formShortcutEntry = gtk.NewSearchEntry() | |
| 83 | + | fs.formTagIDs = tagIDSet(favoriteTags(fav)) | |
| 84 | 84 | entries := []struct { | |
| 85 | 85 | entry *gtk.SearchEntry | |
| 86 | 86 | label string | |
| 87 | 87 | }{ | |
| 88 | 88 | {entry: fs.formURLEntry, label: "URL"}, | |
| 89 | 89 | {entry: fs.formTitleEntry, label: "Title"}, | |
| 90 | - | {entry: fs.formTagsEntry, label: "Tag IDs"}, | |
| 90 | + | {entry: fs.formTagSearch, label: "Add tags..."}, | |
| 91 | 91 | {entry: fs.formShortcutEntry, label: "Shortcut"}, | |
| 92 | 92 | } | |
| 93 | 93 | for _, item := range entries { | |
| @@ -96,8 +96,14 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 96 | 96 | } | |
| 97 | 97 | placeholder := item.label | |
| 98 | 98 | item.entry.SetPlaceholderText(&placeholder) | |
| 99 | + | fs.trackTextInputFocus(&item.entry.Widget) | |
| 99 | 100 | fs.formBox.Append(&item.entry.Widget) | |
| 100 | 101 | } | |
| 102 | + | if fs.formTagMatches != nil { | |
| 103 | + | fs.formTagMatches.AddCssClass("favorites-sidebar-form-tag-matches") | |
| 104 | + | fs.formBox.Append(&fs.formTagMatches.Widget) | |
| 105 | + | } | |
| 106 | + | fs.setupFormTagSearch() | |
| 101 | 107 | fs.formSaveButton = gtk.NewButtonWithLabel("Save") | |
| 102 | 108 | if fs.formSaveButton != nil { | |
| 103 | 109 | cb := func(_ gtk.Button) { | |
| @@ -110,7 +116,6 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 110 | 116 | if fav != nil { | |
| 111 | 117 | fs.formURL = fav.URL | |
| 112 | 118 | fs.formTitle = fav.Title | |
| 113 | - | fs.formTags = joinFavoriteTagIDs(fav.Tags) | |
| 114 | 119 | fs.formShortcut = "" | |
| 115 | 120 | if fav.ShortcutKey != nil { | |
| 116 | 121 | fs.formShortcut = strconv.Itoa(*fav.ShortcutKey) | |
| @@ -122,15 +127,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||
| 122 | 127 | if fs.formTitleEntry != nil { | |
| 123 | 128 | fs.formTitleEntry.SetText(fs.formTitle) | |
| 124 | 129 | } | |
| 125 | - | if fs.formTagsEntry != nil { | |
| 126 | - | fs.formTagsEntry.SetText(fs.formTags) | |
| 127 | - | } | |
| 128 | 130 | if fs.formShortcutEntry != nil { | |
| 129 | 131 | fs.formShortcutEntry.SetText(fs.formShortcut) | |
| 130 | 132 | } | |
| 131 | 133 | } else { | |
| 132 | - | fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", "" | |
| 134 | + | fs.formURL, fs.formTitle, fs.formShortcut = "", "", "" | |
| 133 | 135 | } | |
| 136 | + | fs.renderFormTagMatches("") | |
| 134 | 137 | fs.formBox.SetVisible(true) | |
| 135 | 138 | fs.focusForm() | |
| 136 | 139 | } | |
| @@ -177,6 +180,12 @@ func (fs *FavoritesSidebar) cancelManagement() bool { | |||
| 177 | 180 | fs.formBox.SetVisible(false) | |
| 178 | 181 | clearBoxChildren(fs.formBox) | |
| 179 | 182 | } | |
| 183 | + | if fs.tagPromptBox != nil { | |
| 184 | + | clearBoxChildren(fs.tagPromptBox) | |
| 185 | + | fs.tagPromptBox.SetVisible(false) | |
| 186 | + | fs.tagNameEntry = nil | |
| 187 | + | fs.tagPromptSaveBtn = nil | |
| 188 | + | } | |
| 180 | 189 | fs.rebuildList() | |
| 181 | 190 | return active | |
| 182 | 191 | } | |
| @@ -194,13 +203,9 @@ func (fs *FavoritesSidebar) submitForm() bool { | |||
| 194 | 203 | if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) { | |
| 195 | 204 | return false | |
| 196 | 205 | } | |
| 197 | - | url, title, tagsText, shortcutText := fs.formValues() | |
| 206 | + | url, title, shortcutText := fs.formValues() | |
| 207 | + | tags := fs.formTagIDsSnapshot() | |
| 198 | 208 | if mode == favoritesSidebarModeAdd { | |
| 199 | - | tags, err := parseTagIDs(tagsText) | |
| 200 | - | if err != nil { | |
| 201 | - | fs.setNotice(err.Error()) | |
| 202 | - | return true | |
| 203 | - | } | |
| 204 | 209 | key, err := parseShortcut(shortcutText) | |
| 205 | 210 | if err != nil { | |
| 206 | 211 | fs.setNotice(err.Error()) | |
| @@ -231,15 +236,15 @@ func (fs *FavoritesSidebar) submitForm() bool { | |||
| 231 | 236 | fs.setNotice(err.Error()) | |
| 232 | 237 | return true | |
| 233 | 238 | } | |
| 239 | + | if !fs.updateFavoriteTags(id, tags) { | |
| 240 | + | return true | |
| 241 | + | } | |
| 234 | 242 | } | |
| 235 | 243 | fs.cancelManagement() | |
| 236 | 244 | fs.startLoad() | |
| 237 | 245 | return true | |
| 238 | 246 | } | |
| 239 | 247 | ||
| 240 | - | func (fs *FavoritesSidebar) enterTagMode() { | |
| 241 | - | fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite") | |
| 242 | - | } | |
| 243 | 248 | func (fs *FavoritesSidebar) enterShortcutMode() { | |
| 244 | 249 | fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear") | |
| 245 | 250 | } | |
| @@ -256,43 +261,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri | |||
| 256 | 261 | fs.rebuildList() | |
| 257 | 262 | } | |
| 258 | 263 | ||
| 259 | - | func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool { | |
| 260 | - | fav := fs.selectedFavorite() | |
| 261 | - | if fav == nil || ord < 1 { | |
| 262 | - | fs.setNotice("Select a favorite and tag 1-9") | |
| 263 | - | return true | |
| 264 | - | } | |
| 265 | - | fs.mu.RLock() | |
| 266 | - | if ord > len(fs.allTags) || fs.favoritesUC == nil { | |
| 267 | - | fs.mu.RUnlock() | |
| 268 | - | fs.setNotice("No tag for key") | |
| 269 | - | return true | |
| 270 | - | } | |
| 271 | - | tagID := fs.allTags[ord-1].ID | |
| 272 | - | uc := fs.favoritesUC | |
| 273 | - | ctx := fs.ctx | |
| 274 | - | fs.mu.RUnlock() | |
| 275 | - | has := false | |
| 276 | - | for _, tag := range fav.Tags { | |
| 277 | - | if tag.ID == tagID { | |
| 278 | - | has = true | |
| 279 | - | break | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | var err error | |
| 283 | - | if has { | |
| 284 | - | err = uc.UntagFavorite(ctx, fav.ID, tagID) | |
| 285 | - | } else { | |
| 286 | - | err = uc.TagFavorite(ctx, fav.ID, tagID) | |
| 287 | - | } | |
| 288 | - | if err != nil { | |
| 289 | - | fs.setNotice(err.Error()) | |
| 290 | - | return true | |
| 291 | - | } | |
| 292 | - | fs.startLoad() | |
| 293 | - | return true | |
| 294 | - | } | |
| 295 | - | ||
| 296 | 264 | func (fs *FavoritesSidebar) setShortcutKey(key *int) bool { | |
| 297 | 265 | fav := fs.selectedFavorite() | |
| 298 | 266 | if fav == nil { | |
| @@ -402,41 +370,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) { | |||
| 402 | 370 | fs.rebuildList() | |
| 403 | 371 | } | |
| 404 | 372 | ||
| 405 | - | func (fs *FavoritesSidebar) formValues() (string, string, string, string) { | |
| 406 | - | url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut | |
| 373 | + | func (fs *FavoritesSidebar) formValues() (string, string, string) { | |
| 374 | + | url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut | |
| 407 | 375 | if fs.formURLEntry != nil { | |
| 408 | 376 | url = fs.formURLEntry.GetText() | |
| 409 | 377 | } | |
| 410 | 378 | if fs.formTitleEntry != nil { | |
| 411 | 379 | title = fs.formTitleEntry.GetText() | |
| 412 | 380 | } | |
| 413 | - | if fs.formTagsEntry != nil { | |
| 414 | - | tagsText = fs.formTagsEntry.GetText() | |
| 415 | - | } | |
| 416 | 381 | if fs.formShortcutEntry != nil { | |
| 417 | 382 | shortcut = fs.formShortcutEntry.GetText() | |
| 418 | 383 | } | |
| 419 | - | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut) | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | func parseTagIDs(text string) ([]entity.TagID, error) { | |
| 423 | - | if text == "" { | |
| 424 | - | return nil, nil | |
| 425 | - | } | |
| 426 | - | parts := strings.Split(text, ",") | |
| 427 | - | ids := make([]entity.TagID, 0, len(parts)) | |
| 428 | - | for _, part := range parts { | |
| 429 | - | trimmed := strings.TrimSpace(part) | |
| 430 | - | if trimmed == "" { | |
| 431 | - | return nil, fmt.Errorf("invalid tag ID %q", part) | |
| 432 | - | } | |
| 433 | - | v, err := strconv.Atoi(trimmed) | |
| 434 | - | if err != nil || v <= 0 { | |
| 435 | - | return nil, fmt.Errorf("invalid tag ID %q", trimmed) | |
| 436 | - | } | |
| 437 | - | ids = append(ids, entity.TagID(v)) | |
| 438 | - | } | |
| 439 | - | return ids, nil | |
| 384 | + | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(shortcut) | |
| 440 | 385 | } | |
| 441 | 386 | ||
| 442 | 387 | func parseShortcut(text string) (*int, error) { | |
| @@ -450,11 +395,3 @@ func parseShortcut(text string) (*int, error) { | |||
| 450 | 395 | } | |
| 451 | 396 | return &v, nil | |
| 452 | 397 | } | |
| 453 | - | ||
| 454 | - | func joinFavoriteTagIDs(tags []entity.Tag) string { | |
| 455 | - | parts := make([]string, 0, len(tags)) | |
| 456 | - | for _, tag := range tags { | |
| 457 | - | parts = append(parts, fmt.Sprint(tag.ID)) | |
| 458 | - | } | |
| 459 | - | return strings.Join(parts, ",") | |
| 460 | - | } | |
| @@ -18,7 +18,8 @@ const ( | |||||
| 18 | favoritesSidebarModeNone favoritesSidebarMode = iota | 18 | favoritesSidebarModeNone favoritesSidebarMode = iota | ||
| 19 | favoritesSidebarModeAdd | 19 | favoritesSidebarModeAdd | ||
| 20 | favoritesSidebarModeEdit | 20 | favoritesSidebarModeEdit | ||
| 21 | - | favoritesSidebarModeTag | 21 | + | favoritesSidebarModeCreateTag |
| 22 | + | favoritesSidebarModeBindTag | |||
| 22 | favoritesSidebarModeShortcut | 23 | favoritesSidebarModeShortcut | ||
| 23 | ) | 24 | ) | ||
| 24 | 25 | ||||
| @@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() { | |||||
| 42 | fs.editingID = 0 | 43 | fs.editingID = 0 | ||
| 43 | fs.confirmDelete = false | 44 | fs.confirmDelete = false | ||
| 44 | fs.confirmDeleteID = 0 | 45 | fs.confirmDeleteID = 0 | ||
| 45 | - | fs.setNoticeLocked("Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save.") | 46 | + | fs.setNoticeLocked("Add favorite: URL, title, tags, shortcut 1-9. Press Ctrl+Enter or Save.") |
| 46 | fs.mu.Unlock() | 47 | fs.mu.Unlock() | ||
| 47 | fs.renderForm(nil) | 48 | fs.renderForm(nil) | ||
| 48 | fs.rebuildList() | 49 | fs.rebuildList() | ||
| @@ -63,7 +64,7 @@ func (fs *FavoritesSidebar) beginEditForm() { | |||||
| 63 | fs.editingID = fav.ID | 64 | fs.editingID = fav.ID | ||
| 64 | fs.confirmDelete = false | 65 | fs.confirmDelete = false | ||
| 65 | fs.confirmDeleteID = 0 | 66 | fs.confirmDeleteID = 0 | ||
| 66 | - | fs.setNoticeLocked("Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save.") | 67 | + | fs.setNoticeLocked("Edit favorite: URL is read-only; use + on the selected favorite to manage tags. Press Ctrl+Enter or Save.") |
| 67 | fs.mu.Unlock() | 68 | fs.mu.Unlock() | ||
| 68 | fs.renderForm(fav) | 69 | fs.renderForm(fav) | ||
| 69 | fs.rebuildList() | 70 | fs.rebuildList() | ||
| @@ -76,18 +77,17 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||||
| 76 | clearBoxChildren(fs.formBox) | 77 | clearBoxChildren(fs.formBox) | ||
| 77 | fs.formURLEntry = gtk.NewSearchEntry() | 78 | fs.formURLEntry = gtk.NewSearchEntry() | ||
| 78 | fs.formTitleEntry = gtk.NewSearchEntry() | 79 | fs.formTitleEntry = gtk.NewSearchEntry() | ||
| 79 | - | fs.formTagsEntry = nil | 80 | + | fs.formTagSearch = gtk.NewSearchEntry() |
| 80 | - | if fav == nil { | 81 | + | fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3) |
| 81 | - | fs.formTagsEntry = gtk.NewSearchEntry() | |||
| 82 | - | } | |||
| 83 | fs.formShortcutEntry = gtk.NewSearchEntry() | 82 | fs.formShortcutEntry = gtk.NewSearchEntry() | ||
| 83 | + | fs.formTagIDs = tagIDSet(favoriteTags(fav)) | |||
| 84 | entries := []struct { | 84 | entries := []struct { | ||
| 85 | entry *gtk.SearchEntry | 85 | entry *gtk.SearchEntry | ||
| 86 | label string | 86 | label string | ||
| 87 | }{ | 87 | }{ | ||
| 88 | {entry: fs.formURLEntry, label: "URL"}, | 88 | {entry: fs.formURLEntry, label: "URL"}, | ||
| 89 | {entry: fs.formTitleEntry, label: "Title"}, | 89 | {entry: fs.formTitleEntry, label: "Title"}, | ||
| 90 | - | {entry: fs.formTagsEntry, label: "Tag IDs"}, | 90 | + | {entry: fs.formTagSearch, label: "Add tags..."}, |
| 91 | {entry: fs.formShortcutEntry, label: "Shortcut"}, | 91 | {entry: fs.formShortcutEntry, label: "Shortcut"}, | ||
| 92 | } | 92 | } | ||
| 93 | for _, item := range entries { | 93 | for _, item := range entries { | ||
| @@ -96,8 +96,14 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||||
| 96 | } | 96 | } | ||
| 97 | placeholder := item.label | 97 | placeholder := item.label | ||
| 98 | item.entry.SetPlaceholderText(&placeholder) | 98 | item.entry.SetPlaceholderText(&placeholder) | ||
| 99 | + | fs.trackTextInputFocus(&item.entry.Widget) | |||
| 99 | fs.formBox.Append(&item.entry.Widget) | 100 | fs.formBox.Append(&item.entry.Widget) | ||
| 100 | } | 101 | } | ||
| 102 | + | if fs.formTagMatches != nil { | |||
| 103 | + | fs.formTagMatches.AddCssClass("favorites-sidebar-form-tag-matches") | |||
| 104 | + | fs.formBox.Append(&fs.formTagMatches.Widget) | |||
| 105 | + | } | |||
| 106 | + | fs.setupFormTagSearch() | |||
| 101 | fs.formSaveButton = gtk.NewButtonWithLabel("Save") | 107 | fs.formSaveButton = gtk.NewButtonWithLabel("Save") | ||
| 102 | if fs.formSaveButton != nil { | 108 | if fs.formSaveButton != nil { | ||
| 103 | cb := func(_ gtk.Button) { | 109 | cb := func(_ gtk.Button) { | ||
| @@ -110,7 +116,6 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||||
| 110 | if fav != nil { | 116 | if fav != nil { | ||
| 111 | fs.formURL = fav.URL | 117 | fs.formURL = fav.URL | ||
| 112 | fs.formTitle = fav.Title | 118 | fs.formTitle = fav.Title | ||
| 113 | - | fs.formTags = joinFavoriteTagIDs(fav.Tags) | |||
| 114 | fs.formShortcut = "" | 119 | fs.formShortcut = "" | ||
| 115 | if fav.ShortcutKey != nil { | 120 | if fav.ShortcutKey != nil { | ||
| 116 | fs.formShortcut = strconv.Itoa(*fav.ShortcutKey) | 121 | fs.formShortcut = strconv.Itoa(*fav.ShortcutKey) | ||
| @@ -122,15 +127,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) { | |||||
| 122 | if fs.formTitleEntry != nil { | 127 | if fs.formTitleEntry != nil { | ||
| 123 | fs.formTitleEntry.SetText(fs.formTitle) | 128 | fs.formTitleEntry.SetText(fs.formTitle) | ||
| 124 | } | 129 | } | ||
| 125 | - | if fs.formTagsEntry != nil { | |||
| 126 | - | fs.formTagsEntry.SetText(fs.formTags) | |||
| 127 | - | } | |||
| 128 | if fs.formShortcutEntry != nil { | 130 | if fs.formShortcutEntry != nil { | ||
| 129 | fs.formShortcutEntry.SetText(fs.formShortcut) | 131 | fs.formShortcutEntry.SetText(fs.formShortcut) | ||
| 130 | } | 132 | } | ||
| 131 | } else { | 133 | } else { | ||
| 132 | - | fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", "" | 134 | + | fs.formURL, fs.formTitle, fs.formShortcut = "", "", "" |
| 133 | } | 135 | } | ||
| 136 | + | fs.renderFormTagMatches("") | |||
| 134 | fs.formBox.SetVisible(true) | 137 | fs.formBox.SetVisible(true) | ||
| 135 | fs.focusForm() | 138 | fs.focusForm() | ||
| 136 | } | 139 | } | ||
| @@ -177,6 +180,12 @@ func (fs *FavoritesSidebar) cancelManagement() bool { | |||||
| 177 | fs.formBox.SetVisible(false) | 180 | fs.formBox.SetVisible(false) | ||
| 178 | clearBoxChildren(fs.formBox) | 181 | clearBoxChildren(fs.formBox) | ||
| 179 | } | 182 | } | ||
| 183 | + | if fs.tagPromptBox != nil { | |||
| 184 | + | clearBoxChildren(fs.tagPromptBox) | |||
| 185 | + | fs.tagPromptBox.SetVisible(false) | |||
| 186 | + | fs.tagNameEntry = nil | |||
| 187 | + | fs.tagPromptSaveBtn = nil | |||
| 188 | + | } | |||
| 180 | fs.rebuildList() | 189 | fs.rebuildList() | ||
| 181 | return active | 190 | return active | ||
| 182 | } | 191 | } | ||
| @@ -194,13 +203,9 @@ func (fs *FavoritesSidebar) submitForm() bool { | |||||
| 194 | if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) { | 203 | if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) { | ||
| 195 | return false | 204 | return false | ||
| 196 | } | 205 | } | ||
| 197 | - | url, title, tagsText, shortcutText := fs.formValues() | 206 | + | url, title, shortcutText := fs.formValues() |
| 207 | + | tags := fs.formTagIDsSnapshot() | |||
| 198 | if mode == favoritesSidebarModeAdd { | 208 | if mode == favoritesSidebarModeAdd { | ||
| 199 | - | tags, err := parseTagIDs(tagsText) | |||
| 200 | - | if err != nil { | |||
| 201 | - | fs.setNotice(err.Error()) | |||
| 202 | - | return true | |||
| 203 | - | } | |||
| 204 | key, err := parseShortcut(shortcutText) | 209 | key, err := parseShortcut(shortcutText) | ||
| 205 | if err != nil { | 210 | if err != nil { | ||
| 206 | fs.setNotice(err.Error()) | 211 | fs.setNotice(err.Error()) | ||
| @@ -231,15 +236,15 @@ func (fs *FavoritesSidebar) submitForm() bool { | |||||
| 231 | fs.setNotice(err.Error()) | 236 | fs.setNotice(err.Error()) | ||
| 232 | return true | 237 | return true | ||
| 233 | } | 238 | } | ||
| 239 | + | if !fs.updateFavoriteTags(id, tags) { | |||
| 240 | + | return true | |||
| 241 | + | } | |||
| 234 | } | 242 | } | ||
| 235 | fs.cancelManagement() | 243 | fs.cancelManagement() | ||
| 236 | fs.startLoad() | 244 | fs.startLoad() | ||
| 237 | return true | 245 | return true | ||
| 238 | } | 246 | } | ||
| 239 | 247 | ||||
| 240 | - | func (fs *FavoritesSidebar) enterTagMode() { | |||
| 241 | - | fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite") | |||
| 242 | - | } | |||
| 243 | func (fs *FavoritesSidebar) enterShortcutMode() { | 248 | func (fs *FavoritesSidebar) enterShortcutMode() { | ||
| 244 | fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear") | 249 | fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear") | ||
| 245 | } | 250 | } | ||
| @@ -256,43 +261,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri | |||||
| 256 | fs.rebuildList() | 261 | fs.rebuildList() | ||
| 257 | } | 262 | } | ||
| 258 | 263 | ||||
| 259 | - | func (fs *FavoritesSidebar) toggleFavoriteTagByOrdinal(ord int) bool { | |||
| 260 | - | fav := fs.selectedFavorite() | |||
| 261 | - | if fav == nil || ord < 1 { | |||
| 262 | - | fs.setNotice("Select a favorite and tag 1-9") | |||
| 263 | - | return true | |||
| 264 | - | } | |||
| 265 | - | fs.mu.RLock() | |||
| 266 | - | if ord > len(fs.allTags) || fs.favoritesUC == nil { | |||
| 267 | - | fs.mu.RUnlock() | |||
| 268 | - | fs.setNotice("No tag for key") | |||
| 269 | - | return true | |||
| 270 | - | } | |||
| 271 | - | tagID := fs.allTags[ord-1].ID | |||
| 272 | - | uc := fs.favoritesUC | |||
| 273 | - | ctx := fs.ctx | |||
| 274 | - | fs.mu.RUnlock() | |||
| 275 | - | has := false | |||
| 276 | - | for _, tag := range fav.Tags { | |||
| 277 | - | if tag.ID == tagID { | |||
| 278 | - | has = true | |||
| 279 | - | break | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | var err error | |||
| 283 | - | if has { | |||
| 284 | - | err = uc.UntagFavorite(ctx, fav.ID, tagID) | |||
| 285 | - | } else { | |||
| 286 | - | err = uc.TagFavorite(ctx, fav.ID, tagID) | |||
| 287 | - | } | |||
| 288 | - | if err != nil { | |||
| 289 | - | fs.setNotice(err.Error()) | |||
| 290 | - | return true | |||
| 291 | - | } | |||
| 292 | - | fs.startLoad() | |||
| 293 | - | return true | |||
| 294 | - | } | |||
| 295 | - | ||||
| 296 | func (fs *FavoritesSidebar) setShortcutKey(key *int) bool { | 264 | func (fs *FavoritesSidebar) setShortcutKey(key *int) bool { | ||
| 297 | fav := fs.selectedFavorite() | 265 | fav := fs.selectedFavorite() | ||
| 298 | if fav == nil { | 266 | if fav == nil { | ||
| @@ -402,41 +370,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) { | |||||
| 402 | fs.rebuildList() | 370 | fs.rebuildList() | ||
| 403 | } | 371 | } | ||
| 404 | 372 | ||||
| 405 | - | func (fs *FavoritesSidebar) formValues() (string, string, string, string) { | 373 | + | func (fs *FavoritesSidebar) formValues() (string, string, string) { |
| 406 | - | url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut | 374 | + | url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut |
| 407 | if fs.formURLEntry != nil { | 375 | if fs.formURLEntry != nil { | ||
| 408 | url = fs.formURLEntry.GetText() | 376 | url = fs.formURLEntry.GetText() | ||
| 409 | } | 377 | } | ||
| 410 | if fs.formTitleEntry != nil { | 378 | if fs.formTitleEntry != nil { | ||
| 411 | title = fs.formTitleEntry.GetText() | 379 | title = fs.formTitleEntry.GetText() | ||
| 412 | } | 380 | } | ||
| 413 | - | if fs.formTagsEntry != nil { | |||
| 414 | - | tagsText = fs.formTagsEntry.GetText() | |||
| 415 | - | } | |||
| 416 | if fs.formShortcutEntry != nil { | 381 | if fs.formShortcutEntry != nil { | ||
| 417 | shortcut = fs.formShortcutEntry.GetText() | 382 | shortcut = fs.formShortcutEntry.GetText() | ||
| 418 | } | 383 | } | ||
| 419 | - | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut) | 384 | + | return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(shortcut) |
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | func parseTagIDs(text string) ([]entity.TagID, error) { | |||
| 423 | - | if text == "" { | |||
| 424 | - | return nil, nil | |||
| 425 | - | } | |||
| 426 | - | parts := strings.Split(text, ",") | |||
| 427 | - | ids := make([]entity.TagID, 0, len(parts)) | |||
| 428 | - | for _, part := range parts { | |||
| 429 | - | trimmed := strings.TrimSpace(part) | |||
| 430 | - | if trimmed == "" { | |||
| 431 | - | return nil, fmt.Errorf("invalid tag ID %q", part) | |||
| 432 | - | } | |||
| 433 | - | v, err := strconv.Atoi(trimmed) | |||
| 434 | - | if err != nil || v <= 0 { | |||
| 435 | - | return nil, fmt.Errorf("invalid tag ID %q", trimmed) | |||
| 436 | - | } | |||
| 437 | - | ids = append(ids, entity.TagID(v)) | |||
| 438 | - | } | |||
| 439 | - | return ids, nil | |||
| 440 | } | 385 | } | ||
| 441 | 386 | ||||
| 442 | func parseShortcut(text string) (*int, error) { | 387 | func parseShortcut(text string) (*int, error) { | ||
| @@ -450,11 +395,3 @@ func parseShortcut(text string) (*int, error) { | |||||
| 450 | } | 395 | } | ||
| 451 | return &v, nil | 396 | return &v, nil | ||
| 452 | } | 397 | } | ||
| 453 | - | ||||
| 454 | - | func joinFavoriteTagIDs(tags []entity.Tag) string { | |||
| 455 | - | parts := make([]string, 0, len(tags)) | |||
| 456 | - | for _, tag := range tags { | |||
| 457 | - | parts = append(parts, fmt.Sprint(tag.ID)) | |||
| 458 | - | } | |||
| 459 | - | return strings.Join(parts, ",") | |||
| 460 | - | } | |||
M internal/ui/component/favorites_sidebar_keyboard.go +145 -18
| @@ -17,7 +17,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 17 | 17 | if keyController == nil { | |
| 18 | 18 | return | |
| 19 | 19 | } | |
| 20 | - | keyController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 20 | + | // Let focused child widgets process their own keys before sidebar commands. | |
| 21 | + | // In particular, GtkEntry consumes printable text during the target phase, so | |
| 22 | + | // its content cannot reach the global command switch below. | |
| 23 | + | keyController.SetPropagationPhase(gtk.PhaseBubbleValue) | |
| 21 | 24 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | |
| 22 | 25 | fs.mu.RLock() | |
| 23 | 26 | if fs.destroyed { | |
| @@ -29,6 +32,18 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 29 | 32 | fs.mu.RUnlock() | |
| 30 | 33 | ||
| 31 | 34 | textEditing := fs.inTextEditContext() | |
| 35 | + | if fs.isTextInputFocused(fs.tagNameEntry) && | |
| 36 | + | (keyval == uint(gdk.KEY_Return) || keyval == uint(gdk.KEY_KP_Enter)) { | |
| 37 | + | return fs.createTagFromFocusedEntry() | |
| 38 | + | } | |
| 39 | + | if keyval == uint(gdk.KEY_Up) || keyval == uint(gdk.KEY_Down) { | |
| 40 | + | if fs.routeDirectionalFocus(keyval) { | |
| 41 | + | return true | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if shouldDeferToTextInput(textEditing, keyval) { | |
| 45 | + | return false | |
| 46 | + | } | |
| 32 | 47 | switch keyval { | |
| 33 | 48 | case uint(gdk.KEY_Escape): | |
| 34 | 49 | if fs.cancelManagement() { | |
| @@ -43,6 +58,12 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 43 | 58 | } | |
| 44 | 59 | return true | |
| 45 | 60 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 61 | + | if fs.createTagFromFocusedEntry() { | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | if fs.focusedTagControlIndex() >= 0 { | |
| 65 | + | return false | |
| 66 | + | } | |
| 46 | 67 | return fs.handleReturnKey(state) | |
| 47 | 68 | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | |
| 48 | 69 | fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0) | |
| @@ -89,6 +110,11 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||
| 89 | 110 | } | |
| 90 | 111 | fs.selectAdjacentRow(1) | |
| 91 | 112 | return true | |
| 113 | + | case uint(gdk.KEY_plus), uint(gdk.KEY_KP_Add): | |
| 114 | + | if !textEditing && fs.listHasFocus() { | |
| 115 | + | return fs.showTagBindingPicker() | |
| 116 | + | } | |
| 117 | + | return false | |
| 92 | 118 | case uint(gdk.KEY_slash): | |
| 93 | 119 | if textEditing { | |
| 94 | 120 | return false | |
| @@ -279,21 +305,96 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |||
| 279 | 305 | ||
| 280 | 306 | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | |
| 281 | 307 | ||
| 282 | - | func (fs *FavoritesSidebar) inTextEditContext() bool { | |
| 308 | + | // shouldDeferToTextInput keeps the sidebar controller from treating text typed | |
| 309 | + | // into a descendant entry as a global sidebar command. | |
| 310 | + | // Tab remains the one sidebar command that deliberately traverses focus. | |
| 311 | + | func shouldDeferToTextInput(textEditing bool, keyval uint) bool { | |
| 312 | + | return textEditing && keyval != uint(gdk.KEY_Tab) && keyval != uint(gdk.KEY_ISO_Left_Tab) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool { | |
| 283 | 316 | if fs == nil { | |
| 284 | 317 | return false | |
| 285 | 318 | } | |
| 286 | - | if fs.searchEntry != nil && fs.searchEntry.HasFocus() { | |
| 319 | + | fs.mu.RLock() | |
| 320 | + | zone := fs.focusZone | |
| 321 | + | fs.mu.RUnlock() | |
| 322 | + | switch { | |
| 323 | + | case zone == favoritesSidebarFocusTags: | |
| 324 | + | // Tag filters and creation are deliberately Tab-only controls. | |
| 287 | 325 | return true | |
| 288 | - | } | |
| 289 | - | for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} { | |
| 290 | - | if entry != nil && entry.HasFocus() { | |
| 291 | - | return true | |
| 326 | + | case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch: | |
| 327 | + | fs.focusListAndSelectFirst() | |
| 328 | + | return true | |
| 329 | + | case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst(): | |
| 330 | + | fs.setFocusZone(favoritesSidebarFocusSearch) | |
| 331 | + | if fs.searchEntry != nil { | |
| 332 | + | fs.searchEntry.GrabFocus() | |
| 292 | 333 | } | |
| 334 | + | return true | |
| 293 | 335 | } | |
| 294 | 336 | return false | |
| 295 | 337 | } | |
| 296 | 338 | ||
| 339 | + | func (fs *FavoritesSidebar) focusListAndSelectFirst() { | |
| 340 | + | if fs == nil { | |
| 341 | + | return | |
| 342 | + | } | |
| 343 | + | fs.setFocusZone(favoritesSidebarFocusList) | |
| 344 | + | if fs.listBox == nil { | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | fs.listBox.GrabFocus() | |
| 348 | + | fs.mu.RLock() | |
| 349 | + | index := firstSelectableIndex(fs.displayRows) | |
| 350 | + | fs.mu.RUnlock() | |
| 351 | + | fs.selectIndex(index) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func (fs *FavoritesSidebar) selectedRowIsFirst() bool { | |
| 355 | + | if fs == nil { | |
| 356 | + | return false | |
| 357 | + | } | |
| 358 | + | if fs.listBox == nil { | |
| 359 | + | return true | |
| 360 | + | } | |
| 361 | + | row := fs.listBox.GetSelectedRow() | |
| 362 | + | if row == nil { | |
| 363 | + | return true | |
| 364 | + | } | |
| 365 | + | fs.mu.RLock() | |
| 366 | + | first := firstSelectableIndex(fs.displayRows) | |
| 367 | + | fs.mu.RUnlock() | |
| 368 | + | return row.GetIndex() == first | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func (fs *FavoritesSidebar) listHasFocus() bool { | |
| 372 | + | if fs == nil { | |
| 373 | + | return false | |
| 374 | + | } | |
| 375 | + | fs.mu.RLock() | |
| 376 | + | defer fs.mu.RUnlock() | |
| 377 | + | return fs.focusZone == favoritesSidebarFocusList | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func (fs *FavoritesSidebar) inTextEditContext() bool { | |
| 381 | + | if fs == nil { | |
| 382 | + | return false | |
| 383 | + | } | |
| 384 | + | fs.mu.RLock() | |
| 385 | + | defer fs.mu.RUnlock() | |
| 386 | + | return fs.textInputWidget != nil | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func (fs *FavoritesSidebar) isTextInputFocused(widget *gtk.Entry) bool { | |
| 390 | + | if fs == nil || widget == nil { | |
| 391 | + | return false | |
| 392 | + | } | |
| 393 | + | fs.mu.RLock() | |
| 394 | + | defer fs.mu.RUnlock() | |
| 395 | + | return fs.textInputWidget == &widget.Widget | |
| 396 | + | } | |
| 397 | + | ||
| 297 | 398 | func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool { | |
| 298 | 399 | shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue | |
| 299 | 400 | if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 { | |
| @@ -303,9 +404,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi | |||
| 303 | 404 | mode := fs.mode | |
| 304 | 405 | confirm := fs.confirmDelete | |
| 305 | 406 | fs.mu.RUnlock() | |
| 306 | - | if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |
| 307 | - | return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0))) | |
| 308 | - | } | |
| 309 | 407 | if mode == favoritesSidebarModeShortcut { | |
| 310 | 408 | if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |
| 311 | 409 | v := int(keyval - uint(gdk.KEY_0)) | |
| @@ -328,9 +426,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi | |||
| 328 | 426 | case uint(gdk.KEY_e): | |
| 329 | 427 | fs.beginEditForm() | |
| 330 | 428 | return true | |
| 331 | - | case uint(gdk.KEY_t): | |
| 332 | - | fs.enterTagMode() | |
| 333 | - | return true | |
| 334 | 429 | case uint(gdk.KEY_s): | |
| 335 | 430 | fs.enterShortcutMode() | |
| 336 | 431 | return true | |
| @@ -366,13 +461,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||
| 366 | 461 | if fs == nil { | |
| 367 | 462 | return | |
| 368 | 463 | } | |
| 369 | - | zones := fs.availableFocusZones() | |
| 370 | - | if len(zones) == 0 { | |
| 464 | + | if fs.cycleTagControlFocus(reverse) { | |
| 371 | 465 | return | |
| 372 | 466 | } | |
| 373 | 467 | fs.mu.RLock() | |
| 374 | 468 | current := fs.focusZone | |
| 469 | + | tagCount := len(fs.tagControls) | |
| 375 | 470 | fs.mu.RUnlock() | |
| 471 | + | if reverse && current == favoritesSidebarFocusList && tagCount > 0 { | |
| 472 | + | fs.focusTagControl(tagCount - 1) | |
| 473 | + | return | |
| 474 | + | } | |
| 475 | + | zones := fs.availableFocusZones() | |
| 476 | + | if len(zones) == 0 { | |
| 477 | + | return | |
| 478 | + | } | |
| 376 | 479 | idx := -1 | |
| 377 | 480 | for i, zone := range zones { | |
| 378 | 481 | if zone == current { | |
| @@ -394,6 +497,32 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||
| 394 | 497 | fs.focusZoneWidget(zones[idx]) | |
| 395 | 498 | } | |
| 396 | 499 | ||
| 500 | + | func (fs *FavoritesSidebar) cycleTagControlFocus(reverse bool) bool { | |
| 501 | + | fs.mu.RLock() | |
| 502 | + | inTags := fs.focusZone == favoritesSidebarFocusTags | |
| 503 | + | controls := append([]*gtk.Button(nil), fs.tagControls...) | |
| 504 | + | fs.mu.RUnlock() | |
| 505 | + | if !inTags || len(controls) == 0 { | |
| 506 | + | return false | |
| 507 | + | } | |
| 508 | + | index := fs.focusedTagControlIndex() | |
| 509 | + | if index < 0 { | |
| 510 | + | index = 0 | |
| 511 | + | } | |
| 512 | + | if reverse { | |
| 513 | + | if index > 0 { | |
| 514 | + | fs.focusTagControl(index - 1) | |
| 515 | + | return true | |
| 516 | + | } | |
| 517 | + | return false | |
| 518 | + | } | |
| 519 | + | if index < len(controls)-1 { | |
| 520 | + | fs.focusTagControl(index + 1) | |
| 521 | + | return true | |
| 522 | + | } | |
| 523 | + | return false | |
| 524 | + | } | |
| 525 | + | ||
| 397 | 526 | func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone { | |
| 398 | 527 | zones := []favoritesSidebarFocusZone{} | |
| 399 | 528 | if fs.searchEntry != nil { | |
| @@ -428,9 +557,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) { | |||
| 428 | 557 | case favoritesSidebarFocusSearch: | |
| 429 | 558 | fs.focusSearch() | |
| 430 | 559 | case favoritesSidebarFocusTags: | |
| 431 | - | if fs.tagBox != nil { | |
| 432 | - | fs.tagBox.GrabFocus() | |
| 433 | - | } | |
| 560 | + | fs.focusTagControl(0) | |
| 434 | 561 | case favoritesSidebarFocusList, favoritesSidebarFocusConfirm: | |
| 435 | 562 | if fs.listBox != nil { | |
| 436 | 563 | fs.listBox.GrabFocus() | |
| @@ -17,7 +17,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 17 | if keyController == nil { | 17 | if keyController == nil { | ||
| 18 | return | 18 | return | ||
| 19 | } | 19 | } | ||
| 20 | - | keyController.SetPropagationPhase(gtk.PhaseCaptureValue) | 20 | + | // Let focused child widgets process their own keys before sidebar commands. |
| 21 | + | // In particular, GtkEntry consumes printable text during the target phase, so | |||
| 22 | + | // its content cannot reach the global command switch below. | |||
| 23 | + | keyController.SetPropagationPhase(gtk.PhaseBubbleValue) | |||
| 21 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | 24 | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | ||
| 22 | fs.mu.RLock() | 25 | fs.mu.RLock() | ||
| 23 | if fs.destroyed { | 26 | if fs.destroyed { | ||
| @@ -29,6 +32,18 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 29 | fs.mu.RUnlock() | 32 | fs.mu.RUnlock() | ||
| 30 | 33 | ||||
| 31 | textEditing := fs.inTextEditContext() | 34 | textEditing := fs.inTextEditContext() | ||
| 35 | + | if fs.isTextInputFocused(fs.tagNameEntry) && | |||
| 36 | + | (keyval == uint(gdk.KEY_Return) || keyval == uint(gdk.KEY_KP_Enter)) { | |||
| 37 | + | return fs.createTagFromFocusedEntry() | |||
| 38 | + | } | |||
| 39 | + | if keyval == uint(gdk.KEY_Up) || keyval == uint(gdk.KEY_Down) { | |||
| 40 | + | if fs.routeDirectionalFocus(keyval) { | |||
| 41 | + | return true | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if shouldDeferToTextInput(textEditing, keyval) { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 32 | switch keyval { | 47 | switch keyval { | ||
| 33 | case uint(gdk.KEY_Escape): | 48 | case uint(gdk.KEY_Escape): | ||
| 34 | if fs.cancelManagement() { | 49 | if fs.cancelManagement() { | ||
| @@ -43,6 +58,12 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 43 | } | 58 | } | ||
| 44 | return true | 59 | return true | ||
| 45 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | 60 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | ||
| 61 | + | if fs.createTagFromFocusedEntry() { | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | if fs.focusedTagControlIndex() >= 0 { | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 46 | return fs.handleReturnKey(state) | 67 | return fs.handleReturnKey(state) | ||
| 47 | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | 68 | case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): | ||
| 48 | fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0) | 69 | fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0) | ||
| @@ -89,6 +110,11 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() { | |||||
| 89 | } | 110 | } | ||
| 90 | fs.selectAdjacentRow(1) | 111 | fs.selectAdjacentRow(1) | ||
| 91 | return true | 112 | return true | ||
| 113 | + | case uint(gdk.KEY_plus), uint(gdk.KEY_KP_Add): | |||
| 114 | + | if !textEditing && fs.listHasFocus() { | |||
| 115 | + | return fs.showTagBindingPicker() | |||
| 116 | + | } | |||
| 117 | + | return false | |||
| 92 | case uint(gdk.KEY_slash): | 118 | case uint(gdk.KEY_slash): | ||
| 93 | if textEditing { | 119 | if textEditing { | ||
| 94 | return false | 120 | return false | ||
| @@ -279,21 +305,96 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) { | |||||
| 279 | 305 | ||||
| 280 | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | 306 | func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused } | ||
| 281 | 307 | ||||
| 282 | - | func (fs *FavoritesSidebar) inTextEditContext() bool { | 308 | + | // shouldDeferToTextInput keeps the sidebar controller from treating text typed |
| 309 | + | // into a descendant entry as a global sidebar command. | |||
| 310 | + | // Tab remains the one sidebar command that deliberately traverses focus. | |||
| 311 | + | func shouldDeferToTextInput(textEditing bool, keyval uint) bool { | |||
| 312 | + | return textEditing && keyval != uint(gdk.KEY_Tab) && keyval != uint(gdk.KEY_ISO_Left_Tab) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool { | |||
| 283 | if fs == nil { | 316 | if fs == nil { | ||
| 284 | return false | 317 | return false | ||
| 285 | } | 318 | } | ||
| 286 | - | if fs.searchEntry != nil && fs.searchEntry.HasFocus() { | 319 | + | fs.mu.RLock() |
| 320 | + | zone := fs.focusZone | |||
| 321 | + | fs.mu.RUnlock() | |||
| 322 | + | switch { | |||
| 323 | + | case zone == favoritesSidebarFocusTags: | |||
| 324 | + | // Tag filters and creation are deliberately Tab-only controls. | |||
| 287 | return true | 325 | return true | ||
| 288 | - | } | 326 | + | case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch: |
| 289 | - | for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} { | 327 | + | fs.focusListAndSelectFirst() |
| 290 | - | if entry != nil && entry.HasFocus() { | 328 | + | return true |
| 291 | - | return true | 329 | + | case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst(): |
| 330 | + | fs.setFocusZone(favoritesSidebarFocusSearch) | |||
| 331 | + | if fs.searchEntry != nil { | |||
| 332 | + | fs.searchEntry.GrabFocus() | |||
| 292 | } | 333 | } | ||
| 334 | + | return true | |||
| 293 | } | 335 | } | ||
| 294 | return false | 336 | return false | ||
| 295 | } | 337 | } | ||
| 296 | 338 | ||||
| 339 | + | func (fs *FavoritesSidebar) focusListAndSelectFirst() { | |||
| 340 | + | if fs == nil { | |||
| 341 | + | return | |||
| 342 | + | } | |||
| 343 | + | fs.setFocusZone(favoritesSidebarFocusList) | |||
| 344 | + | if fs.listBox == nil { | |||
| 345 | + | return | |||
| 346 | + | } | |||
| 347 | + | fs.listBox.GrabFocus() | |||
| 348 | + | fs.mu.RLock() | |||
| 349 | + | index := firstSelectableIndex(fs.displayRows) | |||
| 350 | + | fs.mu.RUnlock() | |||
| 351 | + | fs.selectIndex(index) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func (fs *FavoritesSidebar) selectedRowIsFirst() bool { | |||
| 355 | + | if fs == nil { | |||
| 356 | + | return false | |||
| 357 | + | } | |||
| 358 | + | if fs.listBox == nil { | |||
| 359 | + | return true | |||
| 360 | + | } | |||
| 361 | + | row := fs.listBox.GetSelectedRow() | |||
| 362 | + | if row == nil { | |||
| 363 | + | return true | |||
| 364 | + | } | |||
| 365 | + | fs.mu.RLock() | |||
| 366 | + | first := firstSelectableIndex(fs.displayRows) | |||
| 367 | + | fs.mu.RUnlock() | |||
| 368 | + | return row.GetIndex() == first | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func (fs *FavoritesSidebar) listHasFocus() bool { | |||
| 372 | + | if fs == nil { | |||
| 373 | + | return false | |||
| 374 | + | } | |||
| 375 | + | fs.mu.RLock() | |||
| 376 | + | defer fs.mu.RUnlock() | |||
| 377 | + | return fs.focusZone == favoritesSidebarFocusList | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func (fs *FavoritesSidebar) inTextEditContext() bool { | |||
| 381 | + | if fs == nil { | |||
| 382 | + | return false | |||
| 383 | + | } | |||
| 384 | + | fs.mu.RLock() | |||
| 385 | + | defer fs.mu.RUnlock() | |||
| 386 | + | return fs.textInputWidget != nil | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func (fs *FavoritesSidebar) isTextInputFocused(widget *gtk.Entry) bool { | |||
| 390 | + | if fs == nil || widget == nil { | |||
| 391 | + | return false | |||
| 392 | + | } | |||
| 393 | + | fs.mu.RLock() | |||
| 394 | + | defer fs.mu.RUnlock() | |||
| 395 | + | return fs.textInputWidget == &widget.Widget | |||
| 396 | + | } | |||
| 397 | + | ||||
| 297 | func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool { | 398 | func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool { | ||
| 298 | shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue | 399 | shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue | ||
| 299 | if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 { | 400 | if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 { | ||
| @@ -303,9 +404,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi | |||||
| 303 | mode := fs.mode | 404 | mode := fs.mode | ||
| 304 | confirm := fs.confirmDelete | 405 | confirm := fs.confirmDelete | ||
| 305 | fs.mu.RUnlock() | 406 | fs.mu.RUnlock() | ||
| 306 | - | if mode == favoritesSidebarModeTag && keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | |||
| 307 | - | return fs.toggleFavoriteTagByOrdinal(int(keyval - uint(gdk.KEY_0))) | |||
| 308 | - | } | |||
| 309 | if mode == favoritesSidebarModeShortcut { | 407 | if mode == favoritesSidebarModeShortcut { | ||
| 310 | if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | 408 | if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) { | ||
| 311 | v := int(keyval - uint(gdk.KEY_0)) | 409 | v := int(keyval - uint(gdk.KEY_0)) | ||
| @@ -328,9 +426,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi | |||||
| 328 | case uint(gdk.KEY_e): | 426 | case uint(gdk.KEY_e): | ||
| 329 | fs.beginEditForm() | 427 | fs.beginEditForm() | ||
| 330 | return true | 428 | return true | ||
| 331 | - | case uint(gdk.KEY_t): | |||
| 332 | - | fs.enterTagMode() | |||
| 333 | - | return true | |||
| 334 | case uint(gdk.KEY_s): | 429 | case uint(gdk.KEY_s): | ||
| 335 | fs.enterShortcutMode() | 430 | fs.enterShortcutMode() | ||
| 336 | return true | 431 | return true | ||
| @@ -366,13 +461,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||||
| 366 | if fs == nil { | 461 | if fs == nil { | ||
| 367 | return | 462 | return | ||
| 368 | } | 463 | } | ||
| 369 | - | zones := fs.availableFocusZones() | 464 | + | if fs.cycleTagControlFocus(reverse) { |
| 370 | - | if len(zones) == 0 { | |||
| 371 | return | 465 | return | ||
| 372 | } | 466 | } | ||
| 373 | fs.mu.RLock() | 467 | fs.mu.RLock() | ||
| 374 | current := fs.focusZone | 468 | current := fs.focusZone | ||
| 469 | + | tagCount := len(fs.tagControls) | |||
| 375 | fs.mu.RUnlock() | 470 | fs.mu.RUnlock() | ||
| 471 | + | if reverse && current == favoritesSidebarFocusList && tagCount > 0 { | |||
| 472 | + | fs.focusTagControl(tagCount - 1) | |||
| 473 | + | return | |||
| 474 | + | } | |||
| 475 | + | zones := fs.availableFocusZones() | |||
| 476 | + | if len(zones) == 0 { | |||
| 477 | + | return | |||
| 478 | + | } | |||
| 376 | idx := -1 | 479 | idx := -1 | ||
| 377 | for i, zone := range zones { | 480 | for i, zone := range zones { | ||
| 378 | if zone == current { | 481 | if zone == current { | ||
| @@ -394,6 +497,32 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) { | |||||
| 394 | fs.focusZoneWidget(zones[idx]) | 497 | fs.focusZoneWidget(zones[idx]) | ||
| 395 | } | 498 | } | ||
| 396 | 499 | ||||
| 500 | + | func (fs *FavoritesSidebar) cycleTagControlFocus(reverse bool) bool { | |||
| 501 | + | fs.mu.RLock() | |||
| 502 | + | inTags := fs.focusZone == favoritesSidebarFocusTags | |||
| 503 | + | controls := append([]*gtk.Button(nil), fs.tagControls...) | |||
| 504 | + | fs.mu.RUnlock() | |||
| 505 | + | if !inTags || len(controls) == 0 { | |||
| 506 | + | return false | |||
| 507 | + | } | |||
| 508 | + | index := fs.focusedTagControlIndex() | |||
| 509 | + | if index < 0 { | |||
| 510 | + | index = 0 | |||
| 511 | + | } | |||
| 512 | + | if reverse { | |||
| 513 | + | if index > 0 { | |||
| 514 | + | fs.focusTagControl(index - 1) | |||
| 515 | + | return true | |||
| 516 | + | } | |||
| 517 | + | return false | |||
| 518 | + | } | |||
| 519 | + | if index < len(controls)-1 { | |||
| 520 | + | fs.focusTagControl(index + 1) | |||
| 521 | + | return true | |||
| 522 | + | } | |||
| 523 | + | return false | |||
| 524 | + | } | |||
| 525 | + | ||||
| 397 | func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone { | 526 | func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone { | ||
| 398 | zones := []favoritesSidebarFocusZone{} | 527 | zones := []favoritesSidebarFocusZone{} | ||
| 399 | if fs.searchEntry != nil { | 528 | if fs.searchEntry != nil { | ||
| @@ -428,9 +557,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) { | |||||
| 428 | case favoritesSidebarFocusSearch: | 557 | case favoritesSidebarFocusSearch: | ||
| 429 | fs.focusSearch() | 558 | fs.focusSearch() | ||
| 430 | case favoritesSidebarFocusTags: | 559 | case favoritesSidebarFocusTags: | ||
| 431 | - | if fs.tagBox != nil { | 560 | + | fs.focusTagControl(0) |
| 432 | - | fs.tagBox.GrabFocus() | |||
| 433 | - | } | |||
| 434 | case favoritesSidebarFocusList, favoritesSidebarFocusConfirm: | 561 | case favoritesSidebarFocusList, favoritesSidebarFocusConfirm: | ||
| 435 | if fs.listBox != nil { | 562 | if fs.listBox != nil { | ||
| 436 | fs.listBox.GrabFocus() | 563 | fs.listBox.GrabFocus() | ||
M internal/ui/component/favorites_sidebar_rows.go +74 -49
| @@ -72,70 +72,90 @@ func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction in | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | func (fs *FavoritesSidebar) renderTags() { | |
| 75 | - | if fs == nil || fs.tagBox == nil { | |
| 75 | + | tagBox, tags, selectedTagIDs, ok := fs.tagRenderState() | |
| 76 | + | if !ok { | |
| 76 | 77 | return | |
| 77 | 78 | } | |
| 79 | + | clearBoxChildren(tagBox) | |
| 80 | + | callbacks := make([]any, 0, len(tags)+2) | |
| 81 | + | controls := make([]*gtk.Button, 0, len(tags)+2) | |
| 82 | + | ||
| 83 | + | fs.appendTagFilter(tagBox, "All", len(selectedTagIDs) == 0, fs.clearTagFilters, &callbacks, &controls) | |
| 84 | + | for _, tag := range tags { | |
| 85 | + | if tag == nil { | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | t := tag | |
| 89 | + | _, selected := selectedTagIDs[t.ID] | |
| 90 | + | fs.appendTagFilter(tagBox, t.Name, selected, func() { fs.toggleTag(t.ID) }, &callbacks, &controls) | |
| 91 | + | } | |
| 92 | + | fs.appendTagFilter(tagBox, "+", false, fs.showCreateTagPrompt, &callbacks, &controls) | |
| 93 | + | if len(controls) > 0 { | |
| 94 | + | controls[len(controls)-1].AddCssClass("favorites-sidebar-tag-add") | |
| 95 | + | } | |
| 96 | + | fs.storeTagControls(callbacks, controls) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (fs *FavoritesSidebar) tagRenderState() (*gtk.Box, []*entity.Tag, map[entity.TagID]struct{}, bool) { | |
| 100 | + | if fs == nil || fs.tagBox == nil { | |
| 101 | + | return nil, nil, nil, false | |
| 102 | + | } | |
| 78 | 103 | fs.mu.RLock() | |
| 104 | + | defer fs.mu.RUnlock() | |
| 79 | 105 | if fs.destroyed { | |
| 80 | - | fs.mu.RUnlock() | |
| 81 | - | return | |
| 106 | + | return nil, nil, nil, false | |
| 82 | 107 | } | |
| 83 | - | tags := append([]*entity.Tag(nil), fs.allTags...) | |
| 84 | - | selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |
| 108 | + | selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |
| 85 | 109 | for id := range fs.selectedTagIDs { | |
| 86 | - | selectedTagIDs[id] = struct{}{} | |
| 110 | + | selected[id] = struct{}{} | |
| 87 | 111 | } | |
| 88 | - | tagBox := fs.tagBox | |
| 89 | - | fs.mu.RUnlock() | |
| 90 | - | ||
| 91 | - | clearBoxChildren(tagBox) | |
| 92 | - | callbacks := make([]any, 0, len(tags)+1) | |
| 112 | + | return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true | |
| 113 | + | } | |
| 93 | 114 | ||
| 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) | |
| 115 | + | func (fs *FavoritesSidebar) appendTagFilter( | |
| 116 | + | box *gtk.Box, | |
| 117 | + | label string, | |
| 118 | + | selected bool, | |
| 119 | + | action func(), | |
| 120 | + | callbacks *[]any, | |
| 121 | + | controls *[]*gtk.Button, | |
| 122 | + | ) { | |
| 123 | + | button := gtk.NewButtonWithLabel(label) | |
| 124 | + | if button == nil { | |
| 125 | + | return | |
| 114 | 126 | } | |
| 127 | + | button.AddCssClass("favorites-sidebar-tag-filter") | |
| 128 | + | if selected { | |
| 129 | + | button.AddCssClass("suggested-action") | |
| 130 | + | } | |
| 131 | + | callback := func(_ gtk.Button) { action() } | |
| 132 | + | *callbacks = append(*callbacks, callback) | |
| 133 | + | button.ConnectClicked(&callback) | |
| 134 | + | box.Append(&button.Widget) | |
| 135 | + | *controls = append(*controls, button) | |
| 136 | + | } | |
| 115 | 137 | ||
| 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) | |
| 138 | + | func (fs *FavoritesSidebar) clearTagFilters() { | |
| 139 | + | if fs == nil { | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | fs.mu.Lock() | |
| 143 | + | if fs.destroyed { | |
| 144 | + | fs.mu.Unlock() | |
| 145 | + | return | |
| 134 | 146 | } | |
| 147 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |
| 148 | + | fs.rebuildDisplayRowsLocked() | |
| 149 | + | fs.mu.Unlock() | |
| 150 | + | fs.renderTags() | |
| 151 | + | fs.rebuildList() | |
| 152 | + | } | |
| 135 | 153 | ||
| 154 | + | func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) { | |
| 136 | 155 | fs.mu.Lock() | |
| 137 | 156 | if !fs.destroyed { | |
| 138 | 157 | fs.tagCallbacks = callbacks | |
| 158 | + | fs.tagControls = controls | |
| 139 | 159 | } | |
| 140 | 160 | fs.mu.Unlock() | |
| 141 | 161 | } | |
| @@ -212,6 +232,7 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | |||
| 212 | 232 | if label == nil { | |
| 213 | 233 | return | |
| 214 | 234 | } | |
| 235 | + | label.AddCssClass("sidebar-empty") | |
| 215 | 236 | label.AddCssClass("favorites-sidebar-empty") | |
| 216 | 237 | label.SetXalign(0.0) | |
| 217 | 238 | row := gtk.NewListBoxRow() | |
| @@ -243,6 +264,7 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow f | |||
| 243 | 264 | if row == nil { | |
| 244 | 265 | return | |
| 245 | 266 | } | |
| 267 | + | row.AddCssClass("sidebar-row") | |
| 246 | 268 | row.AddCssClass("favorites-sidebar-row") | |
| 247 | 269 | row.SetSelectable(displayRow.Selectable) | |
| 248 | 270 | row.SetActivatable(displayRow.Selectable) | |
| @@ -257,6 +279,7 @@ func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) { | |||
| 257 | 279 | if title == nil { | |
| 258 | 280 | return | |
| 259 | 281 | } | |
| 282 | + | title.AddCssClass("sidebar-row-title") | |
| 260 | 283 | title.AddCssClass("favorites-sidebar-row-title") | |
| 261 | 284 | title.SetText(safeSidebarString(fav.Title, fav.URL)) | |
| 262 | 285 | title.SetXalign(0.0) | |
| @@ -273,6 +296,7 @@ func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) { | |||
| 273 | 296 | sub.SetHexpand(true) | |
| 274 | 297 | url := gtk.NewLabel(nil) | |
| 275 | 298 | if url != nil { | |
| 299 | + | url.AddCssClass("sidebar-row-subtitle") | |
| 276 | 300 | url.AddCssClass("favorites-sidebar-row-subtitle") | |
| 277 | 301 | url.SetText(readableURL(fav.URL)) | |
| 278 | 302 | url.SetXalign(0.0) | |
| @@ -299,6 +323,7 @@ func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) { | |||
| 299 | 323 | if tags == nil { | |
| 300 | 324 | return | |
| 301 | 325 | } | |
| 326 | + | tags.AddCssClass("favorites-sidebar-row-tags") | |
| 302 | 327 | for _, tag := range favoriteTags { | |
| 303 | 328 | name := tag.Name | |
| 304 | 329 | label := gtk.NewLabel(&name) | |
| @@ -72,70 +72,90 @@ func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction in | |||||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | func (fs *FavoritesSidebar) renderTags() { | 74 | func (fs *FavoritesSidebar) renderTags() { | ||
| 75 | - | if fs == nil || fs.tagBox == nil { | 75 | + | tagBox, tags, selectedTagIDs, ok := fs.tagRenderState() |
| 76 | + | if !ok { | |||
| 76 | return | 77 | return | ||
| 77 | } | 78 | } | ||
| 79 | + | clearBoxChildren(tagBox) | |||
| 80 | + | callbacks := make([]any, 0, len(tags)+2) | |||
| 81 | + | controls := make([]*gtk.Button, 0, len(tags)+2) | |||
| 82 | + | ||||
| 83 | + | fs.appendTagFilter(tagBox, "All", len(selectedTagIDs) == 0, fs.clearTagFilters, &callbacks, &controls) | |||
| 84 | + | for _, tag := range tags { | |||
| 85 | + | if tag == nil { | |||
| 86 | + | continue | |||
| 87 | + | } | |||
| 88 | + | t := tag | |||
| 89 | + | _, selected := selectedTagIDs[t.ID] | |||
| 90 | + | fs.appendTagFilter(tagBox, t.Name, selected, func() { fs.toggleTag(t.ID) }, &callbacks, &controls) | |||
| 91 | + | } | |||
| 92 | + | fs.appendTagFilter(tagBox, "+", false, fs.showCreateTagPrompt, &callbacks, &controls) | |||
| 93 | + | if len(controls) > 0 { | |||
| 94 | + | controls[len(controls)-1].AddCssClass("favorites-sidebar-tag-add") | |||
| 95 | + | } | |||
| 96 | + | fs.storeTagControls(callbacks, controls) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (fs *FavoritesSidebar) tagRenderState() (*gtk.Box, []*entity.Tag, map[entity.TagID]struct{}, bool) { | |||
| 100 | + | if fs == nil || fs.tagBox == nil { | |||
| 101 | + | return nil, nil, nil, false | |||
| 102 | + | } | |||
| 78 | fs.mu.RLock() | 103 | fs.mu.RLock() | ||
| 104 | + | defer fs.mu.RUnlock() | |||
| 79 | if fs.destroyed { | 105 | if fs.destroyed { | ||
| 80 | - | fs.mu.RUnlock() | 106 | + | return nil, nil, nil, false |
| 81 | - | return | |||
| 82 | } | 107 | } | ||
| 83 | - | tags := append([]*entity.Tag(nil), fs.allTags...) | 108 | + | selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) |
| 84 | - | selectedTagIDs := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs)) | |||
| 85 | for id := range fs.selectedTagIDs { | 109 | for id := range fs.selectedTagIDs { | ||
| 86 | - | selectedTagIDs[id] = struct{}{} | 110 | + | selected[id] = struct{}{} |
| 87 | } | 111 | } | ||
| 88 | - | tagBox := fs.tagBox | 112 | + | return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true |
| 89 | - | fs.mu.RUnlock() | 113 | + | } |
| 90 | - | ||||
| 91 | - | clearBoxChildren(tagBox) | |||
| 92 | - | callbacks := make([]any, 0, len(tags)+1) | |||
| 93 | 114 | ||||
| 94 | - | allButton := gtk.NewButtonWithLabel("All") | 115 | + | func (fs *FavoritesSidebar) appendTagFilter( |
| 95 | - | if allButton != nil { | 116 | + | box *gtk.Box, |
| 96 | - | if len(selectedTagIDs) == 0 { | 117 | + | label string, |
| 97 | - | allButton.AddCssClass("suggested-action") | 118 | + | selected bool, |
| 98 | - | } | 119 | + | action func(), |
| 99 | - | cb := func(_ gtk.Button) { | 120 | + | callbacks *[]any, |
| 100 | - | fs.mu.Lock() | 121 | + | controls *[]*gtk.Button, |
| 101 | - | if fs.destroyed { | 122 | + | ) { |
| 102 | - | fs.mu.Unlock() | 123 | + | button := gtk.NewButtonWithLabel(label) |
| 103 | - | return | 124 | + | if button == nil { |
| 104 | - | } | 125 | + | return |
| 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 | } | 126 | } | ||
| 127 | + | button.AddCssClass("favorites-sidebar-tag-filter") | |||
| 128 | + | if selected { | |||
| 129 | + | button.AddCssClass("suggested-action") | |||
| 130 | + | } | |||
| 131 | + | callback := func(_ gtk.Button) { action() } | |||
| 132 | + | *callbacks = append(*callbacks, callback) | |||
| 133 | + | button.ConnectClicked(&callback) | |||
| 134 | + | box.Append(&button.Widget) | |||
| 135 | + | *controls = append(*controls, button) | |||
| 136 | + | } | |||
| 115 | 137 | ||||
| 116 | - | for _, tag := range tags { | 138 | + | func (fs *FavoritesSidebar) clearTagFilters() { |
| 117 | - | if tag == nil { | 139 | + | if fs == nil { |
| 118 | - | continue | 140 | + | return |
| 119 | - | } | 141 | + | } |
| 120 | - | t := tag | 142 | + | fs.mu.Lock() |
| 121 | - | button := gtk.NewButtonWithLabel(t.Name) | 143 | + | if fs.destroyed { |
| 122 | - | if button == nil { | 144 | + | fs.mu.Unlock() |
| 123 | - | continue | 145 | + | return |
| 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 | } | 146 | } | ||
| 147 | + | fs.selectedTagIDs = make(map[entity.TagID]struct{}) | |||
| 148 | + | fs.rebuildDisplayRowsLocked() | |||
| 149 | + | fs.mu.Unlock() | |||
| 150 | + | fs.renderTags() | |||
| 151 | + | fs.rebuildList() | |||
| 152 | + | } | |||
| 135 | 153 | ||||
| 154 | + | func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) { | |||
| 136 | fs.mu.Lock() | 155 | fs.mu.Lock() | ||
| 137 | if !fs.destroyed { | 156 | if !fs.destroyed { | ||
| 138 | fs.tagCallbacks = callbacks | 157 | fs.tagCallbacks = callbacks | ||
| 158 | + | fs.tagControls = controls | |||
| 139 | } | 159 | } | ||
| 140 | fs.mu.Unlock() | 160 | fs.mu.Unlock() | ||
| 141 | } | 161 | } | ||
| @@ -212,6 +232,7 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) { | |||||
| 212 | if label == nil { | 232 | if label == nil { | ||
| 213 | return | 233 | return | ||
| 214 | } | 234 | } | ||
| 235 | + | label.AddCssClass("sidebar-empty") | |||
| 215 | label.AddCssClass("favorites-sidebar-empty") | 236 | label.AddCssClass("favorites-sidebar-empty") | ||
| 216 | label.SetXalign(0.0) | 237 | label.SetXalign(0.0) | ||
| 217 | row := gtk.NewListBoxRow() | 238 | row := gtk.NewListBoxRow() | ||
| @@ -243,6 +264,7 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow f | |||||
| 243 | if row == nil { | 264 | if row == nil { | ||
| 244 | return | 265 | return | ||
| 245 | } | 266 | } | ||
| 267 | + | row.AddCssClass("sidebar-row") | |||
| 246 | row.AddCssClass("favorites-sidebar-row") | 268 | row.AddCssClass("favorites-sidebar-row") | ||
| 247 | row.SetSelectable(displayRow.Selectable) | 269 | row.SetSelectable(displayRow.Selectable) | ||
| 248 | row.SetActivatable(displayRow.Selectable) | 270 | row.SetActivatable(displayRow.Selectable) | ||
| @@ -257,6 +279,7 @@ func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) { | |||||
| 257 | if title == nil { | 279 | if title == nil { | ||
| 258 | return | 280 | return | ||
| 259 | } | 281 | } | ||
| 282 | + | title.AddCssClass("sidebar-row-title") | |||
| 260 | title.AddCssClass("favorites-sidebar-row-title") | 283 | title.AddCssClass("favorites-sidebar-row-title") | ||
| 261 | title.SetText(safeSidebarString(fav.Title, fav.URL)) | 284 | title.SetText(safeSidebarString(fav.Title, fav.URL)) | ||
| 262 | title.SetXalign(0.0) | 285 | title.SetXalign(0.0) | ||
| @@ -273,6 +296,7 @@ func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) { | |||||
| 273 | sub.SetHexpand(true) | 296 | sub.SetHexpand(true) | ||
| 274 | url := gtk.NewLabel(nil) | 297 | url := gtk.NewLabel(nil) | ||
| 275 | if url != nil { | 298 | if url != nil { | ||
| 299 | + | url.AddCssClass("sidebar-row-subtitle") | |||
| 276 | url.AddCssClass("favorites-sidebar-row-subtitle") | 300 | url.AddCssClass("favorites-sidebar-row-subtitle") | ||
| 277 | url.SetText(readableURL(fav.URL)) | 301 | url.SetText(readableURL(fav.URL)) | ||
| 278 | url.SetXalign(0.0) | 302 | url.SetXalign(0.0) | ||
| @@ -299,6 +323,7 @@ func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) { | |||||
| 299 | if tags == nil { | 323 | if tags == nil { | ||
| 300 | return | 324 | return | ||
| 301 | } | 325 | } | ||
| 326 | + | tags.AddCssClass("favorites-sidebar-row-tags") | |||
| 302 | for _, tag := range favoriteTags { | 327 | for _, tag := range favoriteTags { | ||
| 303 | name := tag.Name | 328 | name := tag.Name | ||
| 304 | label := gtk.NewLabel(&name) | 329 | label := gtk.NewLabel(&name) | ||
A internal/ui/component/favorites_sidebar_tags.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func (fs *FavoritesSidebar) showCreateTagPrompt() { | |
| 12 | + | if fs == nil || fs.tagPromptBox == nil { | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | clearBoxChildren(fs.tagPromptBox) | |
| 16 | + | entry := gtk.NewEntry() | |
| 17 | + | if entry == nil { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | placeholder := "New tag name" | |
| 21 | + | entry.SetPlaceholderText(&placeholder) | |
| 22 | + | entry.AddCssClass("favorites-sidebar-tag-name") | |
| 23 | + | fs.trackTextInputFocus(&entry.Widget) | |
| 24 | + | entry.SetHexpand(true) | |
| 25 | + | activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) } | |
| 26 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, activateCb) | |
| 27 | + | entry.ConnectActivate(&activateCb) | |
| 28 | + | ||
| 29 | + | save := gtk.NewButtonWithLabel("Create") | |
| 30 | + | if save == nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | save.AddCssClass("favorites-sidebar-tag-create") | |
| 34 | + | cb := func(_ gtk.Button) { fs.createTag(entry.GetText()) } | |
| 35 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |
| 36 | + | save.ConnectClicked(&cb) | |
| 37 | + | ||
| 38 | + | fs.tagNameEntry = entry | |
| 39 | + | fs.tagPromptSaveBtn = save | |
| 40 | + | fs.tagPromptBox.Append(&entry.Widget) | |
| 41 | + | fs.tagPromptBox.Append(&save.Widget) | |
| 42 | + | fs.tagPromptBox.SetVisible(true) | |
| 43 | + | fs.mu.Lock() | |
| 44 | + | if !fs.destroyed { | |
| 45 | + | fs.mode = favoritesSidebarModeCreateTag | |
| 46 | + | } | |
| 47 | + | fs.mu.Unlock() | |
| 48 | + | entry.GrabFocus() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (fs *FavoritesSidebar) submitTagName(name string) bool { | |
| 52 | + | return fs.createTag(name) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (fs *FavoritesSidebar) createTag(name string) bool { | |
| 56 | + | if fs == nil { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | name = strings.TrimSpace(name) | |
| 60 | + | if name == "" { | |
| 61 | + | fs.setNotice("Tag name is required") | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | fs.mu.RLock() | |
| 65 | + | uc, ctx := fs.favoritesUC, fs.ctx | |
| 66 | + | fs.mu.RUnlock() | |
| 67 | + | if uc == nil { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | if _, err := uc.AddTag(ctx, name, ""); err != nil { | |
| 71 | + | fs.setNotice(err.Error()) | |
| 72 | + | return true | |
| 73 | + | } | |
| 74 | + | fs.hideTagPrompt() | |
| 75 | + | fs.startLoad() | |
| 76 | + | return true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (fs *FavoritesSidebar) showTagBindingPicker() bool { | |
| 80 | + | if fs == nil || fs.tagPromptBox == nil { | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | fav := fs.selectedFavorite() | |
| 84 | + | if fav == nil { | |
| 85 | + | fs.setNotice("Select a favorite to manage tags") | |
| 86 | + | return true | |
| 87 | + | } | |
| 88 | + | fs.mu.RLock() | |
| 89 | + | tags := append([]*entity.Tag(nil), fs.allTags...) | |
| 90 | + | fs.mu.RUnlock() | |
| 91 | + | clearBoxChildren(fs.tagPromptBox) | |
| 92 | + | for _, tag := range tags { | |
| 93 | + | if tag == nil { | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | t := *tag | |
| 97 | + | label := t.Name | |
| 98 | + | if favoriteHasTag(fav, t.ID) { | |
| 99 | + | label = "✓ " + label | |
| 100 | + | } | |
| 101 | + | button := gtk.NewButtonWithLabel(label) | |
| 102 | + | if button == nil { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | button.AddCssClass("favorites-sidebar-tag-binding") | |
| 106 | + | cb := func(_ gtk.Button) { fs.toggleFavoriteTag(fav.ID, t.ID) } | |
| 107 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |
| 108 | + | button.ConnectClicked(&cb) | |
| 109 | + | fs.tagPromptBox.Append(&button.Widget) | |
| 110 | + | } | |
| 111 | + | fs.tagNameEntry = nil | |
| 112 | + | fs.tagPromptSaveBtn = nil | |
| 113 | + | fs.tagPromptBox.SetVisible(true) | |
| 114 | + | fs.mu.Lock() | |
| 115 | + | if !fs.destroyed { | |
| 116 | + | fs.mode = favoritesSidebarModeBindTag | |
| 117 | + | } | |
| 118 | + | fs.mu.Unlock() | |
| 119 | + | return true | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (fs *FavoritesSidebar) hideTagPrompt() { | |
| 123 | + | if fs == nil || fs.tagPromptBox == nil { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | clearBoxChildren(fs.tagPromptBox) | |
| 127 | + | fs.tagPromptBox.SetVisible(false) | |
| 128 | + | fs.tagNameEntry = nil | |
| 129 | + | fs.tagPromptSaveBtn = nil | |
| 130 | + | fs.mu.Lock() | |
| 131 | + | if !fs.destroyed && (fs.mode == favoritesSidebarModeCreateTag || fs.mode == favoritesSidebarModeBindTag) { | |
| 132 | + | fs.mode = favoritesSidebarModeNone | |
| 133 | + | } | |
| 134 | + | fs.mu.Unlock() | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (fs *FavoritesSidebar) toggleFavoriteTag(favoriteID entity.FavoriteID, tagID entity.TagID) { | |
| 138 | + | fs.mu.RLock() | |
| 139 | + | uc, ctx := fs.favoritesUC, fs.ctx | |
| 140 | + | var has bool | |
| 141 | + | for _, fav := range fs.allFavorites { | |
| 142 | + | if fav == nil || fav.ID != favoriteID { | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | has = favoriteHasTag(fav, tagID) | |
| 146 | + | break | |
| 147 | + | } | |
| 148 | + | fs.mu.RUnlock() | |
| 149 | + | if uc == nil { | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | var err error | |
| 153 | + | if has { | |
| 154 | + | err = uc.UntagFavorite(ctx, favoriteID, tagID) | |
| 155 | + | } else { | |
| 156 | + | err = uc.TagFavorite(ctx, favoriteID, tagID) | |
| 157 | + | } | |
| 158 | + | if err != nil { | |
| 159 | + | fs.setNotice(err.Error()) | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | fs.hideTagPrompt() | |
| 163 | + | fs.startLoad() | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool { | |
| 167 | + | if fav == nil { | |
| 168 | + | return false | |
| 169 | + | } | |
| 170 | + | for _, tag := range fav.Tags { | |
| 171 | + | if tag.ID == tagID { | |
| 172 | + | return true | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return false | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (fs *FavoritesSidebar) focusTagControl(index int) { | |
| 179 | + | if fs == nil || index < 0 || index >= len(fs.tagControls) || fs.tagControls[index] == nil { | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | fs.mu.Lock() | |
| 183 | + | if !fs.destroyed { | |
| 184 | + | fs.focusZone = favoritesSidebarFocusTags | |
| 185 | + | } | |
| 186 | + | fs.mu.Unlock() | |
| 187 | + | fs.tagControls[index].GrabFocus() | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (fs *FavoritesSidebar) focusedTagControlIndex() int { | |
| 191 | + | if fs == nil { | |
| 192 | + | return -1 | |
| 193 | + | } | |
| 194 | + | for index, control := range fs.tagControls { | |
| 195 | + | if control != nil && control.HasFocus() { | |
| 196 | + | return index | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return -1 | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (fs *FavoritesSidebar) createTagFromFocusedEntry() bool { | |
| 203 | + | if fs == nil || fs.tagNameEntry == nil || !fs.tagNameEntry.HasFocus() { | |
| 204 | + | return false | |
| 205 | + | } | |
| 206 | + | return fs.createTag(fs.tagNameEntry.GetText()) | |
| 207 | + | } | |
| @@ -0,0 +1,207 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func (fs *FavoritesSidebar) showCreateTagPrompt() { | |||
| 12 | + | if fs == nil || fs.tagPromptBox == nil { | |||
| 13 | + | return | |||
| 14 | + | } | |||
| 15 | + | clearBoxChildren(fs.tagPromptBox) | |||
| 16 | + | entry := gtk.NewEntry() | |||
| 17 | + | if entry == nil { | |||
| 18 | + | return | |||
| 19 | + | } | |||
| 20 | + | placeholder := "New tag name" | |||
| 21 | + | entry.SetPlaceholderText(&placeholder) | |||
| 22 | + | entry.AddCssClass("favorites-sidebar-tag-name") | |||
| 23 | + | fs.trackTextInputFocus(&entry.Widget) | |||
| 24 | + | entry.SetHexpand(true) | |||
| 25 | + | activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) } | |||
| 26 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, activateCb) | |||
| 27 | + | entry.ConnectActivate(&activateCb) | |||
| 28 | + | ||||
| 29 | + | save := gtk.NewButtonWithLabel("Create") | |||
| 30 | + | if save == nil { | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | save.AddCssClass("favorites-sidebar-tag-create") | |||
| 34 | + | cb := func(_ gtk.Button) { fs.createTag(entry.GetText()) } | |||
| 35 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |||
| 36 | + | save.ConnectClicked(&cb) | |||
| 37 | + | ||||
| 38 | + | fs.tagNameEntry = entry | |||
| 39 | + | fs.tagPromptSaveBtn = save | |||
| 40 | + | fs.tagPromptBox.Append(&entry.Widget) | |||
| 41 | + | fs.tagPromptBox.Append(&save.Widget) | |||
| 42 | + | fs.tagPromptBox.SetVisible(true) | |||
| 43 | + | fs.mu.Lock() | |||
| 44 | + | if !fs.destroyed { | |||
| 45 | + | fs.mode = favoritesSidebarModeCreateTag | |||
| 46 | + | } | |||
| 47 | + | fs.mu.Unlock() | |||
| 48 | + | entry.GrabFocus() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (fs *FavoritesSidebar) submitTagName(name string) bool { | |||
| 52 | + | return fs.createTag(name) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (fs *FavoritesSidebar) createTag(name string) bool { | |||
| 56 | + | if fs == nil { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | name = strings.TrimSpace(name) | |||
| 60 | + | if name == "" { | |||
| 61 | + | fs.setNotice("Tag name is required") | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | fs.mu.RLock() | |||
| 65 | + | uc, ctx := fs.favoritesUC, fs.ctx | |||
| 66 | + | fs.mu.RUnlock() | |||
| 67 | + | if uc == nil { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | if _, err := uc.AddTag(ctx, name, ""); err != nil { | |||
| 71 | + | fs.setNotice(err.Error()) | |||
| 72 | + | return true | |||
| 73 | + | } | |||
| 74 | + | fs.hideTagPrompt() | |||
| 75 | + | fs.startLoad() | |||
| 76 | + | return true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (fs *FavoritesSidebar) showTagBindingPicker() bool { | |||
| 80 | + | if fs == nil || fs.tagPromptBox == nil { | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | fav := fs.selectedFavorite() | |||
| 84 | + | if fav == nil { | |||
| 85 | + | fs.setNotice("Select a favorite to manage tags") | |||
| 86 | + | return true | |||
| 87 | + | } | |||
| 88 | + | fs.mu.RLock() | |||
| 89 | + | tags := append([]*entity.Tag(nil), fs.allTags...) | |||
| 90 | + | fs.mu.RUnlock() | |||
| 91 | + | clearBoxChildren(fs.tagPromptBox) | |||
| 92 | + | for _, tag := range tags { | |||
| 93 | + | if tag == nil { | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | t := *tag | |||
| 97 | + | label := t.Name | |||
| 98 | + | if favoriteHasTag(fav, t.ID) { | |||
| 99 | + | label = "✓ " + label | |||
| 100 | + | } | |||
| 101 | + | button := gtk.NewButtonWithLabel(label) | |||
| 102 | + | if button == nil { | |||
| 103 | + | continue | |||
| 104 | + | } | |||
| 105 | + | button.AddCssClass("favorites-sidebar-tag-binding") | |||
| 106 | + | cb := func(_ gtk.Button) { fs.toggleFavoriteTag(fav.ID, t.ID) } | |||
| 107 | + | fs.retainedCallbacks = append(fs.retainedCallbacks, cb) | |||
| 108 | + | button.ConnectClicked(&cb) | |||
| 109 | + | fs.tagPromptBox.Append(&button.Widget) | |||
| 110 | + | } | |||
| 111 | + | fs.tagNameEntry = nil | |||
| 112 | + | fs.tagPromptSaveBtn = nil | |||
| 113 | + | fs.tagPromptBox.SetVisible(true) | |||
| 114 | + | fs.mu.Lock() | |||
| 115 | + | if !fs.destroyed { | |||
| 116 | + | fs.mode = favoritesSidebarModeBindTag | |||
| 117 | + | } | |||
| 118 | + | fs.mu.Unlock() | |||
| 119 | + | return true | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (fs *FavoritesSidebar) hideTagPrompt() { | |||
| 123 | + | if fs == nil || fs.tagPromptBox == nil { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | clearBoxChildren(fs.tagPromptBox) | |||
| 127 | + | fs.tagPromptBox.SetVisible(false) | |||
| 128 | + | fs.tagNameEntry = nil | |||
| 129 | + | fs.tagPromptSaveBtn = nil | |||
| 130 | + | fs.mu.Lock() | |||
| 131 | + | if !fs.destroyed && (fs.mode == favoritesSidebarModeCreateTag || fs.mode == favoritesSidebarModeBindTag) { | |||
| 132 | + | fs.mode = favoritesSidebarModeNone | |||
| 133 | + | } | |||
| 134 | + | fs.mu.Unlock() | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (fs *FavoritesSidebar) toggleFavoriteTag(favoriteID entity.FavoriteID, tagID entity.TagID) { | |||
| 138 | + | fs.mu.RLock() | |||
| 139 | + | uc, ctx := fs.favoritesUC, fs.ctx | |||
| 140 | + | var has bool | |||
| 141 | + | for _, fav := range fs.allFavorites { | |||
| 142 | + | if fav == nil || fav.ID != favoriteID { | |||
| 143 | + | continue | |||
| 144 | + | } | |||
| 145 | + | has = favoriteHasTag(fav, tagID) | |||
| 146 | + | break | |||
| 147 | + | } | |||
| 148 | + | fs.mu.RUnlock() | |||
| 149 | + | if uc == nil { | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | var err error | |||
| 153 | + | if has { | |||
| 154 | + | err = uc.UntagFavorite(ctx, favoriteID, tagID) | |||
| 155 | + | } else { | |||
| 156 | + | err = uc.TagFavorite(ctx, favoriteID, tagID) | |||
| 157 | + | } | |||
| 158 | + | if err != nil { | |||
| 159 | + | fs.setNotice(err.Error()) | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | fs.hideTagPrompt() | |||
| 163 | + | fs.startLoad() | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool { | |||
| 167 | + | if fav == nil { | |||
| 168 | + | return false | |||
| 169 | + | } | |||
| 170 | + | for _, tag := range fav.Tags { | |||
| 171 | + | if tag.ID == tagID { | |||
| 172 | + | return true | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | return false | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (fs *FavoritesSidebar) focusTagControl(index int) { | |||
| 179 | + | if fs == nil || index < 0 || index >= len(fs.tagControls) || fs.tagControls[index] == nil { | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | fs.mu.Lock() | |||
| 183 | + | if !fs.destroyed { | |||
| 184 | + | fs.focusZone = favoritesSidebarFocusTags | |||
| 185 | + | } | |||
| 186 | + | fs.mu.Unlock() | |||
| 187 | + | fs.tagControls[index].GrabFocus() | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (fs *FavoritesSidebar) focusedTagControlIndex() int { | |||
| 191 | + | if fs == nil { | |||
| 192 | + | return -1 | |||
| 193 | + | } | |||
| 194 | + | for index, control := range fs.tagControls { | |||
| 195 | + | if control != nil && control.HasFocus() { | |||
| 196 | + | return index | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | return -1 | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (fs *FavoritesSidebar) createTagFromFocusedEntry() bool { | |||
| 203 | + | if fs == nil || fs.tagNameEntry == nil || !fs.tagNameEntry.HasFocus() { | |||
| 204 | + | return false | |||
| 205 | + | } | |||
| 206 | + | return fs.createTag(fs.tagNameEntry.GetText()) | |||
| 207 | + | } | |||
M internal/ui/component/favorites_sidebar_test.go +143 -25
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| 16 | 17 | ) | |
| @@ -34,6 +35,10 @@ type fakeFavoritesSidebarUC struct { | |||
| 34 | 35 | favID entity.FavoriteID | |
| 35 | 36 | tagID entity.TagID | |
| 36 | 37 | } | |
| 38 | + | createdTags []struct { | |
| 39 | + | name string | |
| 40 | + | color string | |
| 41 | + | } | |
| 37 | 42 | err error | |
| 38 | 43 | errOnSetShortcut error | |
| 39 | 44 | getAllCalled chan struct{} | |
| @@ -51,6 +56,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er | |||
| 51 | 56 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | |
| 52 | 57 | return f.tags, f.err | |
| 53 | 58 | } | |
| 59 | + | func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) { | |
| 60 | + | f.createdTags = append(f.createdTags, struct { | |
| 61 | + | name string | |
| 62 | + | color string | |
| 63 | + | }{name: name, color: color}) | |
| 64 | + | if f.err != nil { | |
| 65 | + | return nil, f.err | |
| 66 | + | } | |
| 67 | + | return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil | |
| 68 | + | } | |
| 54 | 69 | func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 55 | 70 | f.addInputs = append(f.addInputs, input) | |
| 56 | 71 | if f.err != nil { | |
| @@ -340,20 +355,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||
| 340 | 355 | fs.favoritesUC = uc | |
| 341 | 356 | fs.formURL = "https://add.test" | |
| 342 | 357 | fs.formTitle = "Added" | |
| 343 | - | fs.formTags = "1, 2" | |
| 358 | + | fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}} | |
| 344 | 359 | fs.formShortcut = "3" | |
| 345 | 360 | fs.mode = favoritesSidebarModeAdd | |
| 346 | 361 | ||
| 347 | 362 | assert.True(t, fs.submitForm()) | |
| 348 | 363 | require.Len(t, uc.addInputs, 1) | |
| 349 | - | assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0]) | |
| 364 | + | assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags) | |
| 350 | 365 | require.Len(t, uc.shortcuts, 1) | |
| 351 | 366 | assert.Equal(t, 3, *uc.shortcuts[0].key) | |
| 352 | 367 | ||
| 353 | 368 | fs.mode = favoritesSidebarModeEdit | |
| 354 | 369 | fs.editingID = 7 | |
| 355 | 370 | fs.formTitle = "Edited" | |
| 356 | - | fs.formTags = "999" | |
| 357 | 371 | fs.formShortcut = "4" | |
| 358 | 372 | assert.True(t, fs.submitForm()) | |
| 359 | 373 | require.Len(t, uc.updateInputs, 1) | |
| @@ -372,23 +386,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||
| 372 | 386 | assert.Nil(t, uc.updateInputs[1].ShortcutKey) | |
| 373 | 387 | } | |
| 374 | 388 | ||
| 375 | - | func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | |
| 389 | + | func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) { | |
| 376 | 390 | uc := &fakeFavoritesSidebarUC{} | |
| 377 | 391 | fs := newFavoritesSidebarHarness(nil, nil) | |
| 378 | 392 | fs.favoritesUC = uc | |
| 379 | 393 | fs.formURL = "https://add.test" | |
| 380 | 394 | fs.formTitle = "Added" | |
| 381 | - | fs.formTags = "1, nope" | |
| 395 | + | fs.formShortcut = "10" | |
| 382 | 396 | fs.mode = favoritesSidebarModeAdd | |
| 383 | 397 | ||
| 384 | - | assert.True(t, fs.submitForm()) | |
| 385 | - | assert.Empty(t, uc.addInputs) | |
| 386 | - | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| 387 | - | assert.Contains(t, fs.notice, "invalid tag ID") | |
| 388 | - | assert.Equal(t, "1, nope", fs.formTags) | |
| 389 | - | ||
| 390 | - | fs.formTags = "1" | |
| 391 | - | fs.formShortcut = "10" | |
| 392 | 398 | assert.True(t, fs.submitForm()) | |
| 393 | 399 | assert.Empty(t, uc.addInputs) | |
| 394 | 400 | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |
| @@ -396,6 +402,48 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | |||
| 396 | 402 | assert.Equal(t, "10", fs.formShortcut) | |
| 397 | 403 | } | |
| 398 | 404 | ||
| 405 | + | func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) { | |
| 406 | + | dev := &entity.Tag{ID: 10, Name: "dev"} | |
| 407 | + | news := &entity.Tag{ID: 11, Name: "news"} | |
| 408 | + | reading := &entity.Tag{ID: 12, Name: "reading"} | |
| 409 | + | selected := map[entity.TagID]struct{}{dev.ID: {}} | |
| 410 | + | ||
| 411 | + | assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "")) | |
| 412 | + | assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read")) | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) { | |
| 416 | + | dev := entity.Tag{ID: 10, Name: "dev"} | |
| 417 | + | news := entity.Tag{ID: 11, Name: "news"} | |
| 418 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |
| 419 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |
| 420 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 421 | + | fs.allFavorites = []*entity.Favorite{fav} | |
| 422 | + | fs.formTagIDs = tagIDSet(fav.Tags) | |
| 423 | + | ||
| 424 | + | fs.toggleFormTag(news.ID) | |
| 425 | + | assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot()) | |
| 426 | + | assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot())) | |
| 427 | + | require.Len(t, uc.tagged, 1) | |
| 428 | + | assert.Equal(t, news.ID, uc.tagged[0].tagID) | |
| 429 | + | ||
| 430 | + | fs.toggleFormTag(dev.ID) | |
| 431 | + | assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot())) | |
| 432 | + | require.Len(t, uc.untagged, 1) | |
| 433 | + | assert.Equal(t, dev.ID, uc.untagged[0].tagID) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func TestFavoritesSidebarDirectionalFocusSkipsTagControls(t *testing.T) { | |
| 437 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 438 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}} | |
| 439 | + | fs.focusZone = favoritesSidebarFocusSearch | |
| 440 | + | ||
| 441 | + | assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down))) | |
| 442 | + | assert.Equal(t, favoritesSidebarFocusList, fs.focusZone) | |
| 443 | + | assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up))) | |
| 444 | + | assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone) | |
| 445 | + | } | |
| 446 | + | ||
| 399 | 447 | func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { | |
| 400 | 448 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} | |
| 401 | 449 | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) | |
| @@ -436,7 +484,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||
| 436 | 484 | fs.mode = favoritesSidebarModeEdit | |
| 437 | 485 | fs.editingID = 7 | |
| 438 | 486 | fs.formTitle = "Edited" | |
| 439 | - | fs.formTags = "999" | |
| 440 | 487 | ||
| 441 | 488 | assert.True(t, fs.submitForm()) | |
| 442 | 489 | require.Len(t, uc.updateInputs, 1) | |
| @@ -445,6 +492,86 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||
| 445 | 492 | assert.Empty(t, uc.untagged) | |
| 446 | 493 | } | |
| 447 | 494 | ||
| 495 | + | func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) { | |
| 496 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 497 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 498 | + | ||
| 499 | + | assert.True(t, fs.createTag(" reading ")) | |
| 500 | + | require.Len(t, uc.createdTags, 1) | |
| 501 | + | assert.Equal(t, "reading", uc.createdTags[0].name) | |
| 502 | + | assert.Empty(t, uc.createdTags[0].color) | |
| 503 | + | ||
| 504 | + | assert.True(t, fs.createTag(" \t ")) | |
| 505 | + | assert.Len(t, uc.createdTags, 1) | |
| 506 | + | assert.Equal(t, "Tag name is required", fs.notice) | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) { | |
| 510 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 511 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 512 | + | ||
| 513 | + | assert.True(t, fs.submitTagName(" work ")) | |
| 514 | + | require.Len(t, uc.createdTags, 1) | |
| 515 | + | assert.Equal(t, "work", uc.createdTags[0].name) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) { | |
| 519 | + | dev := entity.Tag{ID: 10, Name: "dev"} | |
| 520 | + | news := entity.Tag{ID: 11, Name: "news"} | |
| 521 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |
| 522 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |
| 523 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 524 | + | fs.allFavorites = []*entity.Favorite{fav} | |
| 525 | + | ||
| 526 | + | fs.toggleFavoriteTag(fav.ID, dev.ID) | |
| 527 | + | require.Len(t, uc.untagged, 1) | |
| 528 | + | assert.Equal(t, dev.ID, uc.untagged[0].tagID) | |
| 529 | + | fs.toggleFavoriteTag(fav.ID, news.ID) | |
| 530 | + | require.Len(t, uc.tagged, 1) | |
| 531 | + | assert.Equal(t, news.ID, uc.tagged[0].tagID) | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) { | |
| 535 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 536 | + | input := >k.Widget{} | |
| 537 | + | ||
| 538 | + | fs.setTextInputFocused(input) | |
| 539 | + | assert.True(t, fs.inTextEditContext()) | |
| 540 | + | assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e))) | |
| 541 | + | ||
| 542 | + | fs.clearTextInputFocus(input) | |
| 543 | + | assert.False(t, fs.inTextEditContext()) | |
| 544 | + | assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e))) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) { | |
| 548 | + | for _, key := range []uint{ | |
| 549 | + | uint(gdk.KEY_a), | |
| 550 | + | uint(gdk.KEY_e), | |
| 551 | + | uint(gdk.KEY_s), | |
| 552 | + | uint(gdk.KEY_r), | |
| 553 | + | uint(gdk.KEY_c), | |
| 554 | + | uint(gdk.KEY_t), | |
| 555 | + | uint(gdk.KEY_plus), | |
| 556 | + | uint(gdk.KEY_Up), | |
| 557 | + | uint(gdk.KEY_Escape), | |
| 558 | + | uint(gdk.KEY_Return), | |
| 559 | + | } { | |
| 560 | + | assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key) | |
| 561 | + | } | |
| 562 | + | assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab))) | |
| 563 | + | assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab))) | |
| 564 | + | assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e))) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) { | |
| 568 | + | fs := newFavoritesSidebarHarness(nil, nil) | |
| 569 | + | ||
| 570 | + | assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0)) | |
| 571 | + | assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0)) | |
| 572 | + | assert.Equal(t, favoritesSidebarModeNone, fs.mode) | |
| 573 | + | } | |
| 574 | + | ||
| 448 | 575 | func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) { | |
| 449 | 576 | fs := newFavoritesSidebarHarness(nil, nil) | |
| 450 | 577 | fs.searchEntry = nil | |
| @@ -505,23 +632,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { | |||
| 505 | 632 | assert.Contains(t, fs.notice, "failed to set shortcut") | |
| 506 | 633 | } | |
| 507 | 634 | ||
| 508 | - | func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) { | |
| 635 | + | func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) { | |
| 509 | 636 | dev := entity.Tag{ID: 10, Name: "dev"} | |
| 510 | - | news := entity.Tag{ID: 11, Name: "news"} | |
| 511 | 637 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |
| 512 | - | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |
| 638 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev}) | |
| 513 | 639 | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |
| 514 | 640 | fs.allTags = uc.tags | |
| 515 | 641 | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | |
| 516 | 642 | ||
| 517 | - | fs.mode = favoritesSidebarModeTag | |
| 518 | - | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0)) | |
| 519 | - | require.Len(t, uc.untagged, 1) | |
| 520 | - | assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID) | |
| 521 | - | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0)) | |
| 522 | - | require.Len(t, uc.tagged, 1) | |
| 523 | - | assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID) | |
| 524 | - | ||
| 525 | 643 | fs.mode = favoritesSidebarModeShortcut | |
| 526 | 644 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0)) | |
| 527 | 645 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0)) | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | "github.com/bnema/puregotk/v4/gdk" | 12 | "github.com/bnema/puregotk/v4/gdk" | ||
| 13 | "github.com/bnema/puregotk/v4/glib" | 13 | "github.com/bnema/puregotk/v4/glib" | ||
| 14 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 17 | ) | ||
| @@ -34,6 +35,10 @@ type fakeFavoritesSidebarUC struct { | |||||
| 34 | favID entity.FavoriteID | 35 | favID entity.FavoriteID | ||
| 35 | tagID entity.TagID | 36 | tagID entity.TagID | ||
| 36 | } | 37 | } | ||
| 38 | + | createdTags []struct { | |||
| 39 | + | name string | |||
| 40 | + | color string | |||
| 41 | + | } | |||
| 37 | err error | 42 | err error | ||
| 38 | errOnSetShortcut error | 43 | errOnSetShortcut error | ||
| 39 | getAllCalled chan struct{} | 44 | getAllCalled chan struct{} | ||
| @@ -51,6 +56,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er | |||||
| 51 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | 56 | func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) { | ||
| 52 | return f.tags, f.err | 57 | return f.tags, f.err | ||
| 53 | } | 58 | } | ||
| 59 | + | func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) { | |||
| 60 | + | f.createdTags = append(f.createdTags, struct { | |||
| 61 | + | name string | |||
| 62 | + | color string | |||
| 63 | + | }{name: name, color: color}) | |||
| 64 | + | if f.err != nil { | |||
| 65 | + | return nil, f.err | |||
| 66 | + | } | |||
| 67 | + | return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil | |||
| 68 | + | } | |||
| 54 | func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | 69 | func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | ||
| 55 | f.addInputs = append(f.addInputs, input) | 70 | f.addInputs = append(f.addInputs, input) | ||
| 56 | if f.err != nil { | 71 | if f.err != nil { | ||
| @@ -340,20 +355,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||||
| 340 | fs.favoritesUC = uc | 355 | fs.favoritesUC = uc | ||
| 341 | fs.formURL = "https://add.test" | 356 | fs.formURL = "https://add.test" | ||
| 342 | fs.formTitle = "Added" | 357 | fs.formTitle = "Added" | ||
| 343 | - | fs.formTags = "1, 2" | 358 | + | fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}} |
| 344 | fs.formShortcut = "3" | 359 | fs.formShortcut = "3" | ||
| 345 | fs.mode = favoritesSidebarModeAdd | 360 | fs.mode = favoritesSidebarModeAdd | ||
| 346 | 361 | ||||
| 347 | assert.True(t, fs.submitForm()) | 362 | assert.True(t, fs.submitForm()) | ||
| 348 | require.Len(t, uc.addInputs, 1) | 363 | require.Len(t, uc.addInputs, 1) | ||
| 349 | - | assert.Equal(t, dto.FavoriteCreateInput{URL: "https://add.test", Title: "Added", Tags: []entity.TagID{1, 2}}, uc.addInputs[0]) | 364 | + | assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags) |
| 350 | require.Len(t, uc.shortcuts, 1) | 365 | require.Len(t, uc.shortcuts, 1) | ||
| 351 | assert.Equal(t, 3, *uc.shortcuts[0].key) | 366 | assert.Equal(t, 3, *uc.shortcuts[0].key) | ||
| 352 | 367 | ||||
| 353 | fs.mode = favoritesSidebarModeEdit | 368 | fs.mode = favoritesSidebarModeEdit | ||
| 354 | fs.editingID = 7 | 369 | fs.editingID = 7 | ||
| 355 | fs.formTitle = "Edited" | 370 | fs.formTitle = "Edited" | ||
| 356 | - | fs.formTags = "999" | |||
| 357 | fs.formShortcut = "4" | 371 | fs.formShortcut = "4" | ||
| 358 | assert.True(t, fs.submitForm()) | 372 | assert.True(t, fs.submitForm()) | ||
| 359 | require.Len(t, uc.updateInputs, 1) | 373 | require.Len(t, uc.updateInputs, 1) | ||
| @@ -372,23 +386,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) { | |||||
| 372 | assert.Nil(t, uc.updateInputs[1].ShortcutKey) | 386 | assert.Nil(t, uc.updateInputs[1].ShortcutKey) | ||
| 373 | } | 387 | } | ||
| 374 | 388 | ||||
| 375 | - | func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | 389 | + | func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) { |
| 376 | uc := &fakeFavoritesSidebarUC{} | 390 | uc := &fakeFavoritesSidebarUC{} | ||
| 377 | fs := newFavoritesSidebarHarness(nil, nil) | 391 | fs := newFavoritesSidebarHarness(nil, nil) | ||
| 378 | fs.favoritesUC = uc | 392 | fs.favoritesUC = uc | ||
| 379 | fs.formURL = "https://add.test" | 393 | fs.formURL = "https://add.test" | ||
| 380 | fs.formTitle = "Added" | 394 | fs.formTitle = "Added" | ||
| 381 | - | fs.formTags = "1, nope" | 395 | + | fs.formShortcut = "10" |
| 382 | fs.mode = favoritesSidebarModeAdd | 396 | fs.mode = favoritesSidebarModeAdd | ||
| 383 | 397 | ||||
| 384 | - | assert.True(t, fs.submitForm()) | |||
| 385 | - | assert.Empty(t, uc.addInputs) | |||
| 386 | - | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | |||
| 387 | - | assert.Contains(t, fs.notice, "invalid tag ID") | |||
| 388 | - | assert.Equal(t, "1, nope", fs.formTags) | |||
| 389 | - | ||||
| 390 | - | fs.formTags = "1" | |||
| 391 | - | fs.formShortcut = "10" | |||
| 392 | assert.True(t, fs.submitForm()) | 398 | assert.True(t, fs.submitForm()) | ||
| 393 | assert.Empty(t, uc.addInputs) | 399 | assert.Empty(t, uc.addInputs) | ||
| 394 | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | 400 | assert.Equal(t, favoritesSidebarModeAdd, fs.mode) | ||
| @@ -396,6 +402,48 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) { | |||||
| 396 | assert.Equal(t, "10", fs.formShortcut) | 402 | assert.Equal(t, "10", fs.formShortcut) | ||
| 397 | } | 403 | } | ||
| 398 | 404 | ||||
| 405 | + | func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) { | |||
| 406 | + | dev := &entity.Tag{ID: 10, Name: "dev"} | |||
| 407 | + | news := &entity.Tag{ID: 11, Name: "news"} | |||
| 408 | + | reading := &entity.Tag{ID: 12, Name: "reading"} | |||
| 409 | + | selected := map[entity.TagID]struct{}{dev.ID: {}} | |||
| 410 | + | ||||
| 411 | + | assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "")) | |||
| 412 | + | assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read")) | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) { | |||
| 416 | + | dev := entity.Tag{ID: 10, Name: "dev"} | |||
| 417 | + | news := entity.Tag{ID: 11, Name: "news"} | |||
| 418 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |||
| 419 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |||
| 420 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 421 | + | fs.allFavorites = []*entity.Favorite{fav} | |||
| 422 | + | fs.formTagIDs = tagIDSet(fav.Tags) | |||
| 423 | + | ||||
| 424 | + | fs.toggleFormTag(news.ID) | |||
| 425 | + | assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot()) | |||
| 426 | + | assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot())) | |||
| 427 | + | require.Len(t, uc.tagged, 1) | |||
| 428 | + | assert.Equal(t, news.ID, uc.tagged[0].tagID) | |||
| 429 | + | ||||
| 430 | + | fs.toggleFormTag(dev.ID) | |||
| 431 | + | assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot())) | |||
| 432 | + | require.Len(t, uc.untagged, 1) | |||
| 433 | + | assert.Equal(t, dev.ID, uc.untagged[0].tagID) | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func TestFavoritesSidebarDirectionalFocusSkipsTagControls(t *testing.T) { | |||
| 437 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 438 | + | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: 1, Favorite: &entity.Favorite{ID: 1}, Selectable: true}} | |||
| 439 | + | fs.focusZone = favoritesSidebarFocusSearch | |||
| 440 | + | ||||
| 441 | + | assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down))) | |||
| 442 | + | assert.Equal(t, favoritesSidebarFocusList, fs.focusZone) | |||
| 443 | + | assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up))) | |||
| 444 | + | assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 399 | func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { | 447 | func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) { | ||
| 400 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} | 448 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev"} | ||
| 401 | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) | 449 | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil) | ||
| @@ -436,7 +484,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||||
| 436 | fs.mode = favoritesSidebarModeEdit | 484 | fs.mode = favoritesSidebarModeEdit | ||
| 437 | fs.editingID = 7 | 485 | fs.editingID = 7 | ||
| 438 | fs.formTitle = "Edited" | 486 | fs.formTitle = "Edited" | ||
| 439 | - | fs.formTags = "999" | |||
| 440 | 487 | ||||
| 441 | assert.True(t, fs.submitForm()) | 488 | assert.True(t, fs.submitForm()) | ||
| 442 | require.Len(t, uc.updateInputs, 1) | 489 | require.Len(t, uc.updateInputs, 1) | ||
| @@ -445,6 +492,86 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) { | |||||
| 445 | assert.Empty(t, uc.untagged) | 492 | assert.Empty(t, uc.untagged) | ||
| 446 | } | 493 | } | ||
| 447 | 494 | ||||
| 495 | + | func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) { | |||
| 496 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 497 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 498 | + | ||||
| 499 | + | assert.True(t, fs.createTag(" reading ")) | |||
| 500 | + | require.Len(t, uc.createdTags, 1) | |||
| 501 | + | assert.Equal(t, "reading", uc.createdTags[0].name) | |||
| 502 | + | assert.Empty(t, uc.createdTags[0].color) | |||
| 503 | + | ||||
| 504 | + | assert.True(t, fs.createTag(" \t ")) | |||
| 505 | + | assert.Len(t, uc.createdTags, 1) | |||
| 506 | + | assert.Equal(t, "Tag name is required", fs.notice) | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) { | |||
| 510 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 511 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 512 | + | ||||
| 513 | + | assert.True(t, fs.submitTagName(" work ")) | |||
| 514 | + | require.Len(t, uc.createdTags, 1) | |||
| 515 | + | assert.Equal(t, "work", uc.createdTags[0].name) | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) { | |||
| 519 | + | dev := entity.Tag{ID: 10, Name: "dev"} | |||
| 520 | + | news := entity.Tag{ID: 11, Name: "news"} | |||
| 521 | + | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | |||
| 522 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | |||
| 523 | + | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | |||
| 524 | + | fs.allFavorites = []*entity.Favorite{fav} | |||
| 525 | + | ||||
| 526 | + | fs.toggleFavoriteTag(fav.ID, dev.ID) | |||
| 527 | + | require.Len(t, uc.untagged, 1) | |||
| 528 | + | assert.Equal(t, dev.ID, uc.untagged[0].tagID) | |||
| 529 | + | fs.toggleFavoriteTag(fav.ID, news.ID) | |||
| 530 | + | require.Len(t, uc.tagged, 1) | |||
| 531 | + | assert.Equal(t, news.ID, uc.tagged[0].tagID) | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) { | |||
| 535 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 536 | + | input := >k.Widget{} | |||
| 537 | + | ||||
| 538 | + | fs.setTextInputFocused(input) | |||
| 539 | + | assert.True(t, fs.inTextEditContext()) | |||
| 540 | + | assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e))) | |||
| 541 | + | ||||
| 542 | + | fs.clearTextInputFocus(input) | |||
| 543 | + | assert.False(t, fs.inTextEditContext()) | |||
| 544 | + | assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e))) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) { | |||
| 548 | + | for _, key := range []uint{ | |||
| 549 | + | uint(gdk.KEY_a), | |||
| 550 | + | uint(gdk.KEY_e), | |||
| 551 | + | uint(gdk.KEY_s), | |||
| 552 | + | uint(gdk.KEY_r), | |||
| 553 | + | uint(gdk.KEY_c), | |||
| 554 | + | uint(gdk.KEY_t), | |||
| 555 | + | uint(gdk.KEY_plus), | |||
| 556 | + | uint(gdk.KEY_Up), | |||
| 557 | + | uint(gdk.KEY_Escape), | |||
| 558 | + | uint(gdk.KEY_Return), | |||
| 559 | + | } { | |||
| 560 | + | assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key) | |||
| 561 | + | } | |||
| 562 | + | assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab))) | |||
| 563 | + | assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab))) | |||
| 564 | + | assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e))) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) { | |||
| 568 | + | fs := newFavoritesSidebarHarness(nil, nil) | |||
| 569 | + | ||||
| 570 | + | assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0)) | |||
| 571 | + | assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0)) | |||
| 572 | + | assert.Equal(t, favoritesSidebarModeNone, fs.mode) | |||
| 573 | + | } | |||
| 574 | + | ||||
| 448 | func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) { | 575 | func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) { | ||
| 449 | fs := newFavoritesSidebarHarness(nil, nil) | 576 | fs := newFavoritesSidebarHarness(nil, nil) | ||
| 450 | fs.searchEntry = nil | 577 | fs.searchEntry = nil | ||
| @@ -505,23 +632,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { | |||||
| 505 | assert.Contains(t, fs.notice, "failed to set shortcut") | 632 | assert.Contains(t, fs.notice, "failed to set shortcut") | ||
| 506 | } | 633 | } | ||
| 507 | 634 | ||||
| 508 | - | func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) { | 635 | + | func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) { |
| 509 | dev := entity.Tag{ID: 10, Name: "dev"} | 636 | dev := entity.Tag{ID: 10, Name: "dev"} | ||
| 510 | - | news := entity.Tag{ID: 11, Name: "news"} | |||
| 511 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | 637 | fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}} | ||
| 512 | - | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news}) | 638 | + | fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev}) |
| 513 | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | 639 | uc := fs.favoritesUC.(*fakeFavoritesSidebarUC) | ||
| 514 | fs.allTags = uc.tags | 640 | fs.allTags = uc.tags | ||
| 515 | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | 641 | fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}} | ||
| 516 | 642 | ||||
| 517 | - | fs.mode = favoritesSidebarModeTag | |||
| 518 | - | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_1), 0)) | |||
| 519 | - | require.Len(t, uc.untagged, 1) | |||
| 520 | - | assert.Equal(t, entity.TagID(10), uc.untagged[0].tagID) | |||
| 521 | - | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_2), 0)) | |||
| 522 | - | require.Len(t, uc.tagged, 1) | |||
| 523 | - | assert.Equal(t, entity.TagID(11), uc.tagged[0].tagID) | |||
| 524 | - | ||||
| 525 | fs.mode = favoritesSidebarModeShortcut | 643 | fs.mode = favoritesSidebarModeShortcut | ||
| 526 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0)) | 644 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0)) | ||
| 527 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0)) | 645 | assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0)) | ||
M internal/ui/component/history_sidebar_rendering.go +4 -0
| @@ -103,6 +103,7 @@ func (hs *HistorySidebar) showEmptyState(listBox *gtk.ListBox, query string) { | |||
| 103 | 103 | if label == nil { | |
| 104 | 104 | return | |
| 105 | 105 | } | |
| 106 | + | label.AddCssClass("sidebar-empty") | |
| 106 | 107 | label.AddCssClass("history-sidebar-empty") | |
| 107 | 108 | ||
| 108 | 109 | if query != "" { | |
| @@ -163,6 +164,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||
| 163 | 164 | if titleLabel == nil { | |
| 164 | 165 | return | |
| 165 | 166 | } | |
| 167 | + | titleLabel.AddCssClass("sidebar-row-title") | |
| 166 | 168 | titleLabel.AddCssClass("history-sidebar-row-title") | |
| 167 | 169 | titleLabel.SetText(safeSidebarString(entry.Title, entry.URL)) | |
| 168 | 170 | titleLabel.SetXalign(0.0) | |
| @@ -180,6 +182,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||
| 180 | 182 | if urlLabel == nil { | |
| 181 | 183 | return | |
| 182 | 184 | } | |
| 185 | + | urlLabel.AddCssClass("sidebar-row-subtitle") | |
| 183 | 186 | urlLabel.AddCssClass("history-sidebar-row-subtitle") | |
| 184 | 187 | urlLabel.SetText(readableURL(entry.URL)) | |
| 185 | 188 | urlLabel.SetXalign(0.0) | |
| @@ -205,6 +208,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||
| 205 | 208 | if row == nil { | |
| 206 | 209 | return | |
| 207 | 210 | } | |
| 211 | + | row.AddCssClass("sidebar-row") | |
| 208 | 212 | row.AddCssClass("history-sidebar-row") | |
| 209 | 213 | row.SetSelectable(true) | |
| 210 | 214 | row.SetActivatable(true) | |
| @@ -103,6 +103,7 @@ func (hs *HistorySidebar) showEmptyState(listBox *gtk.ListBox, query string) { | |||||
| 103 | if label == nil { | 103 | if label == nil { | ||
| 104 | return | 104 | return | ||
| 105 | } | 105 | } | ||
| 106 | + | label.AddCssClass("sidebar-empty") | |||
| 106 | label.AddCssClass("history-sidebar-empty") | 107 | label.AddCssClass("history-sidebar-empty") | ||
| 107 | 108 | ||||
| 108 | if query != "" { | 109 | if query != "" { | ||
| @@ -163,6 +164,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||||
| 163 | if titleLabel == nil { | 164 | if titleLabel == nil { | ||
| 164 | return | 165 | return | ||
| 165 | } | 166 | } | ||
| 167 | + | titleLabel.AddCssClass("sidebar-row-title") | |||
| 166 | titleLabel.AddCssClass("history-sidebar-row-title") | 168 | titleLabel.AddCssClass("history-sidebar-row-title") | ||
| 167 | titleLabel.SetText(safeSidebarString(entry.Title, entry.URL)) | 169 | titleLabel.SetText(safeSidebarString(entry.Title, entry.URL)) | ||
| 168 | titleLabel.SetXalign(0.0) | 170 | titleLabel.SetXalign(0.0) | ||
| @@ -180,6 +182,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||||
| 180 | if urlLabel == nil { | 182 | if urlLabel == nil { | ||
| 181 | return | 183 | return | ||
| 182 | } | 184 | } | ||
| 185 | + | urlLabel.AddCssClass("sidebar-row-subtitle") | |||
| 183 | urlLabel.AddCssClass("history-sidebar-row-subtitle") | 186 | urlLabel.AddCssClass("history-sidebar-row-subtitle") | ||
| 184 | urlLabel.SetText(readableURL(entry.URL)) | 187 | urlLabel.SetText(readableURL(entry.URL)) | ||
| 185 | urlLabel.SetXalign(0.0) | 188 | urlLabel.SetXalign(0.0) | ||
| @@ -205,6 +208,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His | |||||
| 205 | if row == nil { | 208 | if row == nil { | ||
| 206 | return | 209 | return | ||
| 207 | } | 210 | } | ||
| 211 | + | row.AddCssClass("sidebar-row") | |||
| 208 | row.AddCssClass("history-sidebar-row") | 212 | row.AddCssClass("history-sidebar-row") | ||
| 209 | row.SetSelectable(true) | 213 | row.SetSelectable(true) | ||
| 210 | row.SetActivatable(true) | 214 | row.SetActivatable(true) | ||
M internal/ui/component/history_sidebar_widgets.go +5 -0
| @@ -28,6 +28,7 @@ func (hs *HistorySidebar) initOuterBox() error { | |||
| 28 | 28 | if hs.outerBox == nil { | |
| 29 | 29 | return fmt.Errorf("history sidebar: outer box creation failed") | |
| 30 | 30 | } | |
| 31 | + | hs.outerBox.AddCssClass("sidebar-outer") | |
| 31 | 32 | hs.outerBox.AddCssClass("history-sidebar-outer") | |
| 32 | 33 | hs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | |
| 33 | 34 | hs.outerBox.SetHexpand(false) | |
| @@ -41,6 +42,7 @@ func (hs *HistorySidebar) initSearchBox() error { | |||
| 41 | 42 | if hs.searchBox == nil { | |
| 42 | 43 | return fmt.Errorf("history sidebar: search box creation failed") | |
| 43 | 44 | } | |
| 45 | + | hs.searchBox.AddCssClass("sidebar-search-box") | |
| 44 | 46 | hs.searchBox.AddCssClass("history-sidebar-search-box") | |
| 45 | 47 | hs.searchBox.SetHexpand(true) | |
| 46 | 48 | ||
| @@ -48,6 +50,7 @@ func (hs *HistorySidebar) initSearchBox() error { | |||
| 48 | 50 | if hs.searchEntry == nil { | |
| 49 | 51 | return fmt.Errorf("history sidebar: search entry creation failed") | |
| 50 | 52 | } | |
| 53 | + | hs.searchEntry.AddCssClass("sidebar-search") | |
| 51 | 54 | hs.searchEntry.AddCssClass("history-sidebar-search") | |
| 52 | 55 | hs.searchEntry.SetHexpand(true) | |
| 53 | 56 | placeholder := "Search history..." | |
| @@ -66,12 +69,14 @@ func (hs *HistorySidebar) initListArea() error { | |||
| 66 | 69 | hs.scrolledWin.SetVexpand(true) | |
| 67 | 70 | hs.scrolledWin.SetHexpand(true) | |
| 68 | 71 | hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 72 | + | hs.scrolledWin.AddCssClass("sidebar-list") | |
| 69 | 73 | hs.scrolledWin.AddCssClass("history-sidebar-groups") | |
| 70 | 74 | ||
| 71 | 75 | hs.listBox = gtk.NewListBox() | |
| 72 | 76 | if hs.listBox == nil { | |
| 73 | 77 | return fmt.Errorf("history sidebar: list box creation failed") | |
| 74 | 78 | } | |
| 79 | + | hs.listBox.AddCssClass("sidebar-list") | |
| 75 | 80 | hs.listBox.AddCssClass("history-sidebar-groups") | |
| 76 | 81 | hs.listBox.SetActivateOnSingleClick(true) | |
| 77 | 82 | hs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| @@ -28,6 +28,7 @@ func (hs *HistorySidebar) initOuterBox() error { | |||||
| 28 | if hs.outerBox == nil { | 28 | if hs.outerBox == nil { | ||
| 29 | return fmt.Errorf("history sidebar: outer box creation failed") | 29 | return fmt.Errorf("history sidebar: outer box creation failed") | ||
| 30 | } | 30 | } | ||
| 31 | + | hs.outerBox.AddCssClass("sidebar-outer") | |||
| 31 | hs.outerBox.AddCssClass("history-sidebar-outer") | 32 | hs.outerBox.AddCssClass("history-sidebar-outer") | ||
| 32 | hs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | 33 | hs.outerBox.SetSizeRequest(sidebarMinWidth, -1) | ||
| 33 | hs.outerBox.SetHexpand(false) | 34 | hs.outerBox.SetHexpand(false) | ||
| @@ -41,6 +42,7 @@ func (hs *HistorySidebar) initSearchBox() error { | |||||
| 41 | if hs.searchBox == nil { | 42 | if hs.searchBox == nil { | ||
| 42 | return fmt.Errorf("history sidebar: search box creation failed") | 43 | return fmt.Errorf("history sidebar: search box creation failed") | ||
| 43 | } | 44 | } | ||
| 45 | + | hs.searchBox.AddCssClass("sidebar-search-box") | |||
| 44 | hs.searchBox.AddCssClass("history-sidebar-search-box") | 46 | hs.searchBox.AddCssClass("history-sidebar-search-box") | ||
| 45 | hs.searchBox.SetHexpand(true) | 47 | hs.searchBox.SetHexpand(true) | ||
| 46 | 48 | ||||
| @@ -48,6 +50,7 @@ func (hs *HistorySidebar) initSearchBox() error { | |||||
| 48 | if hs.searchEntry == nil { | 50 | if hs.searchEntry == nil { | ||
| 49 | return fmt.Errorf("history sidebar: search entry creation failed") | 51 | return fmt.Errorf("history sidebar: search entry creation failed") | ||
| 50 | } | 52 | } | ||
| 53 | + | hs.searchEntry.AddCssClass("sidebar-search") | |||
| 51 | hs.searchEntry.AddCssClass("history-sidebar-search") | 54 | hs.searchEntry.AddCssClass("history-sidebar-search") | ||
| 52 | hs.searchEntry.SetHexpand(true) | 55 | hs.searchEntry.SetHexpand(true) | ||
| 53 | placeholder := "Search history..." | 56 | placeholder := "Search history..." | ||
| @@ -66,12 +69,14 @@ func (hs *HistorySidebar) initListArea() error { | |||||
| 66 | hs.scrolledWin.SetVexpand(true) | 69 | hs.scrolledWin.SetVexpand(true) | ||
| 67 | hs.scrolledWin.SetHexpand(true) | 70 | hs.scrolledWin.SetHexpand(true) | ||
| 68 | hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | 71 | hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | ||
| 72 | + | hs.scrolledWin.AddCssClass("sidebar-list") | |||
| 69 | hs.scrolledWin.AddCssClass("history-sidebar-groups") | 73 | hs.scrolledWin.AddCssClass("history-sidebar-groups") | ||
| 70 | 74 | ||||
| 71 | hs.listBox = gtk.NewListBox() | 75 | hs.listBox = gtk.NewListBox() | ||
| 72 | if hs.listBox == nil { | 76 | if hs.listBox == nil { | ||
| 73 | return fmt.Errorf("history sidebar: list box creation failed") | 77 | return fmt.Errorf("history sidebar: list box creation failed") | ||
| 74 | } | 78 | } | ||
| 79 | + | hs.listBox.AddCssClass("sidebar-list") | |||
| 75 | hs.listBox.AddCssClass("history-sidebar-groups") | 80 | hs.listBox.AddCssClass("history-sidebar-groups") | ||
| 76 | hs.listBox.SetActivateOnSingleClick(true) | 81 | hs.listBox.SetActivateOnSingleClick(true) | ||
| 77 | hs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 82 | hs.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
M internal/ui/theme/css.go +10 -2
| @@ -159,12 +159,20 @@ func GenerateCSSFullWithTiming(p Palette, _ float64, fonts FontConfig, modeColor | |||
| 159 | 159 | sb.WriteString(generateAccentPickerCSS(p)) | |
| 160 | 160 | sb.WriteString("\n") | |
| 161 | 161 | ||
| 162 | - | // History sidebar styling | |
| 163 | - | sb.WriteString(generateHistorySidebarCSS(p)) | |
| 162 | + | appendNativeSidebarCSS(&sb, p) | |
| 164 | 163 | ||
| 165 | 164 | return sb.String() | |
| 166 | 165 | } | |
| 167 | 166 | ||
| 167 | + | func appendNativeSidebarCSS(sb *strings.Builder, p Palette) { | |
| 168 | + | // Native sidebar styling | |
| 169 | + | sb.WriteString(generateSidebarCSS(p)) | |
| 170 | + | sb.WriteString("\n") | |
| 171 | + | sb.WriteString(generateHistorySidebarCSS(p)) | |
| 172 | + | sb.WriteString("\n") | |
| 173 | + | sb.WriteString(generateFavoritesSidebarCSS(p)) | |
| 174 | + | } | |
| 175 | + | ||
| 168 | 176 | // generateTabBarCSS creates tab bar styles. | |
| 169 | 177 | // Uses em units for scalable UI. | |
| 170 | 178 | func generateTabBarCSS(p Palette) string { | |
| @@ -159,12 +159,20 @@ func GenerateCSSFullWithTiming(p Palette, _ float64, fonts FontConfig, modeColor | |||||
| 159 | sb.WriteString(generateAccentPickerCSS(p)) | 159 | sb.WriteString(generateAccentPickerCSS(p)) | ||
| 160 | sb.WriteString("\n") | 160 | sb.WriteString("\n") | ||
| 161 | 161 | ||||
| 162 | - | // History sidebar styling | 162 | + | appendNativeSidebarCSS(&sb, p) |
| 163 | - | sb.WriteString(generateHistorySidebarCSS(p)) | |||
| 164 | 163 | ||||
| 165 | return sb.String() | 164 | return sb.String() | ||
| 166 | } | 165 | } | ||
| 167 | 166 | ||||
| 167 | + | func appendNativeSidebarCSS(sb *strings.Builder, p Palette) { | |||
| 168 | + | // Native sidebar styling | |||
| 169 | + | sb.WriteString(generateSidebarCSS(p)) | |||
| 170 | + | sb.WriteString("\n") | |||
| 171 | + | sb.WriteString(generateHistorySidebarCSS(p)) | |||
| 172 | + | sb.WriteString("\n") | |||
| 173 | + | sb.WriteString(generateFavoritesSidebarCSS(p)) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 168 | // generateTabBarCSS creates tab bar styles. | 176 | // generateTabBarCSS creates tab bar styles. | ||
| 169 | // Uses em units for scalable UI. | 177 | // Uses em units for scalable UI. | ||
| 170 | func generateTabBarCSS(p Palette) string { | 178 | func generateTabBarCSS(p Palette) string { | ||
A internal/ui/theme/favorites_sidebar_css.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | // generateFavoritesSidebarCSS creates GTK4 CSS for favorites-specific controls. | |
| 4 | + | func generateFavoritesSidebarCSS(_ Palette) string { | |
| 5 | + | return `/* ===== Favorites Sidebar Styling ===== */ | |
| 6 | + | ||
| 7 | + | .favorites-sidebar-tags { | |
| 8 | + | padding: 0.25em 0.5em 0.375em; | |
| 9 | + | spacing: 0.25em; | |
| 10 | + | border-bottom: 0.0625em solid var(--border); | |
| 11 | + | background-color: var(--surface-variant); | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | .favorites-sidebar-tag-filter { | |
| 15 | + | min-height: 0; | |
| 16 | + | min-width: 0; | |
| 17 | + | padding: 0.125em 0.375em; | |
| 18 | + | font-size: 0.72em; | |
| 19 | + | font-weight: 500; | |
| 20 | + | color: var(--muted); | |
| 21 | + | background-color: transparent; | |
| 22 | + | border: 0.0625em solid alpha(var(--border), 0.7); | |
| 23 | + | border-radius: 0.25em; | |
| 24 | + | box-shadow: none; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | .favorites-sidebar-tag-filter:hover { | |
| 28 | + | color: var(--text); | |
| 29 | + | background-color: alpha(var(--accent), 0.10); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | .favorites-sidebar-tag-filter.suggested-action { | |
| 33 | + | color: var(--accent); | |
| 34 | + | background-color: alpha(var(--accent), 0.16); | |
| 35 | + | border-color: alpha(var(--accent), 0.5); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | .favorites-sidebar-tag-add { | |
| 39 | + | min-width: 1.4em; | |
| 40 | + | font-weight: 700; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | .favorites-sidebar-tag-prompt { | |
| 44 | + | padding: 0.375em 0.5em; | |
| 45 | + | spacing: 0.375em; | |
| 46 | + | border-bottom: 0.0625em solid var(--border); | |
| 47 | + | background-color: var(--surface-variant); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | .favorites-sidebar-tag-prompt entry, | |
| 51 | + | .favorites-sidebar-tag-prompt button { | |
| 52 | + | min-height: 0; | |
| 53 | + | padding: 0.1875em 0.375em; | |
| 54 | + | font-size: 0.78em; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | .favorites-sidebar-tag-binding { | |
| 58 | + | min-height: 0; | |
| 59 | + | padding: 0.125em 0.375em; | |
| 60 | + | font-size: 0.72em; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | .favorites-sidebar-shortcut-badge, | |
| 64 | + | .favorites-sidebar-tag-chip { | |
| 65 | + | padding: 0.0625em 0.25em; | |
| 66 | + | font-size: 0.66em; | |
| 67 | + | font-weight: 600; | |
| 68 | + | color: var(--muted); | |
| 69 | + | background-color: alpha(var(--surface-variant), 0.9); | |
| 70 | + | border: 0.0625em solid alpha(var(--border), 0.65); | |
| 71 | + | border-radius: 0.2em; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | .favorites-sidebar-row-tags { | |
| 75 | + | margin-top: 0.0625em; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | .favorites-sidebar-shortcut-badge { | |
| 79 | + | color: var(--accent); | |
| 80 | + | border-color: alpha(var(--accent), 0.45); | |
| 81 | + | background-color: alpha(var(--accent), 0.10); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | .favorites-sidebar-form { | |
| 85 | + | padding: 0.5em; | |
| 86 | + | spacing: 0.375em; | |
| 87 | + | border-top: 0.0625em solid var(--border); | |
| 88 | + | background-color: var(--surface-variant); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | .favorites-sidebar-form entry { | |
| 92 | + | min-height: 0; | |
| 93 | + | padding: 0.1875em 0.375em; | |
| 94 | + | font-size: 0.8em; | |
| 95 | + | background-color: var(--surface); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | .favorites-sidebar-form-tag-matches { | |
| 99 | + | spacing: 0.25em; | |
| 100 | + | padding: 0.125em 0; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | .favorites-sidebar-form-tag-match { | |
| 104 | + | min-height: 0; | |
| 105 | + | min-width: 0; | |
| 106 | + | padding: 0.0625em 0.25em; | |
| 107 | + | font-size: 0.7em; | |
| 108 | + | color: var(--muted); | |
| 109 | + | background-color: alpha(var(--surface), 0.8); | |
| 110 | + | border: 0.0625em solid alpha(var(--border), 0.65); | |
| 111 | + | border-radius: 0.2em; | |
| 112 | + | box-shadow: none; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | .favorites-sidebar-form-tag-match:hover { | |
| 116 | + | color: var(--text); | |
| 117 | + | background-color: alpha(var(--accent), 0.12); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | .favorites-sidebar-form button { | |
| 121 | + | min-height: 0; | |
| 122 | + | padding: 0.1875em 0.5em; | |
| 123 | + | font-size: 0.78em; | |
| 124 | + | } | |
| 125 | + | ` | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | // generateFavoritesSidebarCSS creates GTK4 CSS for favorites-specific controls. | |||
| 4 | + | func generateFavoritesSidebarCSS(_ Palette) string { | |||
| 5 | + | return `/* ===== Favorites Sidebar Styling ===== */ | |||
| 6 | + | ||||
| 7 | + | .favorites-sidebar-tags { | |||
| 8 | + | padding: 0.25em 0.5em 0.375em; | |||
| 9 | + | spacing: 0.25em; | |||
| 10 | + | border-bottom: 0.0625em solid var(--border); | |||
| 11 | + | background-color: var(--surface-variant); | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | .favorites-sidebar-tag-filter { | |||
| 15 | + | min-height: 0; | |||
| 16 | + | min-width: 0; | |||
| 17 | + | padding: 0.125em 0.375em; | |||
| 18 | + | font-size: 0.72em; | |||
| 19 | + | font-weight: 500; | |||
| 20 | + | color: var(--muted); | |||
| 21 | + | background-color: transparent; | |||
| 22 | + | border: 0.0625em solid alpha(var(--border), 0.7); | |||
| 23 | + | border-radius: 0.25em; | |||
| 24 | + | box-shadow: none; | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | .favorites-sidebar-tag-filter:hover { | |||
| 28 | + | color: var(--text); | |||
| 29 | + | background-color: alpha(var(--accent), 0.10); | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | .favorites-sidebar-tag-filter.suggested-action { | |||
| 33 | + | color: var(--accent); | |||
| 34 | + | background-color: alpha(var(--accent), 0.16); | |||
| 35 | + | border-color: alpha(var(--accent), 0.5); | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | .favorites-sidebar-tag-add { | |||
| 39 | + | min-width: 1.4em; | |||
| 40 | + | font-weight: 700; | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | .favorites-sidebar-tag-prompt { | |||
| 44 | + | padding: 0.375em 0.5em; | |||
| 45 | + | spacing: 0.375em; | |||
| 46 | + | border-bottom: 0.0625em solid var(--border); | |||
| 47 | + | background-color: var(--surface-variant); | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | .favorites-sidebar-tag-prompt entry, | |||
| 51 | + | .favorites-sidebar-tag-prompt button { | |||
| 52 | + | min-height: 0; | |||
| 53 | + | padding: 0.1875em 0.375em; | |||
| 54 | + | font-size: 0.78em; | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | .favorites-sidebar-tag-binding { | |||
| 58 | + | min-height: 0; | |||
| 59 | + | padding: 0.125em 0.375em; | |||
| 60 | + | font-size: 0.72em; | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | .favorites-sidebar-shortcut-badge, | |||
| 64 | + | .favorites-sidebar-tag-chip { | |||
| 65 | + | padding: 0.0625em 0.25em; | |||
| 66 | + | font-size: 0.66em; | |||
| 67 | + | font-weight: 600; | |||
| 68 | + | color: var(--muted); | |||
| 69 | + | background-color: alpha(var(--surface-variant), 0.9); | |||
| 70 | + | border: 0.0625em solid alpha(var(--border), 0.65); | |||
| 71 | + | border-radius: 0.2em; | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | .favorites-sidebar-row-tags { | |||
| 75 | + | margin-top: 0.0625em; | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | .favorites-sidebar-shortcut-badge { | |||
| 79 | + | color: var(--accent); | |||
| 80 | + | border-color: alpha(var(--accent), 0.45); | |||
| 81 | + | background-color: alpha(var(--accent), 0.10); | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | .favorites-sidebar-form { | |||
| 85 | + | padding: 0.5em; | |||
| 86 | + | spacing: 0.375em; | |||
| 87 | + | border-top: 0.0625em solid var(--border); | |||
| 88 | + | background-color: var(--surface-variant); | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | .favorites-sidebar-form entry { | |||
| 92 | + | min-height: 0; | |||
| 93 | + | padding: 0.1875em 0.375em; | |||
| 94 | + | font-size: 0.8em; | |||
| 95 | + | background-color: var(--surface); | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | .favorites-sidebar-form-tag-matches { | |||
| 99 | + | spacing: 0.25em; | |||
| 100 | + | padding: 0.125em 0; | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | .favorites-sidebar-form-tag-match { | |||
| 104 | + | min-height: 0; | |||
| 105 | + | min-width: 0; | |||
| 106 | + | padding: 0.0625em 0.25em; | |||
| 107 | + | font-size: 0.7em; | |||
| 108 | + | color: var(--muted); | |||
| 109 | + | background-color: alpha(var(--surface), 0.8); | |||
| 110 | + | border: 0.0625em solid alpha(var(--border), 0.65); | |||
| 111 | + | border-radius: 0.2em; | |||
| 112 | + | box-shadow: none; | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | .favorites-sidebar-form-tag-match:hover { | |||
| 116 | + | color: var(--text); | |||
| 117 | + | background-color: alpha(var(--accent), 0.12); | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | .favorites-sidebar-form button { | |||
| 121 | + | min-height: 0; | |||
| 122 | + | padding: 0.1875em 0.5em; | |||
| 123 | + | font-size: 0.78em; | |||
| 124 | + | } | |||
| 125 | + | ` | |||
| 126 | + | } | |||
A internal/ui/theme/favorites_sidebar_css_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestGenerateFavoritesSidebarCSS_ContainsCompactControlStyles(t *testing.T) { | |
| 11 | + | css := generateFavoritesSidebarCSS(DefaultDarkPalette()) | |
| 12 | + | ||
| 13 | + | for _, selector := range []string{ | |
| 14 | + | ".favorites-sidebar-tags", | |
| 15 | + | ".favorites-sidebar-tag-filter", | |
| 16 | + | ".favorites-sidebar-tag-filter.suggested-action", | |
| 17 | + | ".favorites-sidebar-tag-add", | |
| 18 | + | ".favorites-sidebar-tag-prompt", | |
| 19 | + | ".favorites-sidebar-tag-binding", | |
| 20 | + | ".favorites-sidebar-shortcut-badge", | |
| 21 | + | ".favorites-sidebar-tag-chip", | |
| 22 | + | ".favorites-sidebar-form", | |
| 23 | + | ".favorites-sidebar-form-tag-matches", | |
| 24 | + | ".favorites-sidebar-form-tag-match", | |
| 25 | + | } { | |
| 26 | + | assert.Contains(t, css, selector+" {") | |
| 27 | + | } | |
| 28 | + | assert.Contains(t, css, "font-size: 0.66em;", "tags and shortcut badges stay compact") | |
| 29 | + | assert.Contains(t, css, "border-bottom: 0.0625em solid var(--border);", "filters are separated from the list") | |
| 30 | + | assert.Contains(t, css, "border-top: 0.0625em solid var(--border);", "the form is separated from the list") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestGenerateFavoritesSidebarCSS_UsesSharedThemeVariables(t *testing.T) { | |
| 34 | + | css := generateFavoritesSidebarCSS(DefaultDarkPalette()) | |
| 35 | + | ||
| 36 | + | assert.Contains(t, css, "var(--surface)") | |
| 37 | + | assert.Contains(t, css, "var(--surface-variant)") | |
| 38 | + | assert.Contains(t, css, "var(--accent)") | |
| 39 | + | assert.NotContains(t, css, DefaultDarkPalette().Accent) | |
| 40 | + | assert.Equal(t, css, generateFavoritesSidebarCSS(DefaultLightPalette())) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestGenerateFavoritesSidebarCSS_IsRegisteredWithTheFullTheme(t *testing.T) { | |
| 44 | + | css := GenerateCSSFull(DefaultDarkPalette(), 1, DefaultFontConfig(), DefaultModeColors()) | |
| 45 | + | ||
| 46 | + | assert.Contains(t, css, "Native Sidebar Foundation") | |
| 47 | + | assert.Contains(t, css, "Favorites Sidebar Styling") | |
| 48 | + | assert.Contains(t, css, ".favorites-sidebar-tag-filter {") | |
| 49 | + | assert.Contains(t, css, ".favorites-sidebar-form {") | |
| 50 | + | assert.Equal(t, strings.Count(css, "{"), strings.Count(css, "}")) | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestGenerateFavoritesSidebarCSS_ContainsCompactControlStyles(t *testing.T) { | |||
| 11 | + | css := generateFavoritesSidebarCSS(DefaultDarkPalette()) | |||
| 12 | + | ||||
| 13 | + | for _, selector := range []string{ | |||
| 14 | + | ".favorites-sidebar-tags", | |||
| 15 | + | ".favorites-sidebar-tag-filter", | |||
| 16 | + | ".favorites-sidebar-tag-filter.suggested-action", | |||
| 17 | + | ".favorites-sidebar-tag-add", | |||
| 18 | + | ".favorites-sidebar-tag-prompt", | |||
| 19 | + | ".favorites-sidebar-tag-binding", | |||
| 20 | + | ".favorites-sidebar-shortcut-badge", | |||
| 21 | + | ".favorites-sidebar-tag-chip", | |||
| 22 | + | ".favorites-sidebar-form", | |||
| 23 | + | ".favorites-sidebar-form-tag-matches", | |||
| 24 | + | ".favorites-sidebar-form-tag-match", | |||
| 25 | + | } { | |||
| 26 | + | assert.Contains(t, css, selector+" {") | |||
| 27 | + | } | |||
| 28 | + | assert.Contains(t, css, "font-size: 0.66em;", "tags and shortcut badges stay compact") | |||
| 29 | + | assert.Contains(t, css, "border-bottom: 0.0625em solid var(--border);", "filters are separated from the list") | |||
| 30 | + | assert.Contains(t, css, "border-top: 0.0625em solid var(--border);", "the form is separated from the list") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestGenerateFavoritesSidebarCSS_UsesSharedThemeVariables(t *testing.T) { | |||
| 34 | + | css := generateFavoritesSidebarCSS(DefaultDarkPalette()) | |||
| 35 | + | ||||
| 36 | + | assert.Contains(t, css, "var(--surface)") | |||
| 37 | + | assert.Contains(t, css, "var(--surface-variant)") | |||
| 38 | + | assert.Contains(t, css, "var(--accent)") | |||
| 39 | + | assert.NotContains(t, css, DefaultDarkPalette().Accent) | |||
| 40 | + | assert.Equal(t, css, generateFavoritesSidebarCSS(DefaultLightPalette())) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestGenerateFavoritesSidebarCSS_IsRegisteredWithTheFullTheme(t *testing.T) { | |||
| 44 | + | css := GenerateCSSFull(DefaultDarkPalette(), 1, DefaultFontConfig(), DefaultModeColors()) | |||
| 45 | + | ||||
| 46 | + | assert.Contains(t, css, "Native Sidebar Foundation") | |||
| 47 | + | assert.Contains(t, css, "Favorites Sidebar Styling") | |||
| 48 | + | assert.Contains(t, css, ".favorites-sidebar-tag-filter {") | |||
| 49 | + | assert.Contains(t, css, ".favorites-sidebar-form {") | |||
| 50 | + | assert.Equal(t, strings.Count(css, "{"), strings.Count(css, "}")) | |||
| 51 | + | } | |||
M internal/ui/theme/history_sidebar_css.go +0 -58
| @@ -4,26 +4,6 @@ package theme | |||
| 4 | 4 | func generateHistorySidebarCSS(_ Palette) string { | |
| 5 | 5 | return `/* ===== History Sidebar Styling ===== */ | |
| 6 | 6 | ||
| 7 | - | .history-sidebar-outer { | |
| 8 | - | background-color: var(--surface); | |
| 9 | - | border-left: 0.0625em solid var(--border); | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | .history-sidebar-search-box { | |
| 13 | - | padding: 0.375em 0.5em; | |
| 14 | - | border-bottom: 0.0625em solid var(--border); | |
| 15 | - | background-color: var(--surface); | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | .history-sidebar-search { | |
| 19 | - | padding: 0.125em 0.375em; | |
| 20 | - | font-size: 0.85em; | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | .history-sidebar-groups { | |
| 24 | - | background-color: var(--surface); | |
| 25 | - | } | |
| 26 | - | ||
| 27 | 7 | .history-sidebar-group-header { | |
| 28 | 8 | padding: 0.25em 0.625em; | |
| 29 | 9 | padding-top: 0.375em; | |
| @@ -36,37 +16,6 @@ func generateHistorySidebarCSS(_ Palette) string { | |||
| 36 | 16 | border-bottom: 0.0625em solid var(--border); | |
| 37 | 17 | } | |
| 38 | 18 | ||
| 39 | - | .history-sidebar-row { | |
| 40 | - | padding: 0.1875em 0.625em; | |
| 41 | - | min-height: 0; | |
| 42 | - | border-bottom: 0.0625em solid alpha(var(--border), 0.4); | |
| 43 | - | background-color: var(--surface); | |
| 44 | - | transition: background-color 100ms ease; | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | .history-sidebar-row:hover { | |
| 48 | - | background-color: alpha(var(--accent), 0.18); | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | .history-sidebar-row:selected { | |
| 52 | - | background-color: alpha(var(--accent), 0.18); | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | .history-sidebar-row:focus { | |
| 56 | - | background-color: alpha(var(--accent), 0.18); | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | .history-sidebar-row-title { | |
| 60 | - | font-size: 0.82em; | |
| 61 | - | color: var(--text); | |
| 62 | - | font-weight: 500; | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | .history-sidebar-row-subtitle { | |
| 66 | - | font-size: 0.72em; | |
| 67 | - | color: var(--muted); | |
| 68 | - | } | |
| 69 | - | ||
| 70 | 19 | .history-sidebar-row-time { | |
| 71 | 20 | font-size: 0.68em; | |
| 72 | 21 | color: var(--muted); | |
| @@ -74,13 +23,6 @@ func generateHistorySidebarCSS(_ Palette) string { | |||
| 74 | 23 | opacity: 0.75; | |
| 75 | 24 | } | |
| 76 | 25 | ||
| 77 | - | .history-sidebar-empty { | |
| 78 | - | padding: 1.5em 0.75em; | |
| 79 | - | font-size: 0.82em; | |
| 80 | - | color: var(--muted); | |
| 81 | - | font-style: italic; | |
| 82 | - | } | |
| 83 | - | ||
| 84 | 26 | .history-sidebar-loading { | |
| 85 | 27 | padding: 1.5em 0.75em; | |
| 86 | 28 | font-size: 0.82em; | |
| @@ -4,26 +4,6 @@ package theme | |||||
| 4 | func generateHistorySidebarCSS(_ Palette) string { | 4 | func generateHistorySidebarCSS(_ Palette) string { | ||
| 5 | return `/* ===== History Sidebar Styling ===== */ | 5 | return `/* ===== History Sidebar Styling ===== */ | ||
| 6 | 6 | ||||
| 7 | - | .history-sidebar-outer { | |||
| 8 | - | background-color: var(--surface); | |||
| 9 | - | border-left: 0.0625em solid var(--border); | |||
| 10 | - | } | |||
| 11 | - | ||||
| 12 | - | .history-sidebar-search-box { | |||
| 13 | - | padding: 0.375em 0.5em; | |||
| 14 | - | border-bottom: 0.0625em solid var(--border); | |||
| 15 | - | background-color: var(--surface); | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | .history-sidebar-search { | |||
| 19 | - | padding: 0.125em 0.375em; | |||
| 20 | - | font-size: 0.85em; | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | .history-sidebar-groups { | |||
| 24 | - | background-color: var(--surface); | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | .history-sidebar-group-header { | 7 | .history-sidebar-group-header { | ||
| 28 | padding: 0.25em 0.625em; | 8 | padding: 0.25em 0.625em; | ||
| 29 | padding-top: 0.375em; | 9 | padding-top: 0.375em; | ||
| @@ -36,37 +16,6 @@ func generateHistorySidebarCSS(_ Palette) string { | |||||
| 36 | border-bottom: 0.0625em solid var(--border); | 16 | border-bottom: 0.0625em solid var(--border); | ||
| 37 | } | 17 | } | ||
| 38 | 18 | ||||
| 39 | - | .history-sidebar-row { | |||
| 40 | - | padding: 0.1875em 0.625em; | |||
| 41 | - | min-height: 0; | |||
| 42 | - | border-bottom: 0.0625em solid alpha(var(--border), 0.4); | |||
| 43 | - | background-color: var(--surface); | |||
| 44 | - | transition: background-color 100ms ease; | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | .history-sidebar-row:hover { | |||
| 48 | - | background-color: alpha(var(--accent), 0.18); | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | .history-sidebar-row:selected { | |||
| 52 | - | background-color: alpha(var(--accent), 0.18); | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | .history-sidebar-row:focus { | |||
| 56 | - | background-color: alpha(var(--accent), 0.18); | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | .history-sidebar-row-title { | |||
| 60 | - | font-size: 0.82em; | |||
| 61 | - | color: var(--text); | |||
| 62 | - | font-weight: 500; | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | .history-sidebar-row-subtitle { | |||
| 66 | - | font-size: 0.72em; | |||
| 67 | - | color: var(--muted); | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | .history-sidebar-row-time { | 19 | .history-sidebar-row-time { | ||
| 71 | font-size: 0.68em; | 20 | font-size: 0.68em; | ||
| 72 | color: var(--muted); | 21 | color: var(--muted); | ||
| @@ -74,13 +23,6 @@ func generateHistorySidebarCSS(_ Palette) string { | |||||
| 74 | opacity: 0.75; | 23 | opacity: 0.75; | ||
| 75 | } | 24 | } | ||
| 76 | 25 | ||||
| 77 | - | .history-sidebar-empty { | |||
| 78 | - | padding: 1.5em 0.75em; | |||
| 79 | - | font-size: 0.82em; | |||
| 80 | - | color: var(--muted); | |||
| 81 | - | font-style: italic; | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | .history-sidebar-loading { | 26 | .history-sidebar-loading { | ||
| 85 | padding: 1.5em 0.75em; | 27 | padding: 1.5em 0.75em; | ||
| 86 | font-size: 0.82em; | 28 | font-size: 0.82em; | ||
M internal/ui/theme/history_sidebar_css_test.go +58 -70
| @@ -12,19 +12,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) { | |||
| 12 | 12 | css := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 13 | 13 | ||
| 14 | 14 | selectors := []string{ | |
| 15 | - | ".history-sidebar-outer", | |
| 16 | - | ".history-sidebar-search-box", | |
| 17 | - | ".history-sidebar-search", | |
| 18 | - | ".history-sidebar-groups", | |
| 19 | 15 | ".history-sidebar-group-header", | |
| 20 | - | ".history-sidebar-row", | |
| 21 | - | ".history-sidebar-row:hover", | |
| 22 | - | ".history-sidebar-row:selected", | |
| 23 | - | ".history-sidebar-row:focus", | |
| 24 | - | ".history-sidebar-row-title", | |
| 25 | - | ".history-sidebar-row-subtitle", | |
| 26 | 16 | ".history-sidebar-row-time", | |
| 27 | - | ".history-sidebar-empty", | |
| 28 | 17 | ".history-sidebar-loading", | |
| 29 | 18 | } | |
| 30 | 19 | ||
| @@ -34,8 +23,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) { | |||
| 34 | 23 | } | |
| 35 | 24 | } | |
| 36 | 25 | ||
| 37 | - | func TestGenerateHistorySidebarCSS_AccentAlphaInterpolation(t *testing.T) { | |
| 38 | - | css := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 26 | + | func TestGenerateSidebarCSS_AccentAlphaInterpolation(t *testing.T) { | |
| 27 | + | css := generateSidebarCSS(DefaultDarkPalette()) | |
| 39 | 28 | ||
| 40 | 29 | expectedAlpha := "alpha(var(--accent), 0.18)" | |
| 41 | 30 | assert.Contains(t, css, expectedAlpha, | |
| @@ -60,9 +49,9 @@ func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) { | |||
| 60 | 49 | assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette") | |
| 61 | 50 | } | |
| 62 | 51 | ||
| 63 | - | func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) { | |
| 64 | - | darkCSS := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 65 | - | lightCSS := generateHistorySidebarCSS(DefaultLightPalette()) | |
| 52 | + | func TestGenerateSidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) { | |
| 53 | + | darkCSS := generateSidebarCSS(DefaultDarkPalette()) | |
| 54 | + | lightCSS := generateSidebarCSS(DefaultLightPalette()) | |
| 66 | 55 | ||
| 67 | 56 | assert.Equal(t, darkCSS, lightCSS, | |
| 68 | 57 | "history sidebar fragment should be palette-independent when it relies on CSS variables") | |
| @@ -73,12 +62,12 @@ func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t | |||
| 73 | 62 | func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) { | |
| 74 | 63 | fullCSS := GenerateCSS(DefaultDarkPalette()) | |
| 75 | 64 | ||
| 76 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 77 | - | "full CSS must contain history sidebar selectors") | |
| 65 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 66 | + | "full CSS must contain shared sidebar selectors") | |
| 78 | 67 | assert.Contains(t, fullCSS, ".history-sidebar-group-header {", | |
| 79 | 68 | "full CSS must contain group header selector") | |
| 80 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | |
| 81 | - | "full CSS must contain row title selector") | |
| 69 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", | |
| 70 | + | "full CSS must contain shared row title selector") | |
| 82 | 71 | assert.Contains(t, fullCSS, "History Sidebar Styling", | |
| 83 | 72 | "full CSS must contain the history sidebar comment marker") | |
| 84 | 73 | } | |
| @@ -100,7 +89,7 @@ func TestGenerateHistorySidebarCSS_NoEmptyCSSBlocks(t *testing.T) { | |||
| 100 | 89 | } | |
| 101 | 90 | } | |
| 102 | 91 | ||
| 103 | - | func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) { | |
| 92 | + | func TestGenerateSidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) { | |
| 104 | 93 | customPalette := Palette{ | |
| 105 | 94 | Background: "#111111", | |
| 106 | 95 | Surface: "#222222", | |
| @@ -114,22 +103,21 @@ func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) | |||
| 114 | 103 | Destructive: "#cc2222", | |
| 115 | 104 | } | |
| 116 | 105 | ||
| 117 | - | css := generateHistorySidebarCSS(customPalette) | |
| 106 | + | css := generateSidebarCSS(customPalette) | |
| 118 | 107 | ||
| 119 | 108 | assert.Contains(t, css, "alpha(var(--accent), 0.18)", | |
| 120 | 109 | "sidebar CSS should use the shared accent variable") | |
| 121 | 110 | assert.NotContains(t, css, "#ff6600", | |
| 122 | 111 | "sidebar fragment should not inline palette-specific accent values") | |
| 123 | 112 | ||
| 124 | - | assert.Contains(t, css, ".history-sidebar-outer {") | |
| 125 | - | assert.Contains(t, css, ".history-sidebar-row-title {") | |
| 126 | - | assert.Contains(t, css, ".history-sidebar-row-subtitle {") | |
| 127 | - | assert.Contains(t, css, ".history-sidebar-row-time {") | |
| 128 | - | assert.Contains(t, css, ".history-sidebar-empty {") | |
| 113 | + | assert.Contains(t, css, ".sidebar-outer {") | |
| 114 | + | assert.Contains(t, css, ".sidebar-row-title {") | |
| 115 | + | assert.Contains(t, css, ".sidebar-row-subtitle {") | |
| 116 | + | assert.Contains(t, css, ".sidebar-empty {") | |
| 129 | 117 | } | |
| 130 | 118 | ||
| 131 | - | func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) { | |
| 132 | - | css := generateHistorySidebarCSS(DefaultDarkPalette()) | |
| 119 | + | func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) { | |
| 120 | + | css := generateSidebarCSS(DefaultDarkPalette()) | |
| 133 | 121 | ||
| 134 | 122 | assert.Contains(t, css, "transition:", | |
| 135 | 123 | "CSS should include transition property for smooth hover effects") | |
| @@ -149,27 +137,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) { | |||
| 149 | 137 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) { | |
| 150 | 138 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 151 | 139 | ||
| 152 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 153 | - | "GenerateCSSFull must include history sidebar outer selector for dark palette") | |
| 154 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | |
| 155 | - | "GenerateCSSFull must include history sidebar row title selector for dark palette") | |
| 156 | - | assert.Contains(t, fullCSS, ".history-sidebar-search {", | |
| 157 | - | "GenerateCSSFull must include history sidebar search selector for dark palette") | |
| 158 | - | assert.Contains(t, fullCSS, ".history-sidebar-empty {", | |
| 159 | - | "GenerateCSSFull must include history sidebar empty selector for dark palette") | |
| 140 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 141 | + | "GenerateCSSFull must include shared sidebar outer selector for dark palette") | |
| 142 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", | |
| 143 | + | "GenerateCSSFull must include shared sidebar row title selector for dark palette") | |
| 144 | + | assert.Contains(t, fullCSS, ".sidebar-search {", | |
| 145 | + | "GenerateCSSFull must include shared sidebar search selector for dark palette") | |
| 146 | + | assert.Contains(t, fullCSS, ".sidebar-empty {", | |
| 147 | + | "GenerateCSSFull must include shared sidebar empty selector for dark palette") | |
| 160 | 148 | } | |
| 161 | 149 | ||
| 162 | 150 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) { | |
| 163 | 151 | fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 164 | 152 | ||
| 165 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 166 | - | "GenerateCSSFull must include history sidebar outer selector for light palette") | |
| 167 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | |
| 168 | - | "GenerateCSSFull must include history sidebar row title selector for light palette") | |
| 169 | - | assert.Contains(t, fullCSS, ".history-sidebar-search {", | |
| 170 | - | "GenerateCSSFull must include history sidebar search selector for light palette") | |
| 171 | - | assert.Contains(t, fullCSS, ".history-sidebar-empty {", | |
| 172 | - | "GenerateCSSFull must include history sidebar empty selector for light palette") | |
| 153 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 154 | + | "GenerateCSSFull must include shared sidebar outer selector for light palette") | |
| 155 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", | |
| 156 | + | "GenerateCSSFull must include shared sidebar row title selector for light palette") | |
| 157 | + | assert.Contains(t, fullCSS, ".sidebar-search {", | |
| 158 | + | "GenerateCSSFull must include shared sidebar search selector for light palette") | |
| 159 | + | assert.Contains(t, fullCSS, ".sidebar-empty {", | |
| 160 | + | "GenerateCSSFull must include shared sidebar empty selector for light palette") | |
| 173 | 161 | } | |
| 174 | 162 | ||
| 175 | 163 | func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) { | |
| @@ -181,8 +169,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T | |||
| 181 | 169 | lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 182 | 170 | ||
| 183 | 171 | // Both must contain history sidebar selectors. | |
| 184 | - | assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles") | |
| 185 | - | assert.Contains(t, lightCSS, ".history-sidebar-outer {", "light CSS must have sidebar styles") | |
| 172 | + | assert.Contains(t, darkCSS, ".sidebar-outer {", "dark CSS must have shared sidebar styles") | |
| 173 | + | assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles") | |
| 186 | 174 | ||
| 187 | 175 | // The CSS must differ between palettes (different color values). | |
| 188 | 176 | if darkCSS == lightCSS { | |
| @@ -211,14 +199,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t * | |||
| 211 | 199 | ||
| 212 | 200 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors) | |
| 213 | 201 | ||
| 214 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | |
| 215 | - | "GenerateCSSFull with custom scale/fonts must include history sidebar selectors") | |
| 216 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:hover {", | |
| 217 | - | "GenerateCSSFull must include hover state for history rows") | |
| 218 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:selected {", | |
| 219 | - | "GenerateCSSFull must include selected state for history rows") | |
| 220 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:focus {", | |
| 221 | - | "GenerateCSSFull must include focus state for history rows") | |
| 202 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", | |
| 203 | + | "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors") | |
| 204 | + | assert.Contains(t, fullCSS, ".sidebar-row:hover {", | |
| 205 | + | "GenerateCSSFull must include hover state for shared sidebar rows") | |
| 206 | + | assert.Contains(t, fullCSS, ".sidebar-row:selected {", | |
| 207 | + | "GenerateCSSFull must include selected state for shared sidebar rows") | |
| 208 | + | assert.Contains(t, fullCSS, ".sidebar-row:focus {", | |
| 209 | + | "GenerateCSSFull must include focus state for shared sidebar rows") | |
| 222 | 210 | } | |
| 223 | 211 | ||
| 224 | 212 | // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the | |
| @@ -247,21 +235,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) { | |||
| 247 | 235 | lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 248 | 236 | customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | |
| 249 | 237 | ||
| 250 | - | // All three must contain the full set of history sidebar selectors. | |
| 238 | + | // All three must contain the shared foundation and history-specific rules. | |
| 251 | 239 | sidebarSelectors := []string{ | |
| 252 | - | ".history-sidebar-outer", | |
| 253 | - | ".history-sidebar-search-box", | |
| 254 | - | ".history-sidebar-search", | |
| 255 | - | ".history-sidebar-groups", | |
| 240 | + | ".sidebar-outer", | |
| 241 | + | ".sidebar-search-box", | |
| 242 | + | ".sidebar-search", | |
| 243 | + | ".sidebar-list", | |
| 244 | + | ".sidebar-row", | |
| 245 | + | ".sidebar-row:hover", | |
| 246 | + | ".sidebar-row:selected", | |
| 247 | + | ".sidebar-row:focus", | |
| 248 | + | ".sidebar-row-title", | |
| 249 | + | ".sidebar-row-subtitle", | |
| 250 | + | ".sidebar-empty", | |
| 256 | 251 | ".history-sidebar-group-header", | |
| 257 | - | ".history-sidebar-row", | |
| 258 | - | ".history-sidebar-row:hover", | |
| 259 | - | ".history-sidebar-row:selected", | |
| 260 | - | ".history-sidebar-row:focus", | |
| 261 | - | ".history-sidebar-row-title", | |
| 262 | - | ".history-sidebar-row-subtitle", | |
| 263 | 252 | ".history-sidebar-row-time", | |
| 264 | - | ".history-sidebar-empty", | |
| 265 | 253 | ".history-sidebar-loading", | |
| 266 | 254 | } | |
| 267 | 255 | ||
| @@ -324,9 +312,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) { | |||
| 324 | 312 | assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n") | |
| 325 | 313 | } | |
| 326 | 314 | ||
| 327 | - | // History sidebar selectors must be present in both. | |
| 328 | - | assert.Contains(t, cssBefore, ".history-sidebar-row:selected {") | |
| 329 | - | assert.Contains(t, cssAfter, ".history-sidebar-row:selected {") | |
| 315 | + | // Shared sidebar selectors must be present in both. | |
| 316 | + | assert.Contains(t, cssBefore, ".sidebar-row:selected {") | |
| 317 | + | assert.Contains(t, cssAfter, ".sidebar-row:selected {") | |
| 330 | 318 | ||
| 331 | 319 | // The before and after CSS must be different (palette changed). | |
| 332 | 320 | assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches") | |
| @@ -12,19 +12,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) { | |||||
| 12 | css := generateHistorySidebarCSS(DefaultDarkPalette()) | 12 | css := generateHistorySidebarCSS(DefaultDarkPalette()) | ||
| 13 | 13 | ||||
| 14 | selectors := []string{ | 14 | selectors := []string{ | ||
| 15 | - | ".history-sidebar-outer", | |||
| 16 | - | ".history-sidebar-search-box", | |||
| 17 | - | ".history-sidebar-search", | |||
| 18 | - | ".history-sidebar-groups", | |||
| 19 | ".history-sidebar-group-header", | 15 | ".history-sidebar-group-header", | ||
| 20 | - | ".history-sidebar-row", | |||
| 21 | - | ".history-sidebar-row:hover", | |||
| 22 | - | ".history-sidebar-row:selected", | |||
| 23 | - | ".history-sidebar-row:focus", | |||
| 24 | - | ".history-sidebar-row-title", | |||
| 25 | - | ".history-sidebar-row-subtitle", | |||
| 26 | ".history-sidebar-row-time", | 16 | ".history-sidebar-row-time", | ||
| 27 | - | ".history-sidebar-empty", | |||
| 28 | ".history-sidebar-loading", | 17 | ".history-sidebar-loading", | ||
| 29 | } | 18 | } | ||
| 30 | 19 | ||||
| @@ -34,8 +23,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) { | |||||
| 34 | } | 23 | } | ||
| 35 | } | 24 | } | ||
| 36 | 25 | ||||
| 37 | - | func TestGenerateHistorySidebarCSS_AccentAlphaInterpolation(t *testing.T) { | 26 | + | func TestGenerateSidebarCSS_AccentAlphaInterpolation(t *testing.T) { |
| 38 | - | css := generateHistorySidebarCSS(DefaultDarkPalette()) | 27 | + | css := generateSidebarCSS(DefaultDarkPalette()) |
| 39 | 28 | ||||
| 40 | expectedAlpha := "alpha(var(--accent), 0.18)" | 29 | expectedAlpha := "alpha(var(--accent), 0.18)" | ||
| 41 | assert.Contains(t, css, expectedAlpha, | 30 | assert.Contains(t, css, expectedAlpha, | ||
| @@ -60,9 +49,9 @@ func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) { | |||||
| 60 | assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette") | 49 | assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette") | ||
| 61 | } | 50 | } | ||
| 62 | 51 | ||||
| 63 | - | func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) { | 52 | + | func TestGenerateSidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t *testing.T) { |
| 64 | - | darkCSS := generateHistorySidebarCSS(DefaultDarkPalette()) | 53 | + | darkCSS := generateSidebarCSS(DefaultDarkPalette()) |
| 65 | - | lightCSS := generateHistorySidebarCSS(DefaultLightPalette()) | 54 | + | lightCSS := generateSidebarCSS(DefaultLightPalette()) |
| 66 | 55 | ||||
| 67 | assert.Equal(t, darkCSS, lightCSS, | 56 | assert.Equal(t, darkCSS, lightCSS, | ||
| 68 | "history sidebar fragment should be palette-independent when it relies on CSS variables") | 57 | "history sidebar fragment should be palette-independent when it relies on CSS variables") | ||
| @@ -73,12 +62,12 @@ func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t | |||||
| 73 | func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) { | 62 | func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) { | ||
| 74 | fullCSS := GenerateCSS(DefaultDarkPalette()) | 63 | fullCSS := GenerateCSS(DefaultDarkPalette()) | ||
| 75 | 64 | ||||
| 76 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | 65 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", |
| 77 | - | "full CSS must contain history sidebar selectors") | 66 | + | "full CSS must contain shared sidebar selectors") |
| 78 | assert.Contains(t, fullCSS, ".history-sidebar-group-header {", | 67 | assert.Contains(t, fullCSS, ".history-sidebar-group-header {", | ||
| 79 | "full CSS must contain group header selector") | 68 | "full CSS must contain group header selector") | ||
| 80 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | 69 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", |
| 81 | - | "full CSS must contain row title selector") | 70 | + | "full CSS must contain shared row title selector") |
| 82 | assert.Contains(t, fullCSS, "History Sidebar Styling", | 71 | assert.Contains(t, fullCSS, "History Sidebar Styling", | ||
| 83 | "full CSS must contain the history sidebar comment marker") | 72 | "full CSS must contain the history sidebar comment marker") | ||
| 84 | } | 73 | } | ||
| @@ -100,7 +89,7 @@ func TestGenerateHistorySidebarCSS_NoEmptyCSSBlocks(t *testing.T) { | |||||
| 100 | } | 89 | } | ||
| 101 | } | 90 | } | ||
| 102 | 91 | ||||
| 103 | - | func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) { | 92 | + | func TestGenerateSidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) { |
| 104 | customPalette := Palette{ | 93 | customPalette := Palette{ | ||
| 105 | Background: "#111111", | 94 | Background: "#111111", | ||
| 106 | Surface: "#222222", | 95 | Surface: "#222222", | ||
| @@ -114,22 +103,21 @@ func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) | |||||
| 114 | Destructive: "#cc2222", | 103 | Destructive: "#cc2222", | ||
| 115 | } | 104 | } | ||
| 116 | 105 | ||||
| 117 | - | css := generateHistorySidebarCSS(customPalette) | 106 | + | css := generateSidebarCSS(customPalette) |
| 118 | 107 | ||||
| 119 | assert.Contains(t, css, "alpha(var(--accent), 0.18)", | 108 | assert.Contains(t, css, "alpha(var(--accent), 0.18)", | ||
| 120 | "sidebar CSS should use the shared accent variable") | 109 | "sidebar CSS should use the shared accent variable") | ||
| 121 | assert.NotContains(t, css, "#ff6600", | 110 | assert.NotContains(t, css, "#ff6600", | ||
| 122 | "sidebar fragment should not inline palette-specific accent values") | 111 | "sidebar fragment should not inline palette-specific accent values") | ||
| 123 | 112 | ||||
| 124 | - | assert.Contains(t, css, ".history-sidebar-outer {") | 113 | + | assert.Contains(t, css, ".sidebar-outer {") |
| 125 | - | assert.Contains(t, css, ".history-sidebar-row-title {") | 114 | + | assert.Contains(t, css, ".sidebar-row-title {") |
| 126 | - | assert.Contains(t, css, ".history-sidebar-row-subtitle {") | 115 | + | assert.Contains(t, css, ".sidebar-row-subtitle {") |
| 127 | - | assert.Contains(t, css, ".history-sidebar-row-time {") | 116 | + | assert.Contains(t, css, ".sidebar-empty {") |
| 128 | - | assert.Contains(t, css, ".history-sidebar-empty {") | |||
| 129 | } | 117 | } | ||
| 130 | 118 | ||||
| 131 | - | func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) { | 119 | + | func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) { |
| 132 | - | css := generateHistorySidebarCSS(DefaultDarkPalette()) | 120 | + | css := generateSidebarCSS(DefaultDarkPalette()) |
| 133 | 121 | ||||
| 134 | assert.Contains(t, css, "transition:", | 122 | assert.Contains(t, css, "transition:", | ||
| 135 | "CSS should include transition property for smooth hover effects") | 123 | "CSS should include transition property for smooth hover effects") | ||
| @@ -149,27 +137,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) { | |||||
| 149 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) { | 137 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) { | ||
| 150 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | 138 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | ||
| 151 | 139 | ||||
| 152 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | 140 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", |
| 153 | - | "GenerateCSSFull must include history sidebar outer selector for dark palette") | 141 | + | "GenerateCSSFull must include shared sidebar outer selector for dark palette") |
| 154 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | 142 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", |
| 155 | - | "GenerateCSSFull must include history sidebar row title selector for dark palette") | 143 | + | "GenerateCSSFull must include shared sidebar row title selector for dark palette") |
| 156 | - | assert.Contains(t, fullCSS, ".history-sidebar-search {", | 144 | + | assert.Contains(t, fullCSS, ".sidebar-search {", |
| 157 | - | "GenerateCSSFull must include history sidebar search selector for dark palette") | 145 | + | "GenerateCSSFull must include shared sidebar search selector for dark palette") |
| 158 | - | assert.Contains(t, fullCSS, ".history-sidebar-empty {", | 146 | + | assert.Contains(t, fullCSS, ".sidebar-empty {", |
| 159 | - | "GenerateCSSFull must include history sidebar empty selector for dark palette") | 147 | + | "GenerateCSSFull must include shared sidebar empty selector for dark palette") |
| 160 | } | 148 | } | ||
| 161 | 149 | ||||
| 162 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) { | 150 | func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) { | ||
| 163 | fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | 151 | fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | ||
| 164 | 152 | ||||
| 165 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | 153 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", |
| 166 | - | "GenerateCSSFull must include history sidebar outer selector for light palette") | 154 | + | "GenerateCSSFull must include shared sidebar outer selector for light palette") |
| 167 | - | assert.Contains(t, fullCSS, ".history-sidebar-row-title {", | 155 | + | assert.Contains(t, fullCSS, ".sidebar-row-title {", |
| 168 | - | "GenerateCSSFull must include history sidebar row title selector for light palette") | 156 | + | "GenerateCSSFull must include shared sidebar row title selector for light palette") |
| 169 | - | assert.Contains(t, fullCSS, ".history-sidebar-search {", | 157 | + | assert.Contains(t, fullCSS, ".sidebar-search {", |
| 170 | - | "GenerateCSSFull must include history sidebar search selector for light palette") | 158 | + | "GenerateCSSFull must include shared sidebar search selector for light palette") |
| 171 | - | assert.Contains(t, fullCSS, ".history-sidebar-empty {", | 159 | + | assert.Contains(t, fullCSS, ".sidebar-empty {", |
| 172 | - | "GenerateCSSFull must include history sidebar empty selector for light palette") | 160 | + | "GenerateCSSFull must include shared sidebar empty selector for light palette") |
| 173 | } | 161 | } | ||
| 174 | 162 | ||||
| 175 | func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) { | 163 | func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) { | ||
| @@ -181,8 +169,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T | |||||
| 181 | lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | 169 | lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors()) | ||
| 182 | 170 | ||||
| 183 | // Both must contain history sidebar selectors. | 171 | // Both must contain history sidebar selectors. | ||
| 184 | - | assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles") | 172 | + | assert.Contains(t, darkCSS, ".sidebar-outer {", "dark CSS must have shared sidebar styles") |
| 185 | - | assert.Contains(t, lightCSS, ".history-sidebar-outer {", "light CSS must have sidebar styles") | 173 | + | assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles") |
| 186 | 174 | ||||
| 187 | // The CSS must differ between palettes (different color values). | 175 | // The CSS must differ between palettes (different color values). | ||
| 188 | if darkCSS == lightCSS { | 176 | if darkCSS == lightCSS { | ||
| @@ -211,14 +199,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t * | |||||
| 211 | 199 | ||||
| 212 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors) | 200 | fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors) | ||
| 213 | 201 | ||||
| 214 | - | assert.Contains(t, fullCSS, ".history-sidebar-outer {", | 202 | + | assert.Contains(t, fullCSS, ".sidebar-outer {", |
| 215 | - | "GenerateCSSFull with custom scale/fonts must include history sidebar selectors") | 203 | + | "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors") |
| 216 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:hover {", | 204 | + | assert.Contains(t, fullCSS, ".sidebar-row:hover {", |
| 217 | - | "GenerateCSSFull must include hover state for history rows") | 205 | + | "GenerateCSSFull must include hover state for shared sidebar rows") |
| 218 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:selected {", | 206 | + | assert.Contains(t, fullCSS, ".sidebar-row:selected {", |
| 219 | - | "GenerateCSSFull must include selected state for history rows") | 207 | + | "GenerateCSSFull must include selected state for shared sidebar rows") |
| 220 | - | assert.Contains(t, fullCSS, ".history-sidebar-row:focus {", | 208 | + | assert.Contains(t, fullCSS, ".sidebar-row:focus {", |
| 221 | - | "GenerateCSSFull must include focus state for history rows") | 209 | + | "GenerateCSSFull must include focus state for shared sidebar rows") |
| 222 | } | 210 | } | ||
| 223 | 211 | ||||
| 224 | // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the | 212 | // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the | ||
| @@ -247,21 +235,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) { | |||||
| 247 | lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | 235 | lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | ||
| 248 | customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | 236 | customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors()) | ||
| 249 | 237 | ||||
| 250 | - | // All three must contain the full set of history sidebar selectors. | 238 | + | // All three must contain the shared foundation and history-specific rules. |
| 251 | sidebarSelectors := []string{ | 239 | sidebarSelectors := []string{ | ||
| 252 | - | ".history-sidebar-outer", | 240 | + | ".sidebar-outer", |
| 253 | - | ".history-sidebar-search-box", | 241 | + | ".sidebar-search-box", |
| 254 | - | ".history-sidebar-search", | 242 | + | ".sidebar-search", |
| 255 | - | ".history-sidebar-groups", | 243 | + | ".sidebar-list", |
| 244 | + | ".sidebar-row", | |||
| 245 | + | ".sidebar-row:hover", | |||
| 246 | + | ".sidebar-row:selected", | |||
| 247 | + | ".sidebar-row:focus", | |||
| 248 | + | ".sidebar-row-title", | |||
| 249 | + | ".sidebar-row-subtitle", | |||
| 250 | + | ".sidebar-empty", | |||
| 256 | ".history-sidebar-group-header", | 251 | ".history-sidebar-group-header", | ||
| 257 | - | ".history-sidebar-row", | |||
| 258 | - | ".history-sidebar-row:hover", | |||
| 259 | - | ".history-sidebar-row:selected", | |||
| 260 | - | ".history-sidebar-row:focus", | |||
| 261 | - | ".history-sidebar-row-title", | |||
| 262 | - | ".history-sidebar-row-subtitle", | |||
| 263 | ".history-sidebar-row-time", | 252 | ".history-sidebar-row-time", | ||
| 264 | - | ".history-sidebar-empty", | |||
| 265 | ".history-sidebar-loading", | 253 | ".history-sidebar-loading", | ||
| 266 | } | 254 | } | ||
| 267 | 255 | ||||
| @@ -324,9 +312,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) { | |||||
| 324 | assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n") | 312 | assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n") | ||
| 325 | } | 313 | } | ||
| 326 | 314 | ||||
| 327 | - | // History sidebar selectors must be present in both. | 315 | + | // Shared sidebar selectors must be present in both. |
| 328 | - | assert.Contains(t, cssBefore, ".history-sidebar-row:selected {") | 316 | + | assert.Contains(t, cssBefore, ".sidebar-row:selected {") |
| 329 | - | assert.Contains(t, cssAfter, ".history-sidebar-row:selected {") | 317 | + | assert.Contains(t, cssAfter, ".sidebar-row:selected {") |
| 330 | 318 | ||||
| 331 | // The before and after CSS must be different (palette changed). | 319 | // The before and after CSS must be different (palette changed). | ||
| 332 | assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches") | 320 | assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches") | ||
A internal/ui/theme/sidebar_css.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | // generateSidebarCSS creates the shared GTK4 foundation used by native sidebars. | |
| 4 | + | // The history sidebar remains the visual reference; feature-specific rules only | |
| 5 | + | // add the controls unique to each sidebar. | |
| 6 | + | func generateSidebarCSS(_ Palette) string { | |
| 7 | + | return `/* ===== Native Sidebar Foundation ===== */ | |
| 8 | + | ||
| 9 | + | .sidebar-outer { | |
| 10 | + | background-color: var(--surface); | |
| 11 | + | border-left: 0.0625em solid var(--border); | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | .sidebar-search-box { | |
| 15 | + | padding: 0.375em 0.5em; | |
| 16 | + | border-bottom: 0.0625em solid var(--border); | |
| 17 | + | background-color: var(--surface); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | .sidebar-search { | |
| 21 | + | padding: 0.125em 0.375em; | |
| 22 | + | font-size: 0.85em; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | .sidebar-list { | |
| 26 | + | background-color: var(--surface); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | .sidebar-row { | |
| 30 | + | padding: 0.1875em 0.625em; | |
| 31 | + | min-height: 0; | |
| 32 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.4); | |
| 33 | + | background-color: var(--surface); | |
| 34 | + | transition: background-color 100ms ease; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | .sidebar-row:hover { | |
| 38 | + | background-color: alpha(var(--accent), 0.18); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | .sidebar-row:selected { | |
| 42 | + | background-color: alpha(var(--accent), 0.18); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | .sidebar-row:focus { | |
| 46 | + | background-color: alpha(var(--accent), 0.18); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | .sidebar-row-title { | |
| 50 | + | font-size: 0.82em; | |
| 51 | + | color: var(--text); | |
| 52 | + | font-weight: 500; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | .sidebar-row-subtitle { | |
| 56 | + | font-size: 0.72em; | |
| 57 | + | color: var(--muted); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | .sidebar-empty { | |
| 61 | + | padding: 1.5em 0.75em; | |
| 62 | + | font-size: 0.82em; | |
| 63 | + | color: var(--muted); | |
| 64 | + | font-style: italic; | |
| 65 | + | } | |
| 66 | + | ` | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | // generateSidebarCSS creates the shared GTK4 foundation used by native sidebars. | |||
| 4 | + | // The history sidebar remains the visual reference; feature-specific rules only | |||
| 5 | + | // add the controls unique to each sidebar. | |||
| 6 | + | func generateSidebarCSS(_ Palette) string { | |||
| 7 | + | return `/* ===== Native Sidebar Foundation ===== */ | |||
| 8 | + | ||||
| 9 | + | .sidebar-outer { | |||
| 10 | + | background-color: var(--surface); | |||
| 11 | + | border-left: 0.0625em solid var(--border); | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | .sidebar-search-box { | |||
| 15 | + | padding: 0.375em 0.5em; | |||
| 16 | + | border-bottom: 0.0625em solid var(--border); | |||
| 17 | + | background-color: var(--surface); | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | .sidebar-search { | |||
| 21 | + | padding: 0.125em 0.375em; | |||
| 22 | + | font-size: 0.85em; | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | .sidebar-list { | |||
| 26 | + | background-color: var(--surface); | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | .sidebar-row { | |||
| 30 | + | padding: 0.1875em 0.625em; | |||
| 31 | + | min-height: 0; | |||
| 32 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.4); | |||
| 33 | + | background-color: var(--surface); | |||
| 34 | + | transition: background-color 100ms ease; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | .sidebar-row:hover { | |||
| 38 | + | background-color: alpha(var(--accent), 0.18); | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | .sidebar-row:selected { | |||
| 42 | + | background-color: alpha(var(--accent), 0.18); | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | .sidebar-row:focus { | |||
| 46 | + | background-color: alpha(var(--accent), 0.18); | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | .sidebar-row-title { | |||
| 50 | + | font-size: 0.82em; | |||
| 51 | + | color: var(--text); | |||
| 52 | + | font-weight: 500; | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | .sidebar-row-subtitle { | |||
| 56 | + | font-size: 0.72em; | |||
| 57 | + | color: var(--muted); | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | .sidebar-empty { | |||
| 61 | + | padding: 1.5em 0.75em; | |||
| 62 | + | font-size: 0.82em; | |||
| 63 | + | color: var(--muted); | |||
| 64 | + | font-style: italic; | |||
| 65 | + | } | |||
| 66 | + | ` | |||
| 67 | + | } | |||