detached 965ed59

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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:
117117 - `Alt+F` is the only floating-pane shortcut enabled by default.
118118 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
119119 - `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.
121121 - `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.
122122 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
123123 - 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 (
1111 type FavoritesSidebarFavorites interface {
1212 GetAll(ctx context.Context) ([]*entity.Favorite, error)
1313 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
14+ AddTag(ctx context.Context, name, color string) (*entity.Tag, error)
1415 AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
1516 UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
1617 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) error17 DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
M internal/ui/component/favorites_sidebar.go +140 -35
@@ -13,8 +13,6 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 )
1515
16-const favoritesSidebarOuterSpacing = 6
17-
1816 // FavoritesSidebarConfig holds configuration for creating a FavoritesSidebar.
1917 type FavoritesSidebarConfig struct {
2018 FavoritesUC port.FavoritesSidebarFavorites
@@ -26,22 +24,27 @@ type FavoritesSidebarConfig struct {
2624
2725 // FavoritesSidebar is a read-only GTK sidebar component for browsing favorites.
2826 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
3537
3638 formURLEntry *gtk.SearchEntry
3739 formTitleEntry *gtk.SearchEntry
38- formTagsEntry *gtk.SearchEntry
40+ formTagSearch *gtk.SearchEntry
41+ formTagMatches *gtk.Box
3942 formShortcutEntry *gtk.SearchEntry
4043 formSaveButton *gtk.Button
4144 formURL string
4245 formTitle string
43- formTags string
4446 formShortcut string
47+ formTagIDs map[entity.TagID]struct{}
4548
4649 favoritesUC port.FavoritesSidebarFavorites
4750 onNavigate func(ctx context.Context, url string) error
@@ -60,6 +63,7 @@ type FavoritesSidebar struct {
6063 destroyed bool
6164 visible bool
6265 focusZone favoritesSidebarFocusZone
66+ textInputWidget *gtk.Widget
6367 mode favoritesSidebarMode
6468 editingID entity.FavoriteID
6569 confirmDelete bool
@@ -157,33 +161,12 @@ func (fs *FavoritesSidebar) Destroy() {
157161 }
158162
159163 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
163166 }
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
173169 }
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)
187170
188171 fs.scrolledWin = gtk.NewScrolledWindow()
189172 if fs.scrolledWin == nil {
@@ -192,12 +175,15 @@ func (fs *FavoritesSidebar) createWidgets() error {
192175 fs.scrolledWin.SetVexpand(true)
193176 fs.scrolledWin.SetHexpand(true)
194177 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
178+ fs.scrolledWin.AddCssClass("sidebar-list")
195179
196180 fs.listBox = gtk.NewListBox()
197181 if fs.listBox == nil {
198182 return fmt.Errorf("favorites sidebar: list box creation failed")
199183 }
184+ fs.listBox.AddCssClass("sidebar-list")
200185 fs.listBox.AddCssClass("favorites-sidebar-list")
186+ fs.trackListFocus(&fs.listBox.Widget)
201187 fs.listBox.SetActivateOnSingleClick(true)
202188 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
203189 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
@@ -220,6 +206,125 @@ func (fs *FavoritesSidebar) createWidgets() error {
220206 return nil
221207 }
222208
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+
223328 func (fs *FavoritesSidebar) focusSearch() {
224329 if fs == nil {
225330 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.FavoritesSidebarFavorites18 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.Box27+ outerBox *gtk.Box
30- searchEntry *gtk.SearchEntry28+ searchEntry *gtk.SearchEntry
31- tagBox *gtk.Box29+ tagBox *gtk.Box
32- scrolledWin *gtk.ScrolledWindow30+ tagControls []*gtk.Button
33- listBox *gtk.ListBox31+ tagPromptBox *gtk.Box
34- formBox *gtk.Box32+ tagNameEntry *gtk.Entry
33+ tagPromptSaveBtn *gtk.Button
34+ scrolledWin *gtk.ScrolledWindow
35+ listBox *gtk.ListBox
36+ formBox *gtk.Box
35 37
36 formURLEntry *gtk.SearchEntry38 formURLEntry *gtk.SearchEntry
37 formTitleEntry *gtk.SearchEntry39 formTitleEntry *gtk.SearchEntry
38- formTagsEntry *gtk.SearchEntry40+ formTagSearch *gtk.SearchEntry
41+ formTagMatches *gtk.Box
39 formShortcutEntry *gtk.SearchEntry42 formShortcutEntry *gtk.SearchEntry
40 formSaveButton *gtk.Button43 formSaveButton *gtk.Button
41 formURL string44 formURL string
42 formTitle string45 formTitle string
43- formTags string
44 formShortcut string46 formShortcut string
47+ formTagIDs map[entity.TagID]struct{}
45 48
46 favoritesUC port.FavoritesSidebarFavorites49 favoritesUC port.FavoritesSidebarFavorites
47 onNavigate func(ctx context.Context, url string) error50 onNavigate func(ctx context.Context, url string) error
@@ -60,6 +63,7 @@ type FavoritesSidebar struct {
60 destroyed bool63 destroyed bool
61 visible bool64 visible bool
62 focusZone favoritesSidebarFocusZone65 focusZone favoritesSidebarFocusZone
66+ textInputWidget *gtk.Widget
63 mode favoritesSidebarMode67 mode favoritesSidebarMode
64 editingID entity.FavoriteID68 editingID entity.FavoriteID
65 confirmDelete bool69 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 nil206 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 return330 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 (
1818 favoritesSidebarModeNone favoritesSidebarMode = iota
1919 favoritesSidebarModeAdd
2020 favoritesSidebarModeEdit
21- favoritesSidebarModeTag
21+ favoritesSidebarModeCreateTag
22+ favoritesSidebarModeBindTag
2223 favoritesSidebarModeShortcut
2324 )
2425
@@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() {
4243 fs.editingID = 0
4344 fs.confirmDelete = false
4445 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.")
4647 fs.mu.Unlock()
4748 fs.renderForm(nil)
4849 fs.rebuildList()
@@ -63,7 +64,7 @@ func (fs *FavoritesSidebar) beginEditForm() {
6364 fs.editingID = fav.ID
6465 fs.confirmDelete = false
6566 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.")
6768 fs.mu.Unlock()
6869 fs.renderForm(fav)
6970 fs.rebuildList()
@@ -76,18 +77,17 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
7677 clearBoxChildren(fs.formBox)
7778 fs.formURLEntry = gtk.NewSearchEntry()
7879 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)
8382 fs.formShortcutEntry = gtk.NewSearchEntry()
83+ fs.formTagIDs = tagIDSet(favoriteTags(fav))
8484 entries := []struct {
8585 entry *gtk.SearchEntry
8686 label string
8787 }{
8888 {entry: fs.formURLEntry, label: "URL"},
8989 {entry: fs.formTitleEntry, label: "Title"},
90- {entry: fs.formTagsEntry, label: "Tag IDs"},
90+ {entry: fs.formTagSearch, label: "Add tags..."},
9191 {entry: fs.formShortcutEntry, label: "Shortcut"},
9292 }
9393 for _, item := range entries {
@@ -96,8 +96,14 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
9696 }
9797 placeholder := item.label
9898 item.entry.SetPlaceholderText(&placeholder)
99+ fs.trackTextInputFocus(&item.entry.Widget)
99100 fs.formBox.Append(&item.entry.Widget)
100101 }
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()
101107 fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102108 if fs.formSaveButton != nil {
103109 cb := func(_ gtk.Button) {
@@ -110,7 +116,6 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
110116 if fav != nil {
111117 fs.formURL = fav.URL
112118 fs.formTitle = fav.Title
113- fs.formTags = joinFavoriteTagIDs(fav.Tags)
114119 fs.formShortcut = ""
115120 if fav.ShortcutKey != nil {
116121 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
@@ -122,15 +127,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
122127 if fs.formTitleEntry != nil {
123128 fs.formTitleEntry.SetText(fs.formTitle)
124129 }
125- if fs.formTagsEntry != nil {
126- fs.formTagsEntry.SetText(fs.formTags)
127- }
128130 if fs.formShortcutEntry != nil {
129131 fs.formShortcutEntry.SetText(fs.formShortcut)
130132 }
131133 } else {
132- fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
134+ fs.formURL, fs.formTitle, fs.formShortcut = "", "", ""
133135 }
136+ fs.renderFormTagMatches("")
134137 fs.formBox.SetVisible(true)
135138 fs.focusForm()
136139 }
@@ -177,6 +180,12 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
177180 fs.formBox.SetVisible(false)
178181 clearBoxChildren(fs.formBox)
179182 }
183+ if fs.tagPromptBox != nil {
184+ clearBoxChildren(fs.tagPromptBox)
185+ fs.tagPromptBox.SetVisible(false)
186+ fs.tagNameEntry = nil
187+ fs.tagPromptSaveBtn = nil
188+ }
180189 fs.rebuildList()
181190 return active
182191 }
@@ -194,13 +203,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
194203 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195204 return false
196205 }
197- url, title, tagsText, shortcutText := fs.formValues()
206+ url, title, shortcutText := fs.formValues()
207+ tags := fs.formTagIDsSnapshot()
198208 if mode == favoritesSidebarModeAdd {
199- tags, err := parseTagIDs(tagsText)
200- if err != nil {
201- fs.setNotice(err.Error())
202- return true
203- }
204209 key, err := parseShortcut(shortcutText)
205210 if err != nil {
206211 fs.setNotice(err.Error())
@@ -231,15 +236,15 @@ func (fs *FavoritesSidebar) submitForm() bool {
231236 fs.setNotice(err.Error())
232237 return true
233238 }
239+ if !fs.updateFavoriteTags(id, tags) {
240+ return true
241+ }
234242 }
235243 fs.cancelManagement()
236244 fs.startLoad()
237245 return true
238246 }
239247
240-func (fs *FavoritesSidebar) enterTagMode() {
241- fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
242-}
243248 func (fs *FavoritesSidebar) enterShortcutMode() {
244249 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245250 }
@@ -256,43 +261,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
256261 fs.rebuildList()
257262 }
258263
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-
296264 func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297265 fav := fs.selectedFavorite()
298266 if fav == nil {
@@ -402,41 +370,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) {
402370 fs.rebuildList()
403371 }
404372
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
407375 if fs.formURLEntry != nil {
408376 url = fs.formURLEntry.GetText()
409377 }
410378 if fs.formTitleEntry != nil {
411379 title = fs.formTitleEntry.GetText()
412380 }
413- if fs.formTagsEntry != nil {
414- tagsText = fs.formTagsEntry.GetText()
415- }
416381 if fs.formShortcutEntry != nil {
417382 shortcut = fs.formShortcutEntry.GetText()
418383 }
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)
440385 }
441386
442387 func parseShortcut(text string) (*int, error) {
@@ -450,11 +395,3 @@ func parseShortcut(text string) (*int, error) {
450395 }
451396 return &v, nil
452397 }
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 = iota18 favoritesSidebarModeNone favoritesSidebarMode = iota
19 favoritesSidebarModeAdd19 favoritesSidebarModeAdd
20 favoritesSidebarModeEdit20 favoritesSidebarModeEdit
21- favoritesSidebarModeTag21+ favoritesSidebarModeCreateTag
22+ favoritesSidebarModeBindTag
22 favoritesSidebarModeShortcut23 favoritesSidebarModeShortcut
23 )24 )
24 25
@@ -42,7 +43,7 @@ func (fs *FavoritesSidebar) beginAddForm() {
42 fs.editingID = 043 fs.editingID = 0
43 fs.confirmDelete = false44 fs.confirmDelete = false
44 fs.confirmDeleteID = 045 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.ID64 fs.editingID = fav.ID
64 fs.confirmDelete = false65 fs.confirmDelete = false
65 fs.confirmDeleteID = 066 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 = nil80+ 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.SearchEntry85 entry *gtk.SearchEntry
86 label string86 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.label97 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.URL117 fs.formURL = fav.URL
112 fs.formTitle = fav.Title118 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 active190 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 false204 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 true237 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 true245 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.formShortcut374+ 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, nil396 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() {
1717 if keyController == nil {
1818 return
1919 }
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)
2124 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
2225 fs.mu.RLock()
2326 if fs.destroyed {
@@ -29,6 +32,18 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
2932 fs.mu.RUnlock()
3033
3134 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+ }
3247 switch keyval {
3348 case uint(gdk.KEY_Escape):
3449 if fs.cancelManagement() {
@@ -43,6 +58,12 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
4358 }
4459 return true
4560 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+ }
4667 return fs.handleReturnKey(state)
4768 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
4869 fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
@@ -89,6 +110,11 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
89110 }
90111 fs.selectAdjacentRow(1)
91112 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
92118 case uint(gdk.KEY_slash):
93119 if textEditing {
94120 return false
@@ -279,21 +305,96 @@ func (fs *FavoritesSidebar) navigateToNewPane(url string) {
279305
280306 func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
281307
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 {
283316 if fs == nil {
284317 return false
285318 }
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.
287325 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()
292333 }
334+ return true
293335 }
294336 return false
295337 }
296338
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+
297398 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298399 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299400 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
@@ -303,9 +404,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
303404 mode := fs.mode
304405 confirm := fs.confirmDelete
305406 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- }
309407 if mode == favoritesSidebarModeShortcut {
310408 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311409 v := int(keyval - uint(gdk.KEY_0))
@@ -328,9 +426,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
328426 case uint(gdk.KEY_e):
329427 fs.beginEditForm()
330428 return true
331- case uint(gdk.KEY_t):
332- fs.enterTagMode()
333- return true
334429 case uint(gdk.KEY_s):
335430 fs.enterShortcutMode()
336431 return true
@@ -366,13 +461,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366461 if fs == nil {
367462 return
368463 }
369- zones := fs.availableFocusZones()
370- if len(zones) == 0 {
464+ if fs.cycleTagControlFocus(reverse) {
371465 return
372466 }
373467 fs.mu.RLock()
374468 current := fs.focusZone
469+ tagCount := len(fs.tagControls)
375470 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+ }
376479 idx := -1
377480 for i, zone := range zones {
378481 if zone == current {
@@ -394,6 +497,32 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
394497 fs.focusZoneWidget(zones[idx])
395498 }
396499
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+
397526 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398527 zones := []favoritesSidebarFocusZone{}
399528 if fs.searchEntry != nil {
@@ -428,9 +557,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
428557 case favoritesSidebarFocusSearch:
429558 fs.focusSearch()
430559 case favoritesSidebarFocusTags:
431- if fs.tagBox != nil {
432- fs.tagBox.GrabFocus()
433- }
560+ fs.focusTagControl(0)
434561 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435562 if fs.listBox != nil {
436563 fs.listBox.GrabFocus()
@@ -17,7 +17,10 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
17 if keyController == nil {17 if keyController == nil {
18 return18 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 true59 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 true112 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 false120 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 false317 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 true325 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 true329+ 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 false336 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.AltMaskValue399 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.mode404 mode := fs.mode
304 confirm := fs.confirmDelete405 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 true428 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 true431 return true
@@ -366,13 +461,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366 if fs == nil {461 if fs == nil {
367 return462 return
368 }463 }
369- zones := fs.availableFocusZones()464+ if fs.cycleTagControlFocus(reverse) {
370- if len(zones) == 0 {
371 return465 return
372 }466 }
373 fs.mu.RLock()467 fs.mu.RLock()
374 current := fs.focusZone468 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 := -1479 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
7272 }
7373
7474 func (fs *FavoritesSidebar) renderTags() {
75- if fs == nil || fs.tagBox == nil {
75+ tagBox, tags, selectedTagIDs, ok := fs.tagRenderState()
76+ if !ok {
7677 return
7778 }
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+ }
78103 fs.mu.RLock()
104+ defer fs.mu.RUnlock()
79105 if fs.destroyed {
80- fs.mu.RUnlock()
81- return
106+ return nil, nil, nil, false
82107 }
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))
85109 for id := range fs.selectedTagIDs {
86- selectedTagIDs[id] = struct{}{}
110+ selected[id] = struct{}{}
87111 }
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+}
93114
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
114126 }
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+}
115137
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
134146 }
147+ fs.selectedTagIDs = make(map[entity.TagID]struct{})
148+ fs.rebuildDisplayRowsLocked()
149+ fs.mu.Unlock()
150+ fs.renderTags()
151+ fs.rebuildList()
152+}
135153
154+func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) {
136155 fs.mu.Lock()
137156 if !fs.destroyed {
138157 fs.tagCallbacks = callbacks
158+ fs.tagControls = controls
139159 }
140160 fs.mu.Unlock()
141161 }
@@ -212,6 +232,7 @@ func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
212232 if label == nil {
213233 return
214234 }
235+ label.AddCssClass("sidebar-empty")
215236 label.AddCssClass("favorites-sidebar-empty")
216237 label.SetXalign(0.0)
217238 row := gtk.NewListBoxRow()
@@ -243,6 +264,7 @@ func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow f
243264 if row == nil {
244265 return
245266 }
267+ row.AddCssClass("sidebar-row")
246268 row.AddCssClass("favorites-sidebar-row")
247269 row.SetSelectable(displayRow.Selectable)
248270 row.SetActivatable(displayRow.Selectable)
@@ -257,6 +279,7 @@ func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
257279 if title == nil {
258280 return
259281 }
282+ title.AddCssClass("sidebar-row-title")
260283 title.AddCssClass("favorites-sidebar-row-title")
261284 title.SetText(safeSidebarString(fav.Title, fav.URL))
262285 title.SetXalign(0.0)
@@ -273,6 +296,7 @@ func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
273296 sub.SetHexpand(true)
274297 url := gtk.NewLabel(nil)
275298 if url != nil {
299+ url.AddCssClass("sidebar-row-subtitle")
276300 url.AddCssClass("favorites-sidebar-row-subtitle")
277301 url.SetText(readableURL(fav.URL))
278302 url.SetXalign(0.0)
@@ -299,6 +323,7 @@ func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
299323 if tags == nil {
300324 return
301325 }
326+ tags.AddCssClass("favorites-sidebar-row-tags")
302327 for _, tag := range favoriteTags {
303328 name := tag.Name
304329 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 return77 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.tagBox112+ 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- return124+ 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- continue140+ return
119- }141+ }
120- t := tag142+ fs.mu.Lock()
121- button := gtk.NewButtonWithLabel(t.Name)143+ if fs.destroyed {
122- if button == nil {144+ fs.mu.Unlock()
123- continue145+ 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 = callbacks157 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 return233 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 return265 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 return280 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 return324 return
301 }325 }
326+ tags.AddCssClass("favorites-sidebar-row-tags")
302 for _, tag := range favoriteTags {327 for _, tag := range favoriteTags {
303 name := tag.Name328 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 (
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 "github.com/bnema/puregotk/v4/gdk"
1313 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/gtk"
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
1617 )
@@ -34,6 +35,10 @@ type fakeFavoritesSidebarUC struct {
3435 favID entity.FavoriteID
3536 tagID entity.TagID
3637 }
38+ createdTags []struct {
39+ name string
40+ color string
41+ }
3742 err error
3843 errOnSetShortcut error
3944 getAllCalled chan struct{}
@@ -51,6 +56,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
5156 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
5257 return f.tags, f.err
5358 }
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+}
5469 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
5570 f.addInputs = append(f.addInputs, input)
5671 if f.err != nil {
@@ -340,20 +355,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
340355 fs.favoritesUC = uc
341356 fs.formURL = "https://add.test"
342357 fs.formTitle = "Added"
343- fs.formTags = "1, 2"
358+ fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
344359 fs.formShortcut = "3"
345360 fs.mode = favoritesSidebarModeAdd
346361
347362 assert.True(t, fs.submitForm())
348363 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)
350365 require.Len(t, uc.shortcuts, 1)
351366 assert.Equal(t, 3, *uc.shortcuts[0].key)
352367
353368 fs.mode = favoritesSidebarModeEdit
354369 fs.editingID = 7
355370 fs.formTitle = "Edited"
356- fs.formTags = "999"
357371 fs.formShortcut = "4"
358372 assert.True(t, fs.submitForm())
359373 require.Len(t, uc.updateInputs, 1)
@@ -372,23 +386,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
372386 assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373387 }
374388
375-func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
389+func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
376390 uc := &fakeFavoritesSidebarUC{}
377391 fs := newFavoritesSidebarHarness(nil, nil)
378392 fs.favoritesUC = uc
379393 fs.formURL = "https://add.test"
380394 fs.formTitle = "Added"
381- fs.formTags = "1, nope"
395+ fs.formShortcut = "10"
382396 fs.mode = favoritesSidebarModeAdd
383397
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"
392398 assert.True(t, fs.submitForm())
393399 assert.Empty(t, uc.addInputs)
394400 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
@@ -396,6 +402,48 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
396402 assert.Equal(t, "10", fs.formShortcut)
397403 }
398404
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+
399447 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400448 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401449 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
@@ -436,7 +484,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
436484 fs.mode = favoritesSidebarModeEdit
437485 fs.editingID = 7
438486 fs.formTitle = "Edited"
439- fs.formTags = "999"
440487
441488 assert.True(t, fs.submitForm())
442489 require.Len(t, uc.updateInputs, 1)
@@ -445,6 +492,86 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
445492 assert.Empty(t, uc.untagged)
446493 }
447494
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 := &gtk.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+
448575 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449576 fs := newFavoritesSidebarHarness(nil, nil)
450577 fs.searchEntry = nil
@@ -505,23 +632,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
505632 assert.Contains(t, fs.notice, "failed to set shortcut")
506633 }
507634
508-func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
635+func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
509636 dev := entity.Tag{ID: 10, Name: "dev"}
510- news := entity.Tag{ID: 11, Name: "news"}
511637 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})
513639 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
514640 fs.allTags = uc.tags
515641 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
516642
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-
525643 fs.mode = favoritesSidebarModeShortcut
526644 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
527645 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.FavoriteID35 favID entity.FavoriteID
35 tagID entity.TagID36 tagID entity.TagID
36 }37 }
38+ createdTags []struct {
39+ name string
40+ color string
41+ }
37 err error42 err error
38 errOnSetShortcut error43 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.err57 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 = uc355 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 = favoritesSidebarModeAdd360 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 = favoritesSidebarModeEdit368 fs.mode = favoritesSidebarModeEdit
354 fs.editingID = 7369 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 = uc392 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 = favoritesSidebarModeAdd396 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 = favoritesSidebarModeEdit484 fs.mode = favoritesSidebarModeEdit
437 fs.editingID = 7485 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 := &gtk.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 = nil577 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.tags640 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 = favoritesSidebarModeShortcut643 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) {
103103 if label == nil {
104104 return
105105 }
106+ label.AddCssClass("sidebar-empty")
106107 label.AddCssClass("history-sidebar-empty")
107108
108109 if query != "" {
@@ -163,6 +164,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
163164 if titleLabel == nil {
164165 return
165166 }
167+ titleLabel.AddCssClass("sidebar-row-title")
166168 titleLabel.AddCssClass("history-sidebar-row-title")
167169 titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
168170 titleLabel.SetXalign(0.0)
@@ -180,6 +182,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
180182 if urlLabel == nil {
181183 return
182184 }
185+ urlLabel.AddCssClass("sidebar-row-subtitle")
183186 urlLabel.AddCssClass("history-sidebar-row-subtitle")
184187 urlLabel.SetText(readableURL(entry.URL))
185188 urlLabel.SetXalign(0.0)
@@ -205,6 +208,7 @@ func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.His
205208 if row == nil {
206209 return
207210 }
211+ row.AddCssClass("sidebar-row")
208212 row.AddCssClass("history-sidebar-row")
209213 row.SetSelectable(true)
210214 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 return104 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 return165 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 return183 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 return209 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 {
2828 if hs.outerBox == nil {
2929 return fmt.Errorf("history sidebar: outer box creation failed")
3030 }
31+ hs.outerBox.AddCssClass("sidebar-outer")
3132 hs.outerBox.AddCssClass("history-sidebar-outer")
3233 hs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
3334 hs.outerBox.SetHexpand(false)
@@ -41,6 +42,7 @@ func (hs *HistorySidebar) initSearchBox() error {
4142 if hs.searchBox == nil {
4243 return fmt.Errorf("history sidebar: search box creation failed")
4344 }
45+ hs.searchBox.AddCssClass("sidebar-search-box")
4446 hs.searchBox.AddCssClass("history-sidebar-search-box")
4547 hs.searchBox.SetHexpand(true)
4648
@@ -48,6 +50,7 @@ func (hs *HistorySidebar) initSearchBox() error {
4850 if hs.searchEntry == nil {
4951 return fmt.Errorf("history sidebar: search entry creation failed")
5052 }
53+ hs.searchEntry.AddCssClass("sidebar-search")
5154 hs.searchEntry.AddCssClass("history-sidebar-search")
5255 hs.searchEntry.SetHexpand(true)
5356 placeholder := "Search history..."
@@ -66,12 +69,14 @@ func (hs *HistorySidebar) initListArea() error {
6669 hs.scrolledWin.SetVexpand(true)
6770 hs.scrolledWin.SetHexpand(true)
6871 hs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
72+ hs.scrolledWin.AddCssClass("sidebar-list")
6973 hs.scrolledWin.AddCssClass("history-sidebar-groups")
7074
7175 hs.listBox = gtk.NewListBox()
7276 if hs.listBox == nil {
7377 return fmt.Errorf("history sidebar: list box creation failed")
7478 }
79+ hs.listBox.AddCssClass("sidebar-list")
7580 hs.listBox.AddCssClass("history-sidebar-groups")
7681 hs.listBox.SetActivateOnSingleClick(true)
7782 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
159159 sb.WriteString(generateAccentPickerCSS(p))
160160 sb.WriteString("\n")
161161
162- // History sidebar styling
163- sb.WriteString(generateHistorySidebarCSS(p))
162+ appendNativeSidebarCSS(&sb, p)
164163
165164 return sb.String()
166165 }
167166
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+
168176 // generateTabBarCSS creates tab bar styles.
169177 // Uses em units for scalable UI.
170178 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 styling162+ 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
44 func generateHistorySidebarCSS(_ Palette) string {
55 return `/* ===== History Sidebar Styling ===== */
66
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-
277 .history-sidebar-group-header {
288 padding: 0.25em 0.625em;
299 padding-top: 0.375em;
@@ -36,37 +16,6 @@ func generateHistorySidebarCSS(_ Palette) string {
3616 border-bottom: 0.0625em solid var(--border);
3717 }
3818
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-
7019 .history-sidebar-row-time {
7120 font-size: 0.68em;
7221 color: var(--muted);
@@ -74,13 +23,6 @@ func generateHistorySidebarCSS(_ Palette) string {
7423 opacity: 0.75;
7524 }
7625
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-
8426 .history-sidebar-loading {
8527 padding: 1.5em 0.75em;
8628 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) {
1212 css := generateHistorySidebarCSS(DefaultDarkPalette())
1313
1414 selectors := []string{
15- ".history-sidebar-outer",
16- ".history-sidebar-search-box",
17- ".history-sidebar-search",
18- ".history-sidebar-groups",
1915 ".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",
2616 ".history-sidebar-row-time",
27- ".history-sidebar-empty",
2817 ".history-sidebar-loading",
2918 }
3019
@@ -34,8 +23,8 @@ func TestGenerateHistorySidebarCSS_ContainsExpectedSelectors(t *testing.T) {
3423 }
3524 }
3625
37-func TestGenerateHistorySidebarCSS_AccentAlphaInterpolation(t *testing.T) {
38- css := generateHistorySidebarCSS(DefaultDarkPalette())
26+func TestGenerateSidebarCSS_AccentAlphaInterpolation(t *testing.T) {
27+ css := generateSidebarCSS(DefaultDarkPalette())
3928
4029 expectedAlpha := "alpha(var(--accent), 0.18)"
4130 assert.Contains(t, css, expectedAlpha,
@@ -60,9 +49,9 @@ func TestGenerateHistorySidebarCSS_DeterministicOutput(t *testing.T) {
6049 assert.Equal(t, hash1, hash2, "CSS output must be deterministic for the same palette")
6150 }
6251
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())
6655
6756 assert.Equal(t, darkCSS, lightCSS,
6857 "history sidebar fragment should be palette-independent when it relies on CSS variables")
@@ -73,12 +62,12 @@ func TestGenerateHistorySidebarCSS_UsesPaletteVariablesInsteadOfInliningColors(t
7362 func TestGenerateHistorySidebarCSS_ThroughGenerateCSS(t *testing.T) {
7463 fullCSS := GenerateCSS(DefaultDarkPalette())
7564
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")
7867 assert.Contains(t, fullCSS, ".history-sidebar-group-header {",
7968 "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")
8271 assert.Contains(t, fullCSS, "History Sidebar Styling",
8372 "full CSS must contain the history sidebar comment marker")
8473 }
@@ -100,7 +89,7 @@ func TestGenerateHistorySidebarCSS_NoEmptyCSSBlocks(t *testing.T) {
10089 }
10190 }
10291
103-func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {
92+func TestGenerateSidebarCSS_CustomPaletteValuesInterpolated(t *testing.T) {
10493 customPalette := Palette{
10594 Background: "#111111",
10695 Surface: "#222222",
@@ -114,22 +103,21 @@ func TestGenerateHistorySidebarCSS_CustomPaletteValuesInterpolated(t *testing.T)
114103 Destructive: "#cc2222",
115104 }
116105
117- css := generateHistorySidebarCSS(customPalette)
106+ css := generateSidebarCSS(customPalette)
118107
119108 assert.Contains(t, css, "alpha(var(--accent), 0.18)",
120109 "sidebar CSS should use the shared accent variable")
121110 assert.NotContains(t, css, "#ff6600",
122111 "sidebar fragment should not inline palette-specific accent values")
123112
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 {")
129117 }
130118
131-func TestGenerateHistorySidebarCSS_ContainsTransition(t *testing.T) {
132- css := generateHistorySidebarCSS(DefaultDarkPalette())
119+func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) {
120+ css := generateSidebarCSS(DefaultDarkPalette())
133121
134122 assert.Contains(t, css, "transition:",
135123 "CSS should include transition property for smooth hover effects")
@@ -149,27 +137,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) {
149137 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {
150138 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
151139
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")
160148 }
161149
162150 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {
163151 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
164152
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")
173161 }
174162
175163 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {
@@ -181,8 +169,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T
181169 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
182170
183171 // 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")
186174
187175 // The CSS must differ between palettes (different color values).
188176 if darkCSS == lightCSS {
@@ -211,14 +199,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t *
211199
212200 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)
213201
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")
222210 }
223211
224212 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the
@@ -247,21 +235,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) {
247235 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
248236 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
249237
250- // All three must contain the full set of history sidebar selectors.
238+ // All three must contain the shared foundation and history-specific rules.
251239 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",
256251 ".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",
263252 ".history-sidebar-row-time",
264- ".history-sidebar-empty",
265253 ".history-sidebar-loading",
266254 }
267255
@@ -324,9 +312,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) {
324312 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")
325313 }
326314
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 {")
330318
331319 // The before and after CSS must be different (palette changed).
332320 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 the212 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close