detached f818ab3

Merge pull request #370 from bnema/feat/favorites-sidebar-theme

refactor(favorites): unify native sidebar interactions
commit
f818ab35640354b52e1d478af198280f67d77c56
author
bnema · · 2026-08-12 09:04 UTC
committer
GitHub · · 2026-08-12 09:04 UTC
merge of
14bdae6 + 0600fe7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +1399 -357
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 `+` at the right end of the tag-filter row 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 `+` at the right end of the tag-filter row 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/app_browser_launch_test.go +23 -1
@@ -35,7 +35,9 @@ import (
3535 "github.com/bnema/dumber/internal/ui/window"
3636 "github.com/bnema/puregotk/v4/gdk"
3737 "github.com/bnema/puregotk/v4/gio"
38+ "github.com/bnema/puregotk/v4/glib"
3839 "github.com/bnema/puregotk/v4/gtk"
40+ "github.com/stretchr/testify/assert"
3941 "github.com/stretchr/testify/mock"
4042 "github.com/stretchr/testify/require"
4143 )
@@ -979,7 +981,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox
979981 label.EXPECT().SetCanTarget(false).Once()
980982 label.EXPECT().SetCanFocus(false).Once()
981983
982- return component.NewToaster(factory), box, label
984+ toaster := component.NewToaster(factory)
985+ t.Cleanup(toaster.CancelAutoDismiss)
986+ return toaster, box, label
983987 }
984988
985989 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -997,6 +1001,24 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
9971001 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
9981002 }
9991003
1004+func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1005+ toaster, box, label := newTestShellToaster(t)
1006+ box.EXPECT().SetVisible(true).Once()
1007+ label.EXPECT().SetText("will not outlive this test").Once()
1008+ toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)
1009+ toaster.CancelAutoDismiss()
1010+
1011+ mainContext := glib.MainContextDefault()
1012+ deadline := time.Now().Add(component.ToastBriefDurationMs + 100*time.Millisecond)
1013+ for time.Now().Before(deadline) {
1014+ for mainContext.Pending() {
1015+ mainContext.Iteration(false)
1016+ }
1017+ time.Sleep(10 * time.Millisecond)
1018+ }
1019+ assert.True(t, toaster.IsVisible(), "canceling must prevent the deferred hide callback")
1020+}
1021+
10001022 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
10011023 ctx := context.Background()
10021024 toaster, box, label := newTestShellToaster(t)
@@ -35,7 +35,9 @@ import (
35 "github.com/bnema/dumber/internal/ui/window"35 "github.com/bnema/dumber/internal/ui/window"
36 "github.com/bnema/puregotk/v4/gdk"36 "github.com/bnema/puregotk/v4/gdk"
37 "github.com/bnema/puregotk/v4/gio"37 "github.com/bnema/puregotk/v4/gio"
38+ "github.com/bnema/puregotk/v4/glib"
38 "github.com/bnema/puregotk/v4/gtk"39 "github.com/bnema/puregotk/v4/gtk"
40+ "github.com/stretchr/testify/assert"
39 "github.com/stretchr/testify/mock"41 "github.com/stretchr/testify/mock"
40 "github.com/stretchr/testify/require"42 "github.com/stretchr/testify/require"
41 )43 )
@@ -979,7 +981,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox
979 label.EXPECT().SetCanTarget(false).Once()981 label.EXPECT().SetCanTarget(false).Once()
980 label.EXPECT().SetCanFocus(false).Once()982 label.EXPECT().SetCanFocus(false).Once()
981 983
982- return component.NewToaster(factory), box, label984+ toaster := component.NewToaster(factory)
985+ t.Cleanup(toaster.CancelAutoDismiss)
986+ return toaster, box, label
983 }987 }
984 988
985 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {989 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -997,6 +1001,24 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
997 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})1001 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
998 }1002 }
999 1003
1004+func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1005+ toaster, box, label := newTestShellToaster(t)
1006+ box.EXPECT().SetVisible(true).Once()
1007+ label.EXPECT().SetText("will not outlive this test").Once()
1008+ toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)
1009+ toaster.CancelAutoDismiss()
1010+
1011+ mainContext := glib.MainContextDefault()
1012+ deadline := time.Now().Add(component.ToastBriefDurationMs + 100*time.Millisecond)
1013+ for time.Now().Before(deadline) {
1014+ for mainContext.Pending() {
1015+ mainContext.Iteration(false)
1016+ }
1017+ time.Sleep(10 * time.Millisecond)
1018+ }
1019+ assert.True(t, toaster.IsVisible(), "canceling must prevent the deferred hide callback")
1020+}
1021+
1000 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {1022 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
1001 ctx := context.Background()1023 ctx := context.Background()
1002 toaster, box, label := newTestShellToaster(t)1024 toaster, box, label := newTestShellToaster(t)
M internal/ui/component/favorites_sidebar.go +148 -40
@@ -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,16 +63,20 @@ 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
6670 confirmDeleteID entity.FavoriteID
6771
68- retainedCallbacks []any
69- tagCallbacks []any
70- ctx context.Context
71- cancel context.CancelFunc
72- mu sync.RWMutex
72+ retainedCallbacks []any
73+ tagCallbacks []any
74+ tagPromptCallbacks []any
75+ formCallbacks []any
76+ formTagMatchCallbacks []any
77+ ctx context.Context
78+ cancel context.CancelFunc
79+ mu sync.RWMutex
7380
7481 idleScheduler func(glib.SourceFunc)
7582 }
@@ -157,33 +164,12 @@ func (fs *FavoritesSidebar) Destroy() {
157164 }
158165
159166 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")
167+ if err := fs.createOuterAndSearch(); err != nil {
168+ return err
163169 }
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")
170+ if err := fs.createTagControls(); err != nil {
171+ return err
173172 }
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)
187173
188174 fs.scrolledWin = gtk.NewScrolledWindow()
189175 if fs.scrolledWin == nil {
@@ -192,12 +178,15 @@ func (fs *FavoritesSidebar) createWidgets() error {
192178 fs.scrolledWin.SetVexpand(true)
193179 fs.scrolledWin.SetHexpand(true)
194180 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
181+ fs.scrolledWin.AddCssClass("sidebar-list")
195182
196183 fs.listBox = gtk.NewListBox()
197184 if fs.listBox == nil {
198185 return fmt.Errorf("favorites sidebar: list box creation failed")
199186 }
187+ fs.listBox.AddCssClass("sidebar-list")
200188 fs.listBox.AddCssClass("favorites-sidebar-list")
189+ fs.trackListFocus(&fs.listBox.Widget)
201190 fs.listBox.SetActivateOnSingleClick(true)
202191 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
203192 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
@@ -220,6 +209,125 @@ func (fs *FavoritesSidebar) createWidgets() error {
220209 return nil
221210 }
222211
212+func (fs *FavoritesSidebar) createOuterAndSearch() error {
213+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
214+ if fs.outerBox == nil {
215+ return fmt.Errorf("favorites sidebar: outer box creation failed")
216+ }
217+ fs.outerBox.AddCssClass("sidebar-outer")
218+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
219+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
220+ fs.outerBox.SetHexpand(false)
221+ fs.outerBox.SetVexpand(true)
222+ fs.outerBox.SetVisible(false)
223+
224+ searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
225+ if searchBox == nil {
226+ return fmt.Errorf("favorites sidebar: search box creation failed")
227+ }
228+ searchBox.AddCssClass("sidebar-search-box")
229+ searchBox.SetHexpand(true)
230+ fs.searchEntry = gtk.NewSearchEntry()
231+ if fs.searchEntry == nil {
232+ return fmt.Errorf("favorites sidebar: search entry creation failed")
233+ }
234+ fs.searchEntry.AddCssClass("sidebar-search")
235+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
236+ fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
237+ fs.searchEntry.SetHexpand(true)
238+ placeholder := "Search favorites..."
239+ fs.searchEntry.SetPlaceholderText(&placeholder)
240+ searchBox.Append(&fs.searchEntry.Widget)
241+ fs.outerBox.Append(&searchBox.Widget)
242+ return nil
243+}
244+
245+func (fs *FavoritesSidebar) createTagControls() error {
246+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
247+ if fs.tagBox == nil {
248+ return fmt.Errorf("favorites sidebar: tag box creation failed")
249+ }
250+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
251+ fs.tagBox.SetHexpand(true)
252+ fs.outerBox.Append(&fs.tagBox.Widget)
253+ fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
254+ if fs.tagPromptBox == nil {
255+ return fmt.Errorf("favorites sidebar: tag prompt creation failed")
256+ }
257+ fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt")
258+ fs.tagPromptBox.SetVisible(false)
259+ fs.outerBox.Append(&fs.tagPromptBox.Widget)
260+ return nil
261+}
262+
263+func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
264+ if fs == nil || widget == nil || callbacks == nil {
265+ return
266+ }
267+ controller := gtk.NewEventControllerFocus()
268+ if controller == nil {
269+ return
270+ }
271+ entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
272+ left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
273+ *callbacks = append(*callbacks, entered, left)
274+ controller.ConnectEnter(&entered)
275+ controller.ConnectLeave(&left)
276+ widget.AddController(&controller.EventController)
277+}
278+
279+func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) {
280+ if fs == nil || widget == nil {
281+ return
282+ }
283+ fs.mu.Lock()
284+ if !fs.destroyed {
285+ fs.textInputWidget = widget
286+ if fs.searchEntry != nil && widget == &fs.searchEntry.Widget {
287+ fs.focusZone = favoritesSidebarFocusSearch
288+ } else {
289+ fs.focusZone = favoritesSidebarFocusForm
290+ }
291+ }
292+ fs.mu.Unlock()
293+}
294+
295+func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) {
296+ if fs == nil || widget == nil {
297+ return
298+ }
299+ controller := gtk.NewEventControllerFocus()
300+ if controller == nil {
301+ return
302+ }
303+ entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) }
304+ fs.retainedCallbacks = append(fs.retainedCallbacks, entered)
305+ controller.ConnectEnter(&entered)
306+ widget.AddController(&controller.EventController)
307+}
308+
309+func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) {
310+ if fs == nil {
311+ return
312+ }
313+ fs.mu.Lock()
314+ if !fs.destroyed {
315+ fs.focusZone = zone
316+ }
317+ fs.mu.Unlock()
318+}
319+
320+func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) {
321+ if fs == nil || widget == nil {
322+ return
323+ }
324+ fs.mu.Lock()
325+ if !fs.destroyed && fs.textInputWidget == widget {
326+ fs.textInputWidget = nil
327+ }
328+ fs.mu.Unlock()
329+}
330+
223331 func (fs *FavoritesSidebar) focusSearch() {
224332 if fs == nil {
225333 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,16 +63,20 @@ 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
66 confirmDeleteID entity.FavoriteID70 confirmDeleteID entity.FavoriteID
67 71
68- retainedCallbacks []any72+ retainedCallbacks []any
69- tagCallbacks []any73+ tagCallbacks []any
70- ctx context.Context74+ tagPromptCallbacks []any
71- cancel context.CancelFunc75+ formCallbacks []any
72- mu sync.RWMutex76+ formTagMatchCallbacks []any
77+ ctx context.Context
78+ cancel context.CancelFunc
79+ mu sync.RWMutex
73 80
74 idleScheduler func(glib.SourceFunc)81 idleScheduler func(glib.SourceFunc)
75 }82 }
@@ -157,33 +164,12 @@ func (fs *FavoritesSidebar) Destroy() {
157 }164 }
158 165
159 func (fs *FavoritesSidebar) createWidgets() error {166 func (fs *FavoritesSidebar) createWidgets() error {
160- fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, favoritesSidebarOuterSpacing)167+ if err := fs.createOuterAndSearch(); err != nil {
161- if fs.outerBox == nil {168+ return err
162- return fmt.Errorf("favorites sidebar: outer box creation failed")
163 }169 }
164- fs.outerBox.AddCssClass("favorites-sidebar-outer")170+ if err := fs.createTagControls(); err != nil {
165- fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)171+ 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 }172 }
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 173
188 fs.scrolledWin = gtk.NewScrolledWindow()174 fs.scrolledWin = gtk.NewScrolledWindow()
189 if fs.scrolledWin == nil {175 if fs.scrolledWin == nil {
@@ -192,12 +178,15 @@ func (fs *FavoritesSidebar) createWidgets() error {
192 fs.scrolledWin.SetVexpand(true)178 fs.scrolledWin.SetVexpand(true)
193 fs.scrolledWin.SetHexpand(true)179 fs.scrolledWin.SetHexpand(true)
194 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)180 fs.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
181+ fs.scrolledWin.AddCssClass("sidebar-list")
195 182
196 fs.listBox = gtk.NewListBox()183 fs.listBox = gtk.NewListBox()
197 if fs.listBox == nil {184 if fs.listBox == nil {
198 return fmt.Errorf("favorites sidebar: list box creation failed")185 return fmt.Errorf("favorites sidebar: list box creation failed")
199 }186 }
187+ fs.listBox.AddCssClass("sidebar-list")
200 fs.listBox.AddCssClass("favorites-sidebar-list")188 fs.listBox.AddCssClass("favorites-sidebar-list")
189+ fs.trackListFocus(&fs.listBox.Widget)
201 fs.listBox.SetActivateOnSingleClick(true)190 fs.listBox.SetActivateOnSingleClick(true)
202 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)191 fs.listBox.SetSelectionMode(gtk.SelectionSingleValue)
203 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {192 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
@@ -220,6 +209,125 @@ func (fs *FavoritesSidebar) createWidgets() error {
220 return nil209 return nil
221 }210 }
222 211
212+func (fs *FavoritesSidebar) createOuterAndSearch() error {
213+ fs.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
214+ if fs.outerBox == nil {
215+ return fmt.Errorf("favorites sidebar: outer box creation failed")
216+ }
217+ fs.outerBox.AddCssClass("sidebar-outer")
218+ fs.outerBox.AddCssClass("favorites-sidebar-outer")
219+ fs.outerBox.SetSizeRequest(sidebarMinWidth, -1)
220+ fs.outerBox.SetHexpand(false)
221+ fs.outerBox.SetVexpand(true)
222+ fs.outerBox.SetVisible(false)
223+
224+ searchBox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
225+ if searchBox == nil {
226+ return fmt.Errorf("favorites sidebar: search box creation failed")
227+ }
228+ searchBox.AddCssClass("sidebar-search-box")
229+ searchBox.SetHexpand(true)
230+ fs.searchEntry = gtk.NewSearchEntry()
231+ if fs.searchEntry == nil {
232+ return fmt.Errorf("favorites sidebar: search entry creation failed")
233+ }
234+ fs.searchEntry.AddCssClass("sidebar-search")
235+ fs.searchEntry.AddCssClass("favorites-sidebar-search")
236+ fs.trackTextInputFocus(&fs.searchEntry.Widget, &fs.retainedCallbacks)
237+ fs.searchEntry.SetHexpand(true)
238+ placeholder := "Search favorites..."
239+ fs.searchEntry.SetPlaceholderText(&placeholder)
240+ searchBox.Append(&fs.searchEntry.Widget)
241+ fs.outerBox.Append(&searchBox.Widget)
242+ return nil
243+}
244+
245+func (fs *FavoritesSidebar) createTagControls() error {
246+ fs.tagBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
247+ if fs.tagBox == nil {
248+ return fmt.Errorf("favorites sidebar: tag box creation failed")
249+ }
250+ fs.tagBox.AddCssClass("favorites-sidebar-tags")
251+ fs.tagBox.SetHexpand(true)
252+ fs.outerBox.Append(&fs.tagBox.Widget)
253+ fs.tagPromptBox = gtk.NewBox(gtk.OrientationHorizontalValue, 4)
254+ if fs.tagPromptBox == nil {
255+ return fmt.Errorf("favorites sidebar: tag prompt creation failed")
256+ }
257+ fs.tagPromptBox.AddCssClass("favorites-sidebar-tag-prompt")
258+ fs.tagPromptBox.SetVisible(false)
259+ fs.outerBox.Append(&fs.tagPromptBox.Widget)
260+ return nil
261+}
262+
263+func (fs *FavoritesSidebar) trackTextInputFocus(widget *gtk.Widget, callbacks *[]any) {
264+ if fs == nil || widget == nil || callbacks == nil {
265+ return
266+ }
267+ controller := gtk.NewEventControllerFocus()
268+ if controller == nil {
269+ return
270+ }
271+ entered := func(gtk.EventControllerFocus) { fs.setTextInputFocused(widget) }
272+ left := func(gtk.EventControllerFocus) { fs.clearTextInputFocus(widget) }
273+ *callbacks = append(*callbacks, entered, left)
274+ controller.ConnectEnter(&entered)
275+ controller.ConnectLeave(&left)
276+ widget.AddController(&controller.EventController)
277+}
278+
279+func (fs *FavoritesSidebar) setTextInputFocused(widget *gtk.Widget) {
280+ if fs == nil || widget == nil {
281+ return
282+ }
283+ fs.mu.Lock()
284+ if !fs.destroyed {
285+ fs.textInputWidget = widget
286+ if fs.searchEntry != nil && widget == &fs.searchEntry.Widget {
287+ fs.focusZone = favoritesSidebarFocusSearch
288+ } else {
289+ fs.focusZone = favoritesSidebarFocusForm
290+ }
291+ }
292+ fs.mu.Unlock()
293+}
294+
295+func (fs *FavoritesSidebar) trackListFocus(widget *gtk.Widget) {
296+ if fs == nil || widget == nil {
297+ return
298+ }
299+ controller := gtk.NewEventControllerFocus()
300+ if controller == nil {
301+ return
302+ }
303+ entered := func(gtk.EventControllerFocus) { fs.setFocusZone(favoritesSidebarFocusList) }
304+ fs.retainedCallbacks = append(fs.retainedCallbacks, entered)
305+ controller.ConnectEnter(&entered)
306+ widget.AddController(&controller.EventController)
307+}
308+
309+func (fs *FavoritesSidebar) setFocusZone(zone favoritesSidebarFocusZone) {
310+ if fs == nil {
311+ return
312+ }
313+ fs.mu.Lock()
314+ if !fs.destroyed {
315+ fs.focusZone = zone
316+ }
317+ fs.mu.Unlock()
318+}
319+
320+func (fs *FavoritesSidebar) clearTextInputFocus(widget *gtk.Widget) {
321+ if fs == nil || widget == nil {
322+ return
323+ }
324+ fs.mu.Lock()
325+ if !fs.destroyed && fs.textInputWidget == widget {
326+ fs.textInputWidget = nil
327+ }
328+ fs.mu.Unlock()
329+}
330+
223 func (fs *FavoritesSidebar) focusSearch() {331 func (fs *FavoritesSidebar) focusSearch() {
224 if fs == nil {332 if fs == nil {
225 return333 return
A internal/ui/component/favorites_sidebar_form_tags.go +180 -0
@@ -0,0 +1,180 @@
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.formCallbacks = append(fs.formCallbacks, 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+ callbacks := make([]any, 0)
69+ for _, tag := range formTagCandidates(tags, selected, query) {
70+ t := *tag
71+ label := t.Name
72+ if _, ok := selected[t.ID]; ok {
73+ label = "✓ " + label
74+ }
75+ button := gtk.NewButtonWithLabel(label)
76+ if button == nil {
77+ continue
78+ }
79+ button.AddCssClass("favorites-sidebar-form-tag-match")
80+ callback := func(_ gtk.Button) { fs.toggleFormTag(t.ID) }
81+ callbacks = append(callbacks, callback)
82+ button.ConnectClicked(&callback)
83+ fs.formTagMatches.Append(&button.Widget)
84+ }
85+ fs.mu.Lock()
86+ if !fs.destroyed {
87+ fs.formTagMatchCallbacks = callbacks
88+ }
89+ fs.mu.Unlock()
90+}
91+
92+func (fs *FavoritesSidebar) toggleFormTag(tagID entity.TagID) {
93+ if fs == nil {
94+ return
95+ }
96+ fs.mu.Lock()
97+ if fs.destroyed {
98+ fs.mu.Unlock()
99+ return
100+ }
101+ if fs.formTagIDs == nil {
102+ fs.formTagIDs = make(map[entity.TagID]struct{})
103+ }
104+ if _, ok := fs.formTagIDs[tagID]; ok {
105+ delete(fs.formTagIDs, tagID)
106+ } else {
107+ fs.formTagIDs[tagID] = struct{}{}
108+ }
109+ fs.mu.Unlock()
110+ query := ""
111+ if fs.formTagSearch != nil {
112+ query = fs.formTagSearch.GetText()
113+ }
114+ fs.renderFormTagMatches(query)
115+}
116+
117+func (fs *FavoritesSidebar) formTagIDsSnapshot() []entity.TagID {
118+ if fs == nil {
119+ return nil
120+ }
121+ fs.mu.RLock()
122+ defer fs.mu.RUnlock()
123+ ids := make([]entity.TagID, 0, len(fs.formTagIDs))
124+ for id := range fs.formTagIDs {
125+ ids = append(ids, id)
126+ }
127+ return ids
128+}
129+
130+func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wanted []entity.TagID) bool {
131+ if fs == nil {
132+ return false
133+ }
134+ fs.mu.RLock()
135+ uc, ctx := fs.favoritesUC, fs.ctx
136+ var current []entity.Tag
137+ for _, fav := range fs.allFavorites {
138+ if fav != nil && fav.ID == favoriteID {
139+ current = append(current, fav.Tags...)
140+ break
141+ }
142+ }
143+ fs.mu.RUnlock()
144+ if uc == nil {
145+ return false
146+ }
147+ wantedSet := make(map[entity.TagID]struct{}, len(wanted))
148+ for _, id := range wanted {
149+ wantedSet[id] = struct{}{}
150+ }
151+ currentSet := tagIDSet(current)
152+ mutated := false
153+ for id := range currentSet {
154+ if _, ok := wantedSet[id]; ok {
155+ continue
156+ }
157+ if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil {
158+ if mutated {
159+ fs.startLoad()
160+ }
161+ fs.setNotice(err.Error())
162+ return false
163+ }
164+ mutated = true
165+ }
166+ for id := range wantedSet {
167+ if _, ok := currentSet[id]; ok {
168+ continue
169+ }
170+ if err := uc.TagFavorite(ctx, favoriteID, id); err != nil {
171+ if mutated {
172+ fs.startLoad()
173+ }
174+ fs.setNotice(err.Error())
175+ return false
176+ }
177+ mutated = true
178+ }
179+ return true
180+}
@@ -0,0 +1,180 @@
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.formCallbacks = append(fs.formCallbacks, 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+ callbacks := make([]any, 0)
69+ for _, tag := range formTagCandidates(tags, selected, query) {
70+ t := *tag
71+ label := t.Name
72+ if _, ok := selected[t.ID]; ok {
73+ label = "✓ " + label
74+ }
75+ button := gtk.NewButtonWithLabel(label)
76+ if button == nil {
77+ continue
78+ }
79+ button.AddCssClass("favorites-sidebar-form-tag-match")
80+ callback := func(_ gtk.Button) { fs.toggleFormTag(t.ID) }
81+ callbacks = append(callbacks, callback)
82+ button.ConnectClicked(&callback)
83+ fs.formTagMatches.Append(&button.Widget)
84+ }
85+ fs.mu.Lock()
86+ if !fs.destroyed {
87+ fs.formTagMatchCallbacks = callbacks
88+ }
89+ fs.mu.Unlock()
90+}
91+
92+func (fs *FavoritesSidebar) toggleFormTag(tagID entity.TagID) {
93+ if fs == nil {
94+ return
95+ }
96+ fs.mu.Lock()
97+ if fs.destroyed {
98+ fs.mu.Unlock()
99+ return
100+ }
101+ if fs.formTagIDs == nil {
102+ fs.formTagIDs = make(map[entity.TagID]struct{})
103+ }
104+ if _, ok := fs.formTagIDs[tagID]; ok {
105+ delete(fs.formTagIDs, tagID)
106+ } else {
107+ fs.formTagIDs[tagID] = struct{}{}
108+ }
109+ fs.mu.Unlock()
110+ query := ""
111+ if fs.formTagSearch != nil {
112+ query = fs.formTagSearch.GetText()
113+ }
114+ fs.renderFormTagMatches(query)
115+}
116+
117+func (fs *FavoritesSidebar) formTagIDsSnapshot() []entity.TagID {
118+ if fs == nil {
119+ return nil
120+ }
121+ fs.mu.RLock()
122+ defer fs.mu.RUnlock()
123+ ids := make([]entity.TagID, 0, len(fs.formTagIDs))
124+ for id := range fs.formTagIDs {
125+ ids = append(ids, id)
126+ }
127+ return ids
128+}
129+
130+func (fs *FavoritesSidebar) updateFavoriteTags(favoriteID entity.FavoriteID, wanted []entity.TagID) bool {
131+ if fs == nil {
132+ return false
133+ }
134+ fs.mu.RLock()
135+ uc, ctx := fs.favoritesUC, fs.ctx
136+ var current []entity.Tag
137+ for _, fav := range fs.allFavorites {
138+ if fav != nil && fav.ID == favoriteID {
139+ current = append(current, fav.Tags...)
140+ break
141+ }
142+ }
143+ fs.mu.RUnlock()
144+ if uc == nil {
145+ return false
146+ }
147+ wantedSet := make(map[entity.TagID]struct{}, len(wanted))
148+ for _, id := range wanted {
149+ wantedSet[id] = struct{}{}
150+ }
151+ currentSet := tagIDSet(current)
152+ mutated := false
153+ for id := range currentSet {
154+ if _, ok := wantedSet[id]; ok {
155+ continue
156+ }
157+ if err := uc.UntagFavorite(ctx, favoriteID, id); err != nil {
158+ if mutated {
159+ fs.startLoad()
160+ }
161+ fs.setNotice(err.Error())
162+ return false
163+ }
164+ mutated = true
165+ }
166+ for id := range wantedSet {
167+ if _, ok := currentSet[id]; ok {
168+ continue
169+ }
170+ if err := uc.TagFavorite(ctx, favoriteID, id); err != nil {
171+ if mutated {
172+ fs.startLoad()
173+ }
174+ fs.setNotice(err.Error())
175+ return false
176+ }
177+ mutated = true
178+ }
179+ return true
180+}
M internal/ui/component/favorites_sidebar_forms.go +34 -94
@@ -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()
@@ -74,20 +75,23 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
7475 return
7576 }
7677 clearBoxChildren(fs.formBox)
78+ fs.formCallbacks = nil
79+ fs.formTagMatchCallbacks = nil
7780 fs.formURLEntry = gtk.NewSearchEntry()
7881 fs.formTitleEntry = gtk.NewSearchEntry()
79- fs.formTagsEntry = nil
80- if fav == nil {
81- fs.formTagsEntry = gtk.NewSearchEntry()
82- }
82+ fs.formTagSearch = gtk.NewSearchEntry()
83+ fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)
8384 fs.formShortcutEntry = gtk.NewSearchEntry()
85+ fs.mu.Lock()
86+ fs.formTagIDs = tagIDSet(favoriteTags(fav))
87+ fs.mu.Unlock()
8488 entries := []struct {
8589 entry *gtk.SearchEntry
8690 label string
8791 }{
8892 {entry: fs.formURLEntry, label: "URL"},
8993 {entry: fs.formTitleEntry, label: "Title"},
90- {entry: fs.formTagsEntry, label: "Tag IDs"},
94+ {entry: fs.formTagSearch, label: "Add tags..."},
9195 {entry: fs.formShortcutEntry, label: "Shortcut"},
9296 }
9397 for _, item := range entries {
@@ -96,21 +100,26 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
96100 }
97101 placeholder := item.label
98102 item.entry.SetPlaceholderText(&placeholder)
103+ fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks)
99104 fs.formBox.Append(&item.entry.Widget)
100105 }
106+ if fs.formTagMatches != nil {
107+ fs.formTagMatches.AddCssClass("favorites-sidebar-form-tag-matches")
108+ fs.formBox.Append(&fs.formTagMatches.Widget)
109+ }
110+ fs.setupFormTagSearch()
101111 fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102112 if fs.formSaveButton != nil {
103113 cb := func(_ gtk.Button) {
104114 fs.submitForm()
105115 }
106- fs.retainedCallbacks = append(fs.retainedCallbacks, cb)
116+ fs.formCallbacks = append(fs.formCallbacks, cb)
107117 fs.formSaveButton.ConnectClicked(&cb)
108118 fs.formBox.Append(&fs.formSaveButton.Widget)
109119 }
110120 if fav != nil {
111121 fs.formURL = fav.URL
112122 fs.formTitle = fav.Title
113- fs.formTags = joinFavoriteTagIDs(fav.Tags)
114123 fs.formShortcut = ""
115124 if fav.ShortcutKey != nil {
116125 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
@@ -122,15 +131,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
122131 if fs.formTitleEntry != nil {
123132 fs.formTitleEntry.SetText(fs.formTitle)
124133 }
125- if fs.formTagsEntry != nil {
126- fs.formTagsEntry.SetText(fs.formTags)
127- }
128134 if fs.formShortcutEntry != nil {
129135 fs.formShortcutEntry.SetText(fs.formShortcut)
130136 }
131137 } else {
132- fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""
138+ fs.formURL, fs.formTitle, fs.formShortcut = "", "", ""
133139 }
140+ fs.renderFormTagMatches("")
134141 fs.formBox.SetVisible(true)
135142 fs.focusForm()
136143 }
@@ -176,6 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
176183 if fs.formBox != nil {
177184 fs.formBox.SetVisible(false)
178185 clearBoxChildren(fs.formBox)
186+ fs.formCallbacks = nil
187+ fs.formTagMatchCallbacks = nil
188+ }
189+ if fs.tagPromptBox != nil {
190+ fs.hideTagPrompt()
179191 }
180192 fs.rebuildList()
181193 return active
@@ -194,13 +206,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
194206 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195207 return false
196208 }
197- url, title, tagsText, shortcutText := fs.formValues()
209+ url, title, shortcutText := fs.formValues()
210+ tags := fs.formTagIDsSnapshot()
198211 if mode == favoritesSidebarModeAdd {
199- tags, err := parseTagIDs(tagsText)
200- if err != nil {
201- fs.setNotice(err.Error())
202- return true
203- }
204212 key, err := parseShortcut(shortcutText)
205213 if err != nil {
206214 fs.setNotice(err.Error())
@@ -231,15 +239,15 @@ func (fs *FavoritesSidebar) submitForm() bool {
231239 fs.setNotice(err.Error())
232240 return true
233241 }
242+ if !fs.updateFavoriteTags(id, tags) {
243+ return true
244+ }
234245 }
235246 fs.cancelManagement()
236247 fs.startLoad()
237248 return true
238249 }
239250
240-func (fs *FavoritesSidebar) enterTagMode() {
241- fs.setModeNotice(favoritesSidebarModeTag, "Tag mode: press 1-9 to toggle visible tag for selected favorite")
242-}
243251 func (fs *FavoritesSidebar) enterShortcutMode() {
244252 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245253 }
@@ -256,43 +264,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
256264 fs.rebuildList()
257265 }
258266
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-
296267 func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297268 fav := fs.selectedFavorite()
298269 if fav == nil {
@@ -402,41 +373,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) {
402373 fs.rebuildList()
403374 }
404375
405-func (fs *FavoritesSidebar) formValues() (string, string, string, string) {
406- url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut
376+func (fs *FavoritesSidebar) formValues() (string, string, string) {
377+ url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut
407378 if fs.formURLEntry != nil {
408379 url = fs.formURLEntry.GetText()
409380 }
410381 if fs.formTitleEntry != nil {
411382 title = fs.formTitleEntry.GetText()
412383 }
413- if fs.formTagsEntry != nil {
414- tagsText = fs.formTagsEntry.GetText()
415- }
416384 if fs.formShortcutEntry != nil {
417385 shortcut = fs.formShortcutEntry.GetText()
418386 }
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
387+ return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(shortcut)
440388 }
441389
442390 func parseShortcut(text string) (*int, error) {
@@ -450,11 +398,3 @@ func parseShortcut(text string) (*int, error) {
450398 }
451399 return &v, nil
452400 }
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()
@@ -74,20 +75,23 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
74 return75 return
75 }76 }
76 clearBoxChildren(fs.formBox)77 clearBoxChildren(fs.formBox)
78+ fs.formCallbacks = nil
79+ fs.formTagMatchCallbacks = nil
77 fs.formURLEntry = gtk.NewSearchEntry()80 fs.formURLEntry = gtk.NewSearchEntry()
78 fs.formTitleEntry = gtk.NewSearchEntry()81 fs.formTitleEntry = gtk.NewSearchEntry()
79- fs.formTagsEntry = nil82+ fs.formTagSearch = gtk.NewSearchEntry()
80- if fav == nil {83+ fs.formTagMatches = gtk.NewBox(gtk.OrientationHorizontalValue, 3)
81- fs.formTagsEntry = gtk.NewSearchEntry()
82- }
83 fs.formShortcutEntry = gtk.NewSearchEntry()84 fs.formShortcutEntry = gtk.NewSearchEntry()
85+ fs.mu.Lock()
86+ fs.formTagIDs = tagIDSet(favoriteTags(fav))
87+ fs.mu.Unlock()
84 entries := []struct {88 entries := []struct {
85 entry *gtk.SearchEntry89 entry *gtk.SearchEntry
86 label string90 label string
87 }{91 }{
88 {entry: fs.formURLEntry, label: "URL"},92 {entry: fs.formURLEntry, label: "URL"},
89 {entry: fs.formTitleEntry, label: "Title"},93 {entry: fs.formTitleEntry, label: "Title"},
90- {entry: fs.formTagsEntry, label: "Tag IDs"},94+ {entry: fs.formTagSearch, label: "Add tags..."},
91 {entry: fs.formShortcutEntry, label: "Shortcut"},95 {entry: fs.formShortcutEntry, label: "Shortcut"},
92 }96 }
93 for _, item := range entries {97 for _, item := range entries {
@@ -96,21 +100,26 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
96 }100 }
97 placeholder := item.label101 placeholder := item.label
98 item.entry.SetPlaceholderText(&placeholder)102 item.entry.SetPlaceholderText(&placeholder)
103+ fs.trackTextInputFocus(&item.entry.Widget, &fs.formCallbacks)
99 fs.formBox.Append(&item.entry.Widget)104 fs.formBox.Append(&item.entry.Widget)
100 }105 }
106+ if fs.formTagMatches != nil {
107+ fs.formTagMatches.AddCssClass("favorites-sidebar-form-tag-matches")
108+ fs.formBox.Append(&fs.formTagMatches.Widget)
109+ }
110+ fs.setupFormTagSearch()
101 fs.formSaveButton = gtk.NewButtonWithLabel("Save")111 fs.formSaveButton = gtk.NewButtonWithLabel("Save")
102 if fs.formSaveButton != nil {112 if fs.formSaveButton != nil {
103 cb := func(_ gtk.Button) {113 cb := func(_ gtk.Button) {
104 fs.submitForm()114 fs.submitForm()
105 }115 }
106- fs.retainedCallbacks = append(fs.retainedCallbacks, cb)116+ fs.formCallbacks = append(fs.formCallbacks, cb)
107 fs.formSaveButton.ConnectClicked(&cb)117 fs.formSaveButton.ConnectClicked(&cb)
108 fs.formBox.Append(&fs.formSaveButton.Widget)118 fs.formBox.Append(&fs.formSaveButton.Widget)
109 }119 }
110 if fav != nil {120 if fav != nil {
111 fs.formURL = fav.URL121 fs.formURL = fav.URL
112 fs.formTitle = fav.Title122 fs.formTitle = fav.Title
113- fs.formTags = joinFavoriteTagIDs(fav.Tags)
114 fs.formShortcut = ""123 fs.formShortcut = ""
115 if fav.ShortcutKey != nil {124 if fav.ShortcutKey != nil {
116 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)125 fs.formShortcut = strconv.Itoa(*fav.ShortcutKey)
@@ -122,15 +131,13 @@ func (fs *FavoritesSidebar) renderForm(fav *entity.Favorite) {
122 if fs.formTitleEntry != nil {131 if fs.formTitleEntry != nil {
123 fs.formTitleEntry.SetText(fs.formTitle)132 fs.formTitleEntry.SetText(fs.formTitle)
124 }133 }
125- if fs.formTagsEntry != nil {
126- fs.formTagsEntry.SetText(fs.formTags)
127- }
128 if fs.formShortcutEntry != nil {134 if fs.formShortcutEntry != nil {
129 fs.formShortcutEntry.SetText(fs.formShortcut)135 fs.formShortcutEntry.SetText(fs.formShortcut)
130 }136 }
131 } else {137 } else {
132- fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut = "", "", "", ""138+ fs.formURL, fs.formTitle, fs.formShortcut = "", "", ""
133 }139 }
140+ fs.renderFormTagMatches("")
134 fs.formBox.SetVisible(true)141 fs.formBox.SetVisible(true)
135 fs.focusForm()142 fs.focusForm()
136 }143 }
@@ -176,6 +183,11 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
176 if fs.formBox != nil {183 if fs.formBox != nil {
177 fs.formBox.SetVisible(false)184 fs.formBox.SetVisible(false)
178 clearBoxChildren(fs.formBox)185 clearBoxChildren(fs.formBox)
186+ fs.formCallbacks = nil
187+ fs.formTagMatchCallbacks = nil
188+ }
189+ if fs.tagPromptBox != nil {
190+ fs.hideTagPrompt()
179 }191 }
180 fs.rebuildList()192 fs.rebuildList()
181 return active193 return active
@@ -194,13 +206,9 @@ func (fs *FavoritesSidebar) submitForm() bool {
194 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {206 if uc == nil || (mode != favoritesSidebarModeAdd && mode != favoritesSidebarModeEdit) {
195 return false207 return false
196 }208 }
197- url, title, tagsText, shortcutText := fs.formValues()209+ url, title, shortcutText := fs.formValues()
210+ tags := fs.formTagIDsSnapshot()
198 if mode == favoritesSidebarModeAdd {211 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)212 key, err := parseShortcut(shortcutText)
205 if err != nil {213 if err != nil {
206 fs.setNotice(err.Error())214 fs.setNotice(err.Error())
@@ -231,15 +239,15 @@ func (fs *FavoritesSidebar) submitForm() bool {
231 fs.setNotice(err.Error())239 fs.setNotice(err.Error())
232 return true240 return true
233 }241 }
242+ if !fs.updateFavoriteTags(id, tags) {
243+ return true
244+ }
234 }245 }
235 fs.cancelManagement()246 fs.cancelManagement()
236 fs.startLoad()247 fs.startLoad()
237 return true248 return true
238 }249 }
239 250
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() {251 func (fs *FavoritesSidebar) enterShortcutMode() {
244 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")252 fs.setModeNotice(favoritesSidebarModeShortcut, "Shortcut mode: press 1-9 to assign, Backspace/Delete to clear")
245 }253 }
@@ -256,43 +264,6 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
256 fs.rebuildList()264 fs.rebuildList()
257 }265 }
258 266
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 {267 func (fs *FavoritesSidebar) setShortcutKey(key *int) bool {
297 fav := fs.selectedFavorite()268 fav := fs.selectedFavorite()
298 if fav == nil {269 if fav == nil {
@@ -402,41 +373,18 @@ func (fs *FavoritesSidebar) setNotice(notice string) {
402 fs.rebuildList()373 fs.rebuildList()
403 }374 }
404 375
405-func (fs *FavoritesSidebar) formValues() (string, string, string, string) {376+func (fs *FavoritesSidebar) formValues() (string, string, string) {
406- url, title, tagsText, shortcut := fs.formURL, fs.formTitle, fs.formTags, fs.formShortcut377+ url, title, shortcut := fs.formURL, fs.formTitle, fs.formShortcut
407 if fs.formURLEntry != nil {378 if fs.formURLEntry != nil {
408 url = fs.formURLEntry.GetText()379 url = fs.formURLEntry.GetText()
409 }380 }
410 if fs.formTitleEntry != nil {381 if fs.formTitleEntry != nil {
411 title = fs.formTitleEntry.GetText()382 title = fs.formTitleEntry.GetText()
412 }383 }
413- if fs.formTagsEntry != nil {
414- tagsText = fs.formTagsEntry.GetText()
415- }
416 if fs.formShortcutEntry != nil {384 if fs.formShortcutEntry != nil {
417 shortcut = fs.formShortcutEntry.GetText()385 shortcut = fs.formShortcutEntry.GetText()
418 }386 }
419- return strings.TrimSpace(url), strings.TrimSpace(title), strings.TrimSpace(tagsText), strings.TrimSpace(shortcut)387+ 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 }388 }
441 389
442 func parseShortcut(text string) (*int, error) {390 func parseShortcut(text string) (*int, error) {
@@ -450,11 +398,3 @@ func parseShortcut(text string) (*int, error) {
450 }398 }
451 return &v, nil399 return &v, nil
452 }400 }
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 +150 -17
@@ -17,6 +17,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
1717 if keyController == nil {
1818 return
1919 }
20+ // Directional controls must run before GtkSearchEntry and GtkListBox consume
21+ // arrows. Explicit text-input focus tracking below still defers printable
22+ // keys to their focused entry.
2023 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
2124 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
2225 fs.mu.RLock()
@@ -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,97 @@ 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 traverses focus and Escape cancels sidebar management while text is focused.
311+func shouldDeferToTextInput(textEditing bool, keyval uint) bool {
312+ return textEditing && keyval != uint(gdk.KEY_Tab) &&
313+ keyval != uint(gdk.KEY_ISO_Left_Tab) && keyval != uint(gdk.KEY_Escape)
314+}
315+
316+func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {
283317 if fs == nil {
284318 return false
285319 }
286- if fs.searchEntry != nil && fs.searchEntry.HasFocus() {
320+ fs.mu.RLock()
321+ zone := fs.focusZone
322+ fs.mu.RUnlock()
323+ switch {
324+ case zone == favoritesSidebarFocusTags:
325+ // Tag filters and creation are deliberately Tab-only controls.
287326 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
327+ case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch:
328+ fs.focusListAndSelectFirst()
329+ return true
330+ case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst():
331+ fs.setFocusZone(favoritesSidebarFocusSearch)
332+ if fs.searchEntry != nil {
333+ fs.searchEntry.GrabFocus()
292334 }
335+ return true
293336 }
294337 return false
295338 }
296339
340+func (fs *FavoritesSidebar) focusListAndSelectFirst() {
341+ if fs == nil {
342+ return
343+ }
344+ fs.setFocusZone(favoritesSidebarFocusList)
345+ if fs.listBox == nil {
346+ return
347+ }
348+ fs.listBox.GrabFocus()
349+ fs.mu.RLock()
350+ index := firstSelectableIndex(fs.displayRows)
351+ fs.mu.RUnlock()
352+ fs.selectIndex(index)
353+}
354+
355+func (fs *FavoritesSidebar) selectedRowIsFirst() bool {
356+ if fs == nil {
357+ return false
358+ }
359+ if fs.listBox == nil {
360+ return true
361+ }
362+ row := fs.listBox.GetSelectedRow()
363+ if row == nil {
364+ return true
365+ }
366+ fs.mu.RLock()
367+ first := firstSelectableIndex(fs.displayRows)
368+ fs.mu.RUnlock()
369+ return row.GetIndex() == first
370+}
371+
372+func (fs *FavoritesSidebar) listHasFocus() bool {
373+ if fs == nil {
374+ return false
375+ }
376+ fs.mu.RLock()
377+ defer fs.mu.RUnlock()
378+ return fs.focusZone == favoritesSidebarFocusList
379+}
380+
381+func (fs *FavoritesSidebar) inTextEditContext() bool {
382+ if fs == nil {
383+ return false
384+ }
385+ fs.mu.RLock()
386+ defer fs.mu.RUnlock()
387+ return fs.textInputWidget != nil
388+}
389+
390+func (fs *FavoritesSidebar) isTextInputFocused(widget *gtk.Entry) bool {
391+ if fs == nil || widget == nil {
392+ return false
393+ }
394+ fs.mu.RLock()
395+ defer fs.mu.RUnlock()
396+ return fs.textInputWidget == &widget.Widget
397+}
398+
297399 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298400 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299401 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
@@ -303,9 +405,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
303405 mode := fs.mode
304406 confirm := fs.confirmDelete
305407 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- }
309408 if mode == favoritesSidebarModeShortcut {
310409 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311410 v := int(keyval - uint(gdk.KEY_0))
@@ -328,9 +427,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
328427 case uint(gdk.KEY_e):
329428 fs.beginEditForm()
330429 return true
331- case uint(gdk.KEY_t):
332- fs.enterTagMode()
333- return true
334430 case uint(gdk.KEY_s):
335431 fs.enterShortcutMode()
336432 return true
@@ -366,13 +462,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366462 if fs == nil {
367463 return
368464 }
369- zones := fs.availableFocusZones()
370- if len(zones) == 0 {
465+ if fs.cycleTagControlFocus(reverse) {
371466 return
372467 }
373468 fs.mu.RLock()
374469 current := fs.focusZone
470+ tagCount := len(fs.tagControls)
375471 fs.mu.RUnlock()
472+ if reverse && current == favoritesSidebarFocusList && tagCount > 0 {
473+ fs.focusTagControl(tagCount - 1)
474+ return
475+ }
476+ zones := fs.availableFocusZones()
477+ if len(zones) == 0 {
478+ return
479+ }
376480 idx := -1
377481 for i, zone := range zones {
378482 if zone == current {
@@ -394,6 +498,37 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
394498 fs.focusZoneWidget(zones[idx])
395499 }
396500
501+func (fs *FavoritesSidebar) cycleTagControlFocus(reverse bool) bool {
502+ fs.mu.RLock()
503+ inTags := fs.focusZone == favoritesSidebarFocusTags
504+ controls := append([]*gtk.Button(nil), fs.tagControls...)
505+ fs.mu.RUnlock()
506+ if !inTags || len(controls) == 0 {
507+ return false
508+ }
509+ index := fs.focusedTagControlIndex()
510+ if index < 0 {
511+ if reverse {
512+ fs.focusTagControl(len(controls) - 1)
513+ } else {
514+ fs.focusTagControl(0)
515+ }
516+ return true
517+ }
518+ if reverse {
519+ if index > 0 {
520+ fs.focusTagControl(index - 1)
521+ return true
522+ }
523+ return false
524+ }
525+ if index < len(controls)-1 {
526+ fs.focusTagControl(index + 1)
527+ return true
528+ }
529+ return false
530+}
531+
397532 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398533 zones := []favoritesSidebarFocusZone{}
399534 if fs.searchEntry != nil {
@@ -428,9 +563,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
428563 case favoritesSidebarFocusSearch:
429564 fs.focusSearch()
430565 case favoritesSidebarFocusTags:
431- if fs.tagBox != nil {
432- fs.tagBox.GrabFocus()
433- }
566+ fs.focusTagControl(0)
434567 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435568 if fs.listBox != nil {
436569 fs.listBox.GrabFocus()
@@ -17,6 +17,9 @@ func (fs *FavoritesSidebar) setupKeyboardNavigation() {
17 if keyController == nil {17 if keyController == nil {
18 return18 return
19 }19 }
20+ // Directional controls must run before GtkSearchEntry and GtkListBox consume
21+ // arrows. Explicit text-input focus tracking below still defers printable
22+ // keys to their focused entry.
20 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)23 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
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()
@@ -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,97 @@ 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 traverses focus and Escape cancels sidebar management while text is focused.
311+func shouldDeferToTextInput(textEditing bool, keyval uint) bool {
312+ return textEditing && keyval != uint(gdk.KEY_Tab) &&
313+ keyval != uint(gdk.KEY_ISO_Left_Tab) && keyval != uint(gdk.KEY_Escape)
314+}
315+
316+func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {
283 if fs == nil {317 if fs == nil {
284 return false318 return false
285 }319 }
286- if fs.searchEntry != nil && fs.searchEntry.HasFocus() {320+ fs.mu.RLock()
321+ zone := fs.focusZone
322+ fs.mu.RUnlock()
323+ switch {
324+ case zone == favoritesSidebarFocusTags:
325+ // Tag filters and creation are deliberately Tab-only controls.
287 return true326 return true
288- }327+ case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch:
289- for _, entry := range []*gtk.SearchEntry{fs.formURLEntry, fs.formTitleEntry, fs.formTagsEntry, fs.formShortcutEntry} {328+ fs.focusListAndSelectFirst()
290- if entry != nil && entry.HasFocus() {329+ return true
291- return true330+ case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst():
331+ fs.setFocusZone(favoritesSidebarFocusSearch)
332+ if fs.searchEntry != nil {
333+ fs.searchEntry.GrabFocus()
292 }334 }
335+ return true
293 }336 }
294 return false337 return false
295 }338 }
296 339
340+func (fs *FavoritesSidebar) focusListAndSelectFirst() {
341+ if fs == nil {
342+ return
343+ }
344+ fs.setFocusZone(favoritesSidebarFocusList)
345+ if fs.listBox == nil {
346+ return
347+ }
348+ fs.listBox.GrabFocus()
349+ fs.mu.RLock()
350+ index := firstSelectableIndex(fs.displayRows)
351+ fs.mu.RUnlock()
352+ fs.selectIndex(index)
353+}
354+
355+func (fs *FavoritesSidebar) selectedRowIsFirst() bool {
356+ if fs == nil {
357+ return false
358+ }
359+ if fs.listBox == nil {
360+ return true
361+ }
362+ row := fs.listBox.GetSelectedRow()
363+ if row == nil {
364+ return true
365+ }
366+ fs.mu.RLock()
367+ first := firstSelectableIndex(fs.displayRows)
368+ fs.mu.RUnlock()
369+ return row.GetIndex() == first
370+}
371+
372+func (fs *FavoritesSidebar) listHasFocus() bool {
373+ if fs == nil {
374+ return false
375+ }
376+ fs.mu.RLock()
377+ defer fs.mu.RUnlock()
378+ return fs.focusZone == favoritesSidebarFocusList
379+}
380+
381+func (fs *FavoritesSidebar) inTextEditContext() bool {
382+ if fs == nil {
383+ return false
384+ }
385+ fs.mu.RLock()
386+ defer fs.mu.RUnlock()
387+ return fs.textInputWidget != nil
388+}
389+
390+func (fs *FavoritesSidebar) isTextInputFocused(widget *gtk.Entry) bool {
391+ if fs == nil || widget == nil {
392+ return false
393+ }
394+ fs.mu.RLock()
395+ defer fs.mu.RUnlock()
396+ return fs.textInputWidget == &widget.Widget
397+}
398+
297 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {399 func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
298 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue400 shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
299 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {401 if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
@@ -303,9 +405,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
303 mode := fs.mode405 mode := fs.mode
304 confirm := fs.confirmDelete406 confirm := fs.confirmDelete
305 fs.mu.RUnlock()407 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 {408 if mode == favoritesSidebarModeShortcut {
310 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {409 if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
311 v := int(keyval - uint(gdk.KEY_0))410 v := int(keyval - uint(gdk.KEY_0))
@@ -328,9 +427,6 @@ func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.Modifi
328 case uint(gdk.KEY_e):427 case uint(gdk.KEY_e):
329 fs.beginEditForm()428 fs.beginEditForm()
330 return true429 return true
331- case uint(gdk.KEY_t):
332- fs.enterTagMode()
333- return true
334 case uint(gdk.KEY_s):430 case uint(gdk.KEY_s):
335 fs.enterShortcutMode()431 fs.enterShortcutMode()
336 return true432 return true
@@ -366,13 +462,21 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
366 if fs == nil {462 if fs == nil {
367 return463 return
368 }464 }
369- zones := fs.availableFocusZones()465+ if fs.cycleTagControlFocus(reverse) {
370- if len(zones) == 0 {
371 return466 return
372 }467 }
373 fs.mu.RLock()468 fs.mu.RLock()
374 current := fs.focusZone469 current := fs.focusZone
470+ tagCount := len(fs.tagControls)
375 fs.mu.RUnlock()471 fs.mu.RUnlock()
472+ if reverse && current == favoritesSidebarFocusList && tagCount > 0 {
473+ fs.focusTagControl(tagCount - 1)
474+ return
475+ }
476+ zones := fs.availableFocusZones()
477+ if len(zones) == 0 {
478+ return
479+ }
376 idx := -1480 idx := -1
377 for i, zone := range zones {481 for i, zone := range zones {
378 if zone == current {482 if zone == current {
@@ -394,6 +498,37 @@ func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
394 fs.focusZoneWidget(zones[idx])498 fs.focusZoneWidget(zones[idx])
395 }499 }
396 500
501+func (fs *FavoritesSidebar) cycleTagControlFocus(reverse bool) bool {
502+ fs.mu.RLock()
503+ inTags := fs.focusZone == favoritesSidebarFocusTags
504+ controls := append([]*gtk.Button(nil), fs.tagControls...)
505+ fs.mu.RUnlock()
506+ if !inTags || len(controls) == 0 {
507+ return false
508+ }
509+ index := fs.focusedTagControlIndex()
510+ if index < 0 {
511+ if reverse {
512+ fs.focusTagControl(len(controls) - 1)
513+ } else {
514+ fs.focusTagControl(0)
515+ }
516+ return true
517+ }
518+ if reverse {
519+ if index > 0 {
520+ fs.focusTagControl(index - 1)
521+ return true
522+ }
523+ return false
524+ }
525+ if index < len(controls)-1 {
526+ fs.focusTagControl(index + 1)
527+ return true
528+ }
529+ return false
530+}
531+
397 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {532 func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
398 zones := []favoritesSidebarFocusZone{}533 zones := []favoritesSidebarFocusZone{}
399 if fs.searchEntry != nil {534 if fs.searchEntry != nil {
@@ -428,9 +563,7 @@ func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
428 case favoritesSidebarFocusSearch:563 case favoritesSidebarFocusSearch:
429 fs.focusSearch()564 fs.focusSearch()
430 case favoritesSidebarFocusTags:565 case favoritesSidebarFocusTags:
431- if fs.tagBox != nil {566+ fs.focusTagControl(0)
432- fs.tagBox.GrabFocus()
433- }
434 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:567 case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
435 if fs.listBox != nil {568 if fs.listBox != nil {
436 fs.listBox.GrabFocus()569 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("favorites-sidebar-tag-filter-active")
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("favorites-sidebar-tag-filter-active")
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 +244 -0
@@ -0,0 +1,244 @@
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+ fs.hideTagPrompt()
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+ callbacks := make([]any, 0, 4)
24+ fs.trackTextInputFocus(&entry.Widget, &callbacks)
25+ entry.SetHexpand(true)
26+ activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) }
27+ callbacks = append(callbacks, activateCb)
28+ entry.ConnectActivate(&activateCb)
29+ fs.storeTagPromptCallbacks(callbacks)
30+
31+ save := gtk.NewButtonWithLabel("Create")
32+ if save == nil {
33+ fs.hideTagPrompt()
34+ return
35+ }
36+ save.AddCssClass("favorites-sidebar-tag-create")
37+ cb := func(_ gtk.Button) { fs.createTag(entry.GetText()) }
38+ callbacks = append(callbacks, cb)
39+ save.ConnectClicked(&cb)
40+
41+ fs.tagNameEntry = entry
42+ fs.tagPromptSaveBtn = save
43+ fs.storeTagPromptCallbacks(callbacks)
44+ fs.tagPromptBox.Append(&entry.Widget)
45+ fs.tagPromptBox.Append(&save.Widget)
46+ fs.tagPromptBox.SetVisible(true)
47+ fs.mu.Lock()
48+ if !fs.destroyed {
49+ fs.mode = favoritesSidebarModeCreateTag
50+ }
51+ fs.mu.Unlock()
52+ entry.GrabFocus()
53+}
54+
55+func (fs *FavoritesSidebar) submitTagName(name string) bool {
56+ return fs.createTag(name)
57+}
58+
59+func (fs *FavoritesSidebar) createTag(name string) bool {
60+ if fs == nil {
61+ return false
62+ }
63+ name = strings.TrimSpace(name)
64+ if name == "" {
65+ fs.setNotice("Tag name is required")
66+ return true
67+ }
68+ fs.mu.RLock()
69+ uc, ctx := fs.favoritesUC, fs.ctx
70+ fs.mu.RUnlock()
71+ if uc == nil {
72+ return false
73+ }
74+ if _, err := uc.AddTag(ctx, name, ""); err != nil {
75+ fs.setNotice(err.Error())
76+ return true
77+ }
78+ fs.hideTagPrompt()
79+ fs.startLoad()
80+ return true
81+}
82+
83+func (fs *FavoritesSidebar) showTagBindingPicker() bool {
84+ if fs == nil || fs.tagPromptBox == nil {
85+ return false
86+ }
87+ fav := fs.selectedFavorite()
88+ if fav == nil {
89+ fs.setNotice("Select a favorite to manage tags")
90+ return true
91+ }
92+ fs.mu.RLock()
93+ tags := append([]*entity.Tag(nil), fs.allTags...)
94+ fs.mu.RUnlock()
95+ fs.clearTagPromptContent()
96+ callbacks := make([]any, 0, len(tags))
97+ for _, tag := range tags {
98+ if tag == nil {
99+ continue
100+ }
101+ t := *tag
102+ label := t.Name
103+ if favoriteHasTag(fav, t.ID) {
104+ label = "✓ " + label
105+ }
106+ button := gtk.NewButtonWithLabel(label)
107+ if button == nil {
108+ continue
109+ }
110+ button.AddCssClass("favorites-sidebar-tag-binding")
111+ cb := func(_ gtk.Button) { fs.toggleFavoriteTag(fav.ID, t.ID) }
112+ callbacks = append(callbacks, cb)
113+ button.ConnectClicked(&cb)
114+ fs.tagPromptBox.Append(&button.Widget)
115+ }
116+ fs.tagNameEntry = nil
117+ fs.tagPromptSaveBtn = nil
118+ fs.storeTagPromptCallbacks(callbacks)
119+ fs.tagPromptBox.SetVisible(true)
120+ fs.mu.Lock()
121+ if !fs.destroyed {
122+ fs.mode = favoritesSidebarModeBindTag
123+ }
124+ fs.mu.Unlock()
125+ return true
126+}
127+
128+func (fs *FavoritesSidebar) hideTagPrompt() {
129+ if fs == nil || fs.tagPromptBox == nil {
130+ return
131+ }
132+ fs.clearTagPromptContent()
133+ fs.tagPromptBox.SetVisible(false)
134+ fs.tagNameEntry = nil
135+ fs.tagPromptSaveBtn = nil
136+ fs.mu.Lock()
137+ if !fs.destroyed && (fs.mode == favoritesSidebarModeCreateTag || fs.mode == favoritesSidebarModeBindTag) {
138+ fs.mode = favoritesSidebarModeNone
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) clearTagPromptContent() {
144+ if fs == nil || fs.tagPromptBox == nil {
145+ return
146+ }
147+ clearBoxChildren(fs.tagPromptBox)
148+ fs.mu.Lock()
149+ fs.tagPromptCallbacks = nil
150+ fs.mu.Unlock()
151+}
152+
153+func (fs *FavoritesSidebar) storeTagPromptCallbacks(callbacks []any) {
154+ if fs == nil {
155+ return
156+ }
157+ fs.mu.Lock()
158+ if !fs.destroyed {
159+ fs.tagPromptCallbacks = callbacks
160+ }
161+ fs.mu.Unlock()
162+}
163+
164+func (fs *FavoritesSidebar) toggleFavoriteTag(favoriteID entity.FavoriteID, tagID entity.TagID) {
165+ fs.mu.RLock()
166+ uc, ctx := fs.favoritesUC, fs.ctx
167+ var has bool
168+ for _, fav := range fs.allFavorites {
169+ if fav == nil || fav.ID != favoriteID {
170+ continue
171+ }
172+ has = favoriteHasTag(fav, tagID)
173+ break
174+ }
175+ fs.mu.RUnlock()
176+ if uc == nil {
177+ return
178+ }
179+ var err error
180+ if has {
181+ err = uc.UntagFavorite(ctx, favoriteID, tagID)
182+ } else {
183+ err = uc.TagFavorite(ctx, favoriteID, tagID)
184+ }
185+ if err != nil {
186+ fs.setNotice(err.Error())
187+ return
188+ }
189+ fs.hideTagPrompt()
190+ fs.startLoad()
191+}
192+
193+func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool {
194+ if fav == nil {
195+ return false
196+ }
197+ for _, tag := range fav.Tags {
198+ if tag.ID == tagID {
199+ return true
200+ }
201+ }
202+ return false
203+}
204+
205+func (fs *FavoritesSidebar) focusTagControl(index int) {
206+ if fs == nil || index < 0 {
207+ return
208+ }
209+ fs.mu.RLock()
210+ controls := append([]*gtk.Button(nil), fs.tagControls...)
211+ fs.mu.RUnlock()
212+ if index >= len(controls) || controls[index] == nil {
213+ return
214+ }
215+ control := controls[index]
216+ fs.mu.Lock()
217+ if !fs.destroyed {
218+ fs.focusZone = favoritesSidebarFocusTags
219+ }
220+ fs.mu.Unlock()
221+ control.GrabFocus()
222+}
223+
224+func (fs *FavoritesSidebar) focusedTagControlIndex() int {
225+ if fs == nil {
226+ return -1
227+ }
228+ fs.mu.RLock()
229+ controls := append([]*gtk.Button(nil), fs.tagControls...)
230+ fs.mu.RUnlock()
231+ for index, control := range controls {
232+ if control != nil && control.HasFocus() {
233+ return index
234+ }
235+ }
236+ return -1
237+}
238+
239+func (fs *FavoritesSidebar) createTagFromFocusedEntry() bool {
240+ if fs == nil || fs.tagNameEntry == nil || !fs.tagNameEntry.HasFocus() {
241+ return false
242+ }
243+ return fs.createTag(fs.tagNameEntry.GetText())
244+}
@@ -0,0 +1,244 @@
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+ fs.hideTagPrompt()
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+ callbacks := make([]any, 0, 4)
24+ fs.trackTextInputFocus(&entry.Widget, &callbacks)
25+ entry.SetHexpand(true)
26+ activateCb := func(gtk.Entry) { fs.submitTagName(entry.GetText()) }
27+ callbacks = append(callbacks, activateCb)
28+ entry.ConnectActivate(&activateCb)
29+ fs.storeTagPromptCallbacks(callbacks)
30+
31+ save := gtk.NewButtonWithLabel("Create")
32+ if save == nil {
33+ fs.hideTagPrompt()
34+ return
35+ }
36+ save.AddCssClass("favorites-sidebar-tag-create")
37+ cb := func(_ gtk.Button) { fs.createTag(entry.GetText()) }
38+ callbacks = append(callbacks, cb)
39+ save.ConnectClicked(&cb)
40+
41+ fs.tagNameEntry = entry
42+ fs.tagPromptSaveBtn = save
43+ fs.storeTagPromptCallbacks(callbacks)
44+ fs.tagPromptBox.Append(&entry.Widget)
45+ fs.tagPromptBox.Append(&save.Widget)
46+ fs.tagPromptBox.SetVisible(true)
47+ fs.mu.Lock()
48+ if !fs.destroyed {
49+ fs.mode = favoritesSidebarModeCreateTag
50+ }
51+ fs.mu.Unlock()
52+ entry.GrabFocus()
53+}
54+
55+func (fs *FavoritesSidebar) submitTagName(name string) bool {
56+ return fs.createTag(name)
57+}
58+
59+func (fs *FavoritesSidebar) createTag(name string) bool {
60+ if fs == nil {
61+ return false
62+ }
63+ name = strings.TrimSpace(name)
64+ if name == "" {
65+ fs.setNotice("Tag name is required")
66+ return true
67+ }
68+ fs.mu.RLock()
69+ uc, ctx := fs.favoritesUC, fs.ctx
70+ fs.mu.RUnlock()
71+ if uc == nil {
72+ return false
73+ }
74+ if _, err := uc.AddTag(ctx, name, ""); err != nil {
75+ fs.setNotice(err.Error())
76+ return true
77+ }
78+ fs.hideTagPrompt()
79+ fs.startLoad()
80+ return true
81+}
82+
83+func (fs *FavoritesSidebar) showTagBindingPicker() bool {
84+ if fs == nil || fs.tagPromptBox == nil {
85+ return false
86+ }
87+ fav := fs.selectedFavorite()
88+ if fav == nil {
89+ fs.setNotice("Select a favorite to manage tags")
90+ return true
91+ }
92+ fs.mu.RLock()
93+ tags := append([]*entity.Tag(nil), fs.allTags...)
94+ fs.mu.RUnlock()
95+ fs.clearTagPromptContent()
96+ callbacks := make([]any, 0, len(tags))
97+ for _, tag := range tags {
98+ if tag == nil {
99+ continue
100+ }
101+ t := *tag
102+ label := t.Name
103+ if favoriteHasTag(fav, t.ID) {
104+ label = "✓ " + label
105+ }
106+ button := gtk.NewButtonWithLabel(label)
107+ if button == nil {
108+ continue
109+ }
110+ button.AddCssClass("favorites-sidebar-tag-binding")
111+ cb := func(_ gtk.Button) { fs.toggleFavoriteTag(fav.ID, t.ID) }
112+ callbacks = append(callbacks, cb)
113+ button.ConnectClicked(&cb)
114+ fs.tagPromptBox.Append(&button.Widget)
115+ }
116+ fs.tagNameEntry = nil
117+ fs.tagPromptSaveBtn = nil
118+ fs.storeTagPromptCallbacks(callbacks)
119+ fs.tagPromptBox.SetVisible(true)
120+ fs.mu.Lock()
121+ if !fs.destroyed {
122+ fs.mode = favoritesSidebarModeBindTag
123+ }
124+ fs.mu.Unlock()
125+ return true
126+}
127+
128+func (fs *FavoritesSidebar) hideTagPrompt() {
129+ if fs == nil || fs.tagPromptBox == nil {
130+ return
131+ }
132+ fs.clearTagPromptContent()
133+ fs.tagPromptBox.SetVisible(false)
134+ fs.tagNameEntry = nil
135+ fs.tagPromptSaveBtn = nil
136+ fs.mu.Lock()
137+ if !fs.destroyed && (fs.mode == favoritesSidebarModeCreateTag || fs.mode == favoritesSidebarModeBindTag) {
138+ fs.mode = favoritesSidebarModeNone
139+ }
140+ fs.mu.Unlock()
141+}
142+
143+func (fs *FavoritesSidebar) clearTagPromptContent() {
144+ if fs == nil || fs.tagPromptBox == nil {
145+ return
146+ }
147+ clearBoxChildren(fs.tagPromptBox)
148+ fs.mu.Lock()
149+ fs.tagPromptCallbacks = nil
150+ fs.mu.Unlock()
151+}
152+
153+func (fs *FavoritesSidebar) storeTagPromptCallbacks(callbacks []any) {
154+ if fs == nil {
155+ return
156+ }
157+ fs.mu.Lock()
158+ if !fs.destroyed {
159+ fs.tagPromptCallbacks = callbacks
160+ }
161+ fs.mu.Unlock()
162+}
163+
164+func (fs *FavoritesSidebar) toggleFavoriteTag(favoriteID entity.FavoriteID, tagID entity.TagID) {
165+ fs.mu.RLock()
166+ uc, ctx := fs.favoritesUC, fs.ctx
167+ var has bool
168+ for _, fav := range fs.allFavorites {
169+ if fav == nil || fav.ID != favoriteID {
170+ continue
171+ }
172+ has = favoriteHasTag(fav, tagID)
173+ break
174+ }
175+ fs.mu.RUnlock()
176+ if uc == nil {
177+ return
178+ }
179+ var err error
180+ if has {
181+ err = uc.UntagFavorite(ctx, favoriteID, tagID)
182+ } else {
183+ err = uc.TagFavorite(ctx, favoriteID, tagID)
184+ }
185+ if err != nil {
186+ fs.setNotice(err.Error())
187+ return
188+ }
189+ fs.hideTagPrompt()
190+ fs.startLoad()
191+}
192+
193+func favoriteHasTag(fav *entity.Favorite, tagID entity.TagID) bool {
194+ if fav == nil {
195+ return false
196+ }
197+ for _, tag := range fav.Tags {
198+ if tag.ID == tagID {
199+ return true
200+ }
201+ }
202+ return false
203+}
204+
205+func (fs *FavoritesSidebar) focusTagControl(index int) {
206+ if fs == nil || index < 0 {
207+ return
208+ }
209+ fs.mu.RLock()
210+ controls := append([]*gtk.Button(nil), fs.tagControls...)
211+ fs.mu.RUnlock()
212+ if index >= len(controls) || controls[index] == nil {
213+ return
214+ }
215+ control := controls[index]
216+ fs.mu.Lock()
217+ if !fs.destroyed {
218+ fs.focusZone = favoritesSidebarFocusTags
219+ }
220+ fs.mu.Unlock()
221+ control.GrabFocus()
222+}
223+
224+func (fs *FavoritesSidebar) focusedTagControlIndex() int {
225+ if fs == nil {
226+ return -1
227+ }
228+ fs.mu.RLock()
229+ controls := append([]*gtk.Button(nil), fs.tagControls...)
230+ fs.mu.RUnlock()
231+ for index, control := range controls {
232+ if control != nil && control.HasFocus() {
233+ return index
234+ }
235+ }
236+ return -1
237+}
238+
239+func (fs *FavoritesSidebar) createTagFromFocusedEntry() bool {
240+ if fs == nil || fs.tagNameEntry == nil || !fs.tagNameEntry.HasFocus() {
241+ return false
242+ }
243+ return fs.createTag(fs.tagNameEntry.GetText())
244+}
M internal/ui/component/favorites_sidebar_test.go +199 -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,8 +35,15 @@ 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
44+ errOnTag map[entity.TagID]error
45+ errOnUntag map[entity.TagID]error
46+ failUntagOnCall int
3947 getAllCalled chan struct{}
4048 }
4149
@@ -51,6 +59,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
5159 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
5260 return f.tags, f.err
5361 }
62+func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) {
63+ f.createdTags = append(f.createdTags, struct {
64+ name string
65+ color string
66+ }{name: name, color: color})
67+ if f.err != nil {
68+ return nil, f.err
69+ }
70+ return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil
71+}
5472 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
5573 f.addInputs = append(f.addInputs, input)
5674 if f.err != nil {
@@ -84,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav
84102 favID entity.FavoriteID
85103 tagID entity.TagID
86104 }{favID: favID, tagID: tagID})
105+ if err := f.errOnTag[tagID]; err != nil {
106+ return err
107+ }
87108 return f.err
88109 }
89110 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -91,6 +112,12 @@ func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.F
91112 favID entity.FavoriteID
92113 tagID entity.TagID
93114 }{favID: favID, tagID: tagID})
115+ if err := f.errOnUntag[tagID]; err != nil {
116+ return err
117+ }
118+ if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
119+ return errors.New("untag failed")
120+ }
94121 return f.err
95122 }
96123
@@ -340,20 +367,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
340367 fs.favoritesUC = uc
341368 fs.formURL = "https://add.test"
342369 fs.formTitle = "Added"
343- fs.formTags = "1, 2"
370+ fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
344371 fs.formShortcut = "3"
345372 fs.mode = favoritesSidebarModeAdd
346373
347374 assert.True(t, fs.submitForm())
348375 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])
376+ assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags)
350377 require.Len(t, uc.shortcuts, 1)
351378 assert.Equal(t, 3, *uc.shortcuts[0].key)
352379
353380 fs.mode = favoritesSidebarModeEdit
354381 fs.editingID = 7
355382 fs.formTitle = "Edited"
356- fs.formTags = "999"
357383 fs.formShortcut = "4"
358384 assert.True(t, fs.submitForm())
359385 require.Len(t, uc.updateInputs, 1)
@@ -372,23 +398,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
372398 assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373399 }
374400
375-func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
401+func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
376402 uc := &fakeFavoritesSidebarUC{}
377403 fs := newFavoritesSidebarHarness(nil, nil)
378404 fs.favoritesUC = uc
379405 fs.formURL = "https://add.test"
380406 fs.formTitle = "Added"
381- fs.formTags = "1, nope"
407+ fs.formShortcut = "10"
382408 fs.mode = favoritesSidebarModeAdd
383409
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"
392410 assert.True(t, fs.submitForm())
393411 assert.Empty(t, uc.addInputs)
394412 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
@@ -396,6 +414,88 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
396414 assert.Equal(t, "10", fs.formShortcut)
397415 }
398416
417+func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) {
418+ dev := &entity.Tag{ID: 10, Name: "dev"}
419+ news := &entity.Tag{ID: 11, Name: "news"}
420+ reading := &entity.Tag{ID: 12, Name: "reading"}
421+ selected := map[entity.TagID]struct{}{dev.ID: {}}
422+
423+ assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, ""))
424+ assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
425+}
426+
427+func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
428+ first := entity.Tag{ID: 10, Name: "first"}
429+ second := entity.Tag{ID: 11, Name: "second"}
430+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
431+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
432+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
433+ uc.failUntagOnCall = 2
434+ fs.allFavorites = []*entity.Favorite{fav}
435+ fs.loadGen = 4
436+
437+ assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
438+ assert.Len(t, uc.untagged, 2)
439+ assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
440+ assert.Equal(t, "untag failed", fs.notice)
441+}
442+
443+func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
444+ dev := entity.Tag{ID: 10, Name: "dev"}
445+ news := entity.Tag{ID: 11, Name: "news"}
446+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
447+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
448+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
449+ fs.allFavorites = []*entity.Favorite{fav}
450+ fs.formTagIDs = tagIDSet(fav.Tags)
451+
452+ fs.toggleFormTag(news.ID)
453+ assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot())
454+ assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
455+ require.Len(t, uc.tagged, 1)
456+ assert.Equal(t, news.ID, uc.tagged[0].tagID)
457+ fav.Tags = []entity.Tag{dev, news}
458+
459+ fs.toggleFormTag(dev.ID)
460+ assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
461+ require.Len(t, uc.untagged, 1)
462+ assert.Equal(t, dev.ID, uc.untagged[0].tagID)
463+ assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
464+}
465+
466+func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
467+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
468+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
469+ if !gtk.InitCheck() {
470+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
471+ }
472+ listBox := gtk.NewListBox()
473+ require.NotNil(t, listBox)
474+ row := gtk.NewListBoxRow()
475+ require.NotNil(t, row)
476+ row.SetSelectable(true)
477+ listBox.Append(&row.Widget)
478+ fs.listBox = listBox
479+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
480+ fs.focusZone = favoritesSidebarFocusSearch
481+
482+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
483+ assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
484+ assert.Equal(t, row, fs.listBox.GetSelectedRow())
485+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
486+ assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
487+}
488+
489+func TestFavoritesSidebarDirectionalFocusConsumesArrowsOnTagControls(t *testing.T) {
490+ fs := newFavoritesSidebarHarness(nil, nil)
491+ fs.focusZone = favoritesSidebarFocusTags
492+
493+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
494+ assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
495+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
496+ assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
497+}
498+
399499 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400500 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401501 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
@@ -436,7 +536,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
436536 fs.mode = favoritesSidebarModeEdit
437537 fs.editingID = 7
438538 fs.formTitle = "Edited"
439- fs.formTags = "999"
440539
441540 assert.True(t, fs.submitForm())
442541 require.Len(t, uc.updateInputs, 1)
@@ -445,6 +544,90 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
445544 assert.Empty(t, uc.untagged)
446545 }
447546
547+func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) {
548+ fs := newFavoritesSidebarHarness(nil, nil)
549+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
550+
551+ assert.True(t, fs.createTag(" reading "))
552+ require.Len(t, uc.createdTags, 1)
553+ assert.Equal(t, "reading", uc.createdTags[0].name)
554+ assert.Empty(t, uc.createdTags[0].color)
555+
556+ assert.True(t, fs.createTag(" \t "))
557+ assert.Len(t, uc.createdTags, 1)
558+ assert.Equal(t, "Tag name is required", fs.notice)
559+}
560+
561+func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) {
562+ fs := newFavoritesSidebarHarness(nil, nil)
563+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
564+
565+ assert.True(t, fs.submitTagName(" work "))
566+ require.Len(t, uc.createdTags, 1)
567+ assert.Equal(t, "work", uc.createdTags[0].name)
568+}
569+
570+func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) {
571+ dev := entity.Tag{ID: 10, Name: "dev"}
572+ news := entity.Tag{ID: 11, Name: "news"}
573+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
574+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
575+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
576+ fs.allFavorites = []*entity.Favorite{fav}
577+
578+ fs.toggleFavoriteTag(fav.ID, dev.ID)
579+ require.Len(t, uc.untagged, 1)
580+ assert.Equal(t, dev.ID, uc.untagged[0].tagID)
581+ fs.toggleFavoriteTag(fav.ID, news.ID)
582+ require.Len(t, uc.tagged, 1)
583+ assert.Equal(t, news.ID, uc.tagged[0].tagID)
584+}
585+
586+func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) {
587+ fs := newFavoritesSidebarHarness(nil, nil)
588+ input := &gtk.Widget{}
589+
590+ fs.setTextInputFocused(input)
591+ assert.True(t, fs.inTextEditContext())
592+ assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
593+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
594+ fs.mode = favoritesSidebarModeAdd
595+ assert.True(t, fs.cancelManagement())
596+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
597+
598+ fs.clearTextInputFocus(input)
599+ assert.False(t, fs.inTextEditContext())
600+ assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
601+}
602+
603+func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
604+ for _, key := range []uint{
605+ uint(gdk.KEY_a),
606+ uint(gdk.KEY_e),
607+ uint(gdk.KEY_s),
608+ uint(gdk.KEY_r),
609+ uint(gdk.KEY_c),
610+ uint(gdk.KEY_t),
611+ uint(gdk.KEY_plus),
612+ uint(gdk.KEY_Up),
613+ uint(gdk.KEY_Return),
614+ } {
615+ assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
616+ }
617+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
618+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
619+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
620+ assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
621+}
622+
623+func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) {
624+ fs := newFavoritesSidebarHarness(nil, nil)
625+
626+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0))
627+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0))
628+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
629+}
630+
448631 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449632 fs := newFavoritesSidebarHarness(nil, nil)
450633 fs.searchEntry = nil
@@ -505,23 +688,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
505688 assert.Contains(t, fs.notice, "failed to set shortcut")
506689 }
507690
508-func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {
691+func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
509692 dev := entity.Tag{ID: 10, Name: "dev"}
510- news := entity.Tag{ID: 11, Name: "news"}
511693 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
512- fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
694+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev})
513695 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
514696 fs.allTags = uc.tags
515697 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
516698
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-
525699 fs.mode = favoritesSidebarModeShortcut
526700 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
527701 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,8 +35,15 @@ 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
44+ errOnTag map[entity.TagID]error
45+ errOnUntag map[entity.TagID]error
46+ failUntagOnCall int
39 getAllCalled chan struct{}47 getAllCalled chan struct{}
40 }48 }
41 49
@@ -51,6 +59,16 @@ func (f *fakeFavoritesSidebarUC) GetAll(context.Context) ([]*entity.Favorite, er
51 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {59 func (f *fakeFavoritesSidebarUC) GetAllTags(context.Context) ([]*entity.Tag, error) {
52 return f.tags, f.err60 return f.tags, f.err
53 }61 }
62+func (f *fakeFavoritesSidebarUC) AddTag(_ context.Context, name, color string) (*entity.Tag, error) {
63+ f.createdTags = append(f.createdTags, struct {
64+ name string
65+ color string
66+ }{name: name, color: color})
67+ if f.err != nil {
68+ return nil, f.err
69+ }
70+ return &entity.Tag{ID: entity.TagID(len(f.tags) + 1), Name: name, Color: color}, nil
71+}
54 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {72 func (f *fakeFavoritesSidebarUC) AddFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
55 f.addInputs = append(f.addInputs, input)73 f.addInputs = append(f.addInputs, input)
56 if f.err != nil {74 if f.err != nil {
@@ -84,6 +102,9 @@ func (f *fakeFavoritesSidebarUC) TagFavorite(_ context.Context, favID entity.Fav
84 favID entity.FavoriteID102 favID entity.FavoriteID
85 tagID entity.TagID103 tagID entity.TagID
86 }{favID: favID, tagID: tagID})104 }{favID: favID, tagID: tagID})
105+ if err := f.errOnTag[tagID]; err != nil {
106+ return err
107+ }
87 return f.err108 return f.err
88 }109 }
89 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {110 func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
@@ -91,6 +112,12 @@ func (f *fakeFavoritesSidebarUC) UntagFavorite(_ context.Context, favID entity.F
91 favID entity.FavoriteID112 favID entity.FavoriteID
92 tagID entity.TagID113 tagID entity.TagID
93 }{favID: favID, tagID: tagID})114 }{favID: favID, tagID: tagID})
115+ if err := f.errOnUntag[tagID]; err != nil {
116+ return err
117+ }
118+ if f.failUntagOnCall > 0 && len(f.untagged) == f.failUntagOnCall {
119+ return errors.New("untag failed")
120+ }
94 return f.err121 return f.err
95 }122 }
96 123
@@ -340,20 +367,19 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
340 fs.favoritesUC = uc367 fs.favoritesUC = uc
341 fs.formURL = "https://add.test"368 fs.formURL = "https://add.test"
342 fs.formTitle = "Added"369 fs.formTitle = "Added"
343- fs.formTags = "1, 2"370+ fs.formTagIDs = map[entity.TagID]struct{}{1: {}, 2: {}}
344 fs.formShortcut = "3"371 fs.formShortcut = "3"
345 fs.mode = favoritesSidebarModeAdd372 fs.mode = favoritesSidebarModeAdd
346 373
347 assert.True(t, fs.submitForm())374 assert.True(t, fs.submitForm())
348 require.Len(t, uc.addInputs, 1)375 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])376+ assert.ElementsMatch(t, []entity.TagID{1, 2}, uc.addInputs[0].Tags)
350 require.Len(t, uc.shortcuts, 1)377 require.Len(t, uc.shortcuts, 1)
351 assert.Equal(t, 3, *uc.shortcuts[0].key)378 assert.Equal(t, 3, *uc.shortcuts[0].key)
352 379
353 fs.mode = favoritesSidebarModeEdit380 fs.mode = favoritesSidebarModeEdit
354 fs.editingID = 7381 fs.editingID = 7
355 fs.formTitle = "Edited"382 fs.formTitle = "Edited"
356- fs.formTags = "999"
357 fs.formShortcut = "4"383 fs.formShortcut = "4"
358 assert.True(t, fs.submitForm())384 assert.True(t, fs.submitForm())
359 require.Len(t, uc.updateInputs, 1)385 require.Len(t, uc.updateInputs, 1)
@@ -372,23 +398,15 @@ func TestFavoritesSidebarAddAndEditDTOs(t *testing.T) {
372 assert.Nil(t, uc.updateInputs[1].ShortcutKey)398 assert.Nil(t, uc.updateInputs[1].ShortcutKey)
373 }399 }
374 400
375-func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {401+func TestFavoritesSidebarInvalidShortcutPreservesFormState(t *testing.T) {
376 uc := &fakeFavoritesSidebarUC{}402 uc := &fakeFavoritesSidebarUC{}
377 fs := newFavoritesSidebarHarness(nil, nil)403 fs := newFavoritesSidebarHarness(nil, nil)
378 fs.favoritesUC = uc404 fs.favoritesUC = uc
379 fs.formURL = "https://add.test"405 fs.formURL = "https://add.test"
380 fs.formTitle = "Added"406 fs.formTitle = "Added"
381- fs.formTags = "1, nope"407+ fs.formShortcut = "10"
382 fs.mode = favoritesSidebarModeAdd408 fs.mode = favoritesSidebarModeAdd
383 409
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())410 assert.True(t, fs.submitForm())
393 assert.Empty(t, uc.addInputs)411 assert.Empty(t, uc.addInputs)
394 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)412 assert.Equal(t, favoritesSidebarModeAdd, fs.mode)
@@ -396,6 +414,88 @@ func TestFavoritesSidebarInvalidTagAndShortcutPreserveFormState(t *testing.T) {
396 assert.Equal(t, "10", fs.formShortcut)414 assert.Equal(t, "10", fs.formShortcut)
397 }415 }
398 416
417+func TestFormTagCandidatesMatchesNamesAndKeepsSelectedTagsVisible(t *testing.T) {
418+ dev := &entity.Tag{ID: 10, Name: "dev"}
419+ news := &entity.Tag{ID: 11, Name: "news"}
420+ reading := &entity.Tag{ID: 12, Name: "reading"}
421+ selected := map[entity.TagID]struct{}{dev.ID: {}}
422+
423+ assert.Equal(t, []*entity.Tag{dev}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, ""))
424+ assert.Equal(t, []*entity.Tag{reading}, formTagCandidates([]*entity.Tag{dev, news, reading}, selected, "read"))
425+}
426+
427+func TestFavoritesSidebarReloadsAfterPartiallyApplyingTagChanges(t *testing.T) {
428+ first := entity.Tag{ID: 10, Name: "first"}
429+ second := entity.Tag{ID: 11, Name: "second"}
430+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{first, second}}
431+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
432+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
433+ uc.failUntagOnCall = 2
434+ fs.allFavorites = []*entity.Favorite{fav}
435+ fs.loadGen = 4
436+
437+ assert.False(t, fs.updateFavoriteTags(fav.ID, nil))
438+ assert.Len(t, uc.untagged, 2)
439+ assert.Equal(t, uint64(5), fs.loadGen, "a partial tag mutation must refresh stale favorites")
440+ assert.Equal(t, "untag failed", fs.notice)
441+}
442+
443+func TestFavoritesSidebarFormTagSelectionAndEditSynchronization(t *testing.T) {
444+ dev := entity.Tag{ID: 10, Name: "dev"}
445+ news := entity.Tag{ID: 11, Name: "news"}
446+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
447+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
448+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
449+ fs.allFavorites = []*entity.Favorite{fav}
450+ fs.formTagIDs = tagIDSet(fav.Tags)
451+
452+ fs.toggleFormTag(news.ID)
453+ assert.ElementsMatch(t, []entity.TagID{dev.ID, news.ID}, fs.formTagIDsSnapshot())
454+ assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
455+ require.Len(t, uc.tagged, 1)
456+ assert.Equal(t, news.ID, uc.tagged[0].tagID)
457+ fav.Tags = []entity.Tag{dev, news}
458+
459+ fs.toggleFormTag(dev.ID)
460+ assert.True(t, fs.updateFavoriteTags(fav.ID, fs.formTagIDsSnapshot()))
461+ require.Len(t, uc.untagged, 1)
462+ assert.Equal(t, dev.ID, uc.untagged[0].tagID)
463+ assert.Len(t, uc.tagged, 1, "the synchronized persisted tags must not be added again")
464+}
465+
466+func TestFavoritesSidebarDirectionalFocusMovesDirectlyBetweenSearchAndFirstFavorite(t *testing.T) {
467+ fav := &entity.Favorite{ID: 1, URL: "https://go.dev"}
468+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
469+ if !gtk.InitCheck() {
470+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
471+ }
472+ listBox := gtk.NewListBox()
473+ require.NotNil(t, listBox)
474+ row := gtk.NewListBoxRow()
475+ require.NotNil(t, row)
476+ row.SetSelectable(true)
477+ listBox.Append(&row.Widget)
478+ fs.listBox = listBox
479+ fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
480+ fs.focusZone = favoritesSidebarFocusSearch
481+
482+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
483+ assert.Equal(t, favoritesSidebarFocusList, fs.focusZone)
484+ assert.Equal(t, row, fs.listBox.GetSelectedRow())
485+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
486+ assert.Equal(t, favoritesSidebarFocusSearch, fs.focusZone)
487+}
488+
489+func TestFavoritesSidebarDirectionalFocusConsumesArrowsOnTagControls(t *testing.T) {
490+ fs := newFavoritesSidebarHarness(nil, nil)
491+ fs.focusZone = favoritesSidebarFocusTags
492+
493+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Down)))
494+ assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
495+ assert.True(t, fs.routeDirectionalFocus(uint(gdk.KEY_Up)))
496+ assert.Equal(t, favoritesSidebarFocusTags, fs.focusZone)
497+}
498+
399 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {499 func TestFavoritesSidebarEnterConfirmsDelete(t *testing.T) {
400 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}500 fav := &entity.Favorite{ID: 5, URL: "https://go.dev"}
401 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)501 fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, nil)
@@ -436,7 +536,6 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
436 fs.mode = favoritesSidebarModeEdit536 fs.mode = favoritesSidebarModeEdit
437 fs.editingID = 7537 fs.editingID = 7
438 fs.formTitle = "Edited"538 fs.formTitle = "Edited"
439- fs.formTags = "999"
440 539
441 assert.True(t, fs.submitForm())540 assert.True(t, fs.submitForm())
442 require.Len(t, uc.updateInputs, 1)541 require.Len(t, uc.updateInputs, 1)
@@ -445,6 +544,90 @@ func TestFavoritesSidebarEditSubmitIgnoresTagsByDesign(t *testing.T) {
445 assert.Empty(t, uc.untagged)544 assert.Empty(t, uc.untagged)
446 }545 }
447 546
547+func TestFavoritesSidebarCreateTagTrimsNameAndUsesApplicationBoundary(t *testing.T) {
548+ fs := newFavoritesSidebarHarness(nil, nil)
549+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
550+
551+ assert.True(t, fs.createTag(" reading "))
552+ require.Len(t, uc.createdTags, 1)
553+ assert.Equal(t, "reading", uc.createdTags[0].name)
554+ assert.Empty(t, uc.createdTags[0].color)
555+
556+ assert.True(t, fs.createTag(" \t "))
557+ assert.Len(t, uc.createdTags, 1)
558+ assert.Equal(t, "Tag name is required", fs.notice)
559+}
560+
561+func TestFavoritesSidebarTagNameSubmissionCreatesTag(t *testing.T) {
562+ fs := newFavoritesSidebarHarness(nil, nil)
563+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
564+
565+ assert.True(t, fs.submitTagName(" work "))
566+ require.Len(t, uc.createdTags, 1)
567+ assert.Equal(t, "work", uc.createdTags[0].name)
568+}
569+
570+func TestFavoritesSidebarTagBindingTogglesExistingTag(t *testing.T) {
571+ dev := entity.Tag{ID: 10, Name: "dev"}
572+ news := entity.Tag{ID: 11, Name: "news"}
573+ fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
574+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})
575+ uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
576+ fs.allFavorites = []*entity.Favorite{fav}
577+
578+ fs.toggleFavoriteTag(fav.ID, dev.ID)
579+ require.Len(t, uc.untagged, 1)
580+ assert.Equal(t, dev.ID, uc.untagged[0].tagID)
581+ fs.toggleFavoriteTag(fav.ID, news.ID)
582+ require.Len(t, uc.tagged, 1)
583+ assert.Equal(t, news.ID, uc.tagged[0].tagID)
584+}
585+
586+func TestFavoritesSidebarExplicitTextInputFocusControlsKeyboardRouting(t *testing.T) {
587+ fs := newFavoritesSidebarHarness(nil, nil)
588+ input := &gtk.Widget{}
589+
590+ fs.setTextInputFocused(input)
591+ assert.True(t, fs.inTextEditContext())
592+ assert.True(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
593+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_e), 0))
594+ fs.mode = favoritesSidebarModeAdd
595+ assert.True(t, fs.cancelManagement())
596+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
597+
598+ fs.clearTextInputFocus(input)
599+ assert.False(t, fs.inTextEditContext())
600+ assert.False(t, shouldDeferToTextInput(fs.inTextEditContext(), uint(gdk.KEY_e)))
601+}
602+
603+func TestFavoritesSidebarTextInputDefersSidebarCommands(t *testing.T) {
604+ for _, key := range []uint{
605+ uint(gdk.KEY_a),
606+ uint(gdk.KEY_e),
607+ uint(gdk.KEY_s),
608+ uint(gdk.KEY_r),
609+ uint(gdk.KEY_c),
610+ uint(gdk.KEY_t),
611+ uint(gdk.KEY_plus),
612+ uint(gdk.KEY_Up),
613+ uint(gdk.KEY_Return),
614+ } {
615+ assert.Truef(t, shouldDeferToTextInput(true, key), "key %d must reach the text input", key)
616+ }
617+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Tab)))
618+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_ISO_Left_Tab)))
619+ assert.False(t, shouldDeferToTextInput(true, uint(gdk.KEY_Escape)))
620+ assert.False(t, shouldDeferToTextInput(false, uint(gdk.KEY_e)))
621+}
622+
623+func TestFavoritesSidebarPlusAndTDoNotHijackTextInput(t *testing.T) {
624+ fs := newFavoritesSidebarHarness(nil, nil)
625+
626+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_t), 0))
627+ assert.False(t, fs.handleSingleKeyCommand(uint(gdk.KEY_plus), 0))
628+ assert.Equal(t, favoritesSidebarModeNone, fs.mode)
629+}
630+
448 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {631 func TestFavoritesSidebarSingleKeyCommandsIgnoreModifiedKeys(t *testing.T) {
449 fs := newFavoritesSidebarHarness(nil, nil)632 fs := newFavoritesSidebarHarness(nil, nil)
450 fs.searchEntry = nil633 fs.searchEntry = nil
@@ -505,23 +688,14 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
505 assert.Contains(t, fs.notice, "failed to set shortcut")688 assert.Contains(t, fs.notice, "failed to set shortcut")
506 }689 }
507 690
508-func TestFavoritesSidebarTagShortcutAndDeleteFlows(t *testing.T) {691+func TestFavoritesSidebarShortcutAndDeleteFlows(t *testing.T) {
509 dev := entity.Tag{ID: 10, Name: "dev"}692 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}}693 fav := &entity.Favorite{ID: 5, URL: "https://go.dev", Tags: []entity.Tag{dev}}
512- fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev, &news})694+ fs := newFavoritesSidebarHarness([]*entity.Favorite{fav}, []*entity.Tag{&dev})
513 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)695 uc := fs.favoritesUC.(*fakeFavoritesSidebarUC)
514 fs.allTags = uc.tags696 fs.allTags = uc.tags
515 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}697 fs.displayRows = []favoriteSidebarDisplayRow{{FavoriteID: fav.ID, Favorite: fav, URL: fav.URL, Selectable: true}}
516 698
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 = favoritesSidebarModeShortcut699 fs.mode = favoritesSidebarModeShortcut
526 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))700 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_9), 0))
527 assert.True(t, fs.handleSingleKeyCommand(uint(gdk.KEY_BackSpace), 0))701 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/component/omnibox_callback_pressure_stress_test.go +6 -0
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "github.com/bnema/purego"
11+ "github.com/bnema/puregotk/v4/gdk"
1112 "github.com/bnema/puregotk/v4/glib"
1213 "github.com/bnema/puregotk/v4/gobject"
1314 "github.com/bnema/puregotk/v4/gtk"
@@ -32,6 +33,11 @@ func runGTKCallbackLifecycleStress(t *testing.T) {
3233 if !gtk.InitCheck() {
3334 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
3435 }
36+ display := gdk.DisplayGetDefault()
37+ if display == nil {
38+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
39+ }
40+ display.Unref()
3541
3642 mainContext := glib.MainContextDefault()
3743 require.NotNil(t, mainContext)
@@ -8,6 +8,7 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/purego"10 "github.com/bnema/purego"
11+ "github.com/bnema/puregotk/v4/gdk"
11 "github.com/bnema/puregotk/v4/glib"12 "github.com/bnema/puregotk/v4/glib"
12 "github.com/bnema/puregotk/v4/gobject"13 "github.com/bnema/puregotk/v4/gobject"
13 "github.com/bnema/puregotk/v4/gtk"14 "github.com/bnema/puregotk/v4/gtk"
@@ -32,6 +33,11 @@ func runGTKCallbackLifecycleStress(t *testing.T) {
32 if !gtk.InitCheck() {33 if !gtk.InitCheck() {
33 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")34 t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
34 }35 }
36+ display := gdk.DisplayGetDefault()
37+ if display == nil {
38+ t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
39+ }
40+ display.Unref()
35 41
36 mainContext := glib.MainContextDefault()42 mainContext := glib.MainContextDefault()
37 require.NotNil(t, mainContext)43 require.NotNil(t, mainContext)
M internal/ui/component/toaster.go +12 -0
@@ -276,6 +276,18 @@ func (t *Toaster) Hide() {
276276 t.hide()
277277 }
278278
279+// CancelAutoDismiss removes a pending auto-dismiss source without changing the
280+// toast's visibility. It is useful when the owner is being torn down.
281+func (t *Toaster) CancelAutoDismiss() {
282+ t.mu.Lock()
283+ defer t.mu.Unlock()
284+
285+ if t.dismissTimer != 0 {
286+ glib.SourceRemove(t.dismissTimer)
287+ t.dismissTimer = 0
288+ }
289+}
290+
279291 // hide performs the actual hide operation (must be called with lock held).
280292 func (t *Toaster) hide() {
281293 if !t.visible {
@@ -276,6 +276,18 @@ func (t *Toaster) Hide() {
276 t.hide()276 t.hide()
277 }277 }
278 278
279+// CancelAutoDismiss removes a pending auto-dismiss source without changing the
280+// toast's visibility. It is useful when the owner is being torn down.
281+func (t *Toaster) CancelAutoDismiss() {
282+ t.mu.Lock()
283+ defer t.mu.Unlock()
284+
285+ if t.dismissTimer != 0 {
286+ glib.SourceRemove(t.dismissTimer)
287+ t.dismissTimer = 0
288+ }
289+}
290+
279 // hide performs the actual hide operation (must be called with lock held).291 // hide performs the actual hide operation (must be called with lock held).
280 func (t *Toaster) hide() {292 func (t *Toaster) hide() {
281 if !t.visible {293 if !t.visible {
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 +122 -0
@@ -0,0 +1,122 @@
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+ border-bottom: 0.0625em solid var(--border);
10+ background-color: var(--surface-variant);
11+}
12+
13+.favorites-sidebar-tag-filter {
14+ min-height: 0;
15+ min-width: 0;
16+ padding: 0.125em 0.375em;
17+ font-size: 0.72em;
18+ font-weight: 500;
19+ color: var(--muted);
20+ background-color: transparent;
21+ border: 0.0625em solid alpha(var(--border), 0.7);
22+ border-radius: 0.25em;
23+ box-shadow: none;
24+}
25+
26+.favorites-sidebar-tag-filter:hover {
27+ color: var(--text);
28+ background-color: alpha(var(--accent), 0.10);
29+}
30+
31+.favorites-sidebar-tag-filter-active {
32+ color: var(--accent);
33+ background-color: alpha(var(--accent), 0.16);
34+ border-color: alpha(var(--accent), 0.5);
35+}
36+
37+.favorites-sidebar-tag-add {
38+ min-width: 1.4em;
39+ font-weight: 700;
40+}
41+
42+.favorites-sidebar-tag-prompt {
43+ padding: 0.375em 0.5em;
44+ border-bottom: 0.0625em solid var(--border);
45+ background-color: var(--surface-variant);
46+}
47+
48+.favorites-sidebar-tag-prompt entry,
49+.favorites-sidebar-tag-prompt button {
50+ min-height: 0;
51+ padding: 0.1875em 0.375em;
52+ font-size: 0.78em;
53+}
54+
55+.favorites-sidebar-tag-binding {
56+ min-height: 0;
57+ padding: 0.125em 0.375em;
58+ font-size: 0.72em;
59+}
60+
61+.favorites-sidebar-shortcut-badge,
62+.favorites-sidebar-tag-chip {
63+ padding: 0.0625em 0.25em;
64+ font-size: 0.66em;
65+ font-weight: 600;
66+ color: var(--muted);
67+ background-color: alpha(var(--surface-variant), 0.9);
68+ border: 0.0625em solid alpha(var(--border), 0.65);
69+ border-radius: 0.2em;
70+}
71+
72+.favorites-sidebar-row-tags {
73+ margin-top: 0.0625em;
74+}
75+
76+.favorites-sidebar-shortcut-badge {
77+ color: var(--accent);
78+ border-color: alpha(var(--accent), 0.45);
79+ background-color: alpha(var(--accent), 0.10);
80+}
81+
82+.favorites-sidebar-form {
83+ padding: 0.5em;
84+ border-top: 0.0625em solid var(--border);
85+ background-color: var(--surface-variant);
86+}
87+
88+.favorites-sidebar-form entry {
89+ min-height: 0;
90+ padding: 0.1875em 0.375em;
91+ font-size: 0.8em;
92+ background-color: var(--surface);
93+}
94+
95+.favorites-sidebar-form-tag-matches {
96+ padding: 0.125em 0;
97+}
98+
99+.favorites-sidebar-form-tag-match {
100+ min-height: 0;
101+ min-width: 0;
102+ padding: 0.0625em 0.25em;
103+ font-size: 0.7em;
104+ color: var(--muted);
105+ background-color: alpha(var(--surface), 0.8);
106+ border: 0.0625em solid alpha(var(--border), 0.65);
107+ border-radius: 0.2em;
108+ box-shadow: none;
109+}
110+
111+.favorites-sidebar-form-tag-match:hover {
112+ color: var(--text);
113+ background-color: alpha(var(--accent), 0.12);
114+}
115+
116+.favorites-sidebar-form button {
117+ min-height: 0;
118+ padding: 0.1875em 0.5em;
119+ font-size: 0.78em;
120+}
121+`
122+}
@@ -0,0 +1,122 @@
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+ border-bottom: 0.0625em solid var(--border);
10+ background-color: var(--surface-variant);
11+}
12+
13+.favorites-sidebar-tag-filter {
14+ min-height: 0;
15+ min-width: 0;
16+ padding: 0.125em 0.375em;
17+ font-size: 0.72em;
18+ font-weight: 500;
19+ color: var(--muted);
20+ background-color: transparent;
21+ border: 0.0625em solid alpha(var(--border), 0.7);
22+ border-radius: 0.25em;
23+ box-shadow: none;
24+}
25+
26+.favorites-sidebar-tag-filter:hover {
27+ color: var(--text);
28+ background-color: alpha(var(--accent), 0.10);
29+}
30+
31+.favorites-sidebar-tag-filter-active {
32+ color: var(--accent);
33+ background-color: alpha(var(--accent), 0.16);
34+ border-color: alpha(var(--accent), 0.5);
35+}
36+
37+.favorites-sidebar-tag-add {
38+ min-width: 1.4em;
39+ font-weight: 700;
40+}
41+
42+.favorites-sidebar-tag-prompt {
43+ padding: 0.375em 0.5em;
44+ border-bottom: 0.0625em solid var(--border);
45+ background-color: var(--surface-variant);
46+}
47+
48+.favorites-sidebar-tag-prompt entry,
49+.favorites-sidebar-tag-prompt button {
50+ min-height: 0;
51+ padding: 0.1875em 0.375em;
52+ font-size: 0.78em;
53+}
54+
55+.favorites-sidebar-tag-binding {
56+ min-height: 0;
57+ padding: 0.125em 0.375em;
58+ font-size: 0.72em;
59+}
60+
61+.favorites-sidebar-shortcut-badge,
62+.favorites-sidebar-tag-chip {
63+ padding: 0.0625em 0.25em;
64+ font-size: 0.66em;
65+ font-weight: 600;
66+ color: var(--muted);
67+ background-color: alpha(var(--surface-variant), 0.9);
68+ border: 0.0625em solid alpha(var(--border), 0.65);
69+ border-radius: 0.2em;
70+}
71+
72+.favorites-sidebar-row-tags {
73+ margin-top: 0.0625em;
74+}
75+
76+.favorites-sidebar-shortcut-badge {
77+ color: var(--accent);
78+ border-color: alpha(var(--accent), 0.45);
79+ background-color: alpha(var(--accent), 0.10);
80+}
81+
82+.favorites-sidebar-form {
83+ padding: 0.5em;
84+ border-top: 0.0625em solid var(--border);
85+ background-color: var(--surface-variant);
86+}
87+
88+.favorites-sidebar-form entry {
89+ min-height: 0;
90+ padding: 0.1875em 0.375em;
91+ font-size: 0.8em;
92+ background-color: var(--surface);
93+}
94+
95+.favorites-sidebar-form-tag-matches {
96+ padding: 0.125em 0;
97+}
98+
99+.favorites-sidebar-form-tag-match {
100+ min-height: 0;
101+ min-width: 0;
102+ padding: 0.0625em 0.25em;
103+ font-size: 0.7em;
104+ color: var(--muted);
105+ background-color: alpha(var(--surface), 0.8);
106+ border: 0.0625em solid alpha(var(--border), 0.65);
107+ border-radius: 0.2em;
108+ box-shadow: none;
109+}
110+
111+.favorites-sidebar-form-tag-match:hover {
112+ color: var(--text);
113+ background-color: alpha(var(--accent), 0.12);
114+}
115+
116+.favorites-sidebar-form button {
117+ min-height: 0;
118+ padding: 0.1875em 0.5em;
119+ font-size: 0.78em;
120+}
121+`
122+}
A internal/ui/theme/favorites_sidebar_css_test.go +53 -0
@@ -0,0 +1,53 @@
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-active",
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, ".favorites-sidebar-tag-filter-active {", "active filters use sidebar-owned styling")
29+ assert.NotContains(t, css, ".suggested-action", "native suggested-action geometry must not affect tag filters")
30+ assert.Contains(t, css, "font-size: 0.66em;", "tags and shortcut badges stay compact")
31+ assert.Contains(t, css, "border-bottom: 0.0625em solid var(--border);", "filters are separated from the list")
32+ assert.Contains(t, css, "border-top: 0.0625em solid var(--border);", "the form is separated from the list")
33+}
34+
35+func TestGenerateFavoritesSidebarCSS_UsesSharedThemeVariables(t *testing.T) {
36+ css := generateFavoritesSidebarCSS(DefaultDarkPalette())
37+
38+ assert.Contains(t, css, "var(--surface)")
39+ assert.Contains(t, css, "var(--surface-variant)")
40+ assert.Contains(t, css, "var(--accent)")
41+ assert.NotContains(t, css, DefaultDarkPalette().Accent)
42+ assert.Equal(t, css, generateFavoritesSidebarCSS(DefaultLightPalette()))
43+}
44+
45+func TestGenerateFavoritesSidebarCSS_IsRegisteredWithTheFullTheme(t *testing.T) {
46+ css := GenerateCSSFull(DefaultDarkPalette(), 1, DefaultFontConfig(), DefaultModeColors())
47+
48+ assert.Contains(t, css, "Native Sidebar Foundation")
49+ assert.Contains(t, css, "Favorites Sidebar Styling")
50+ assert.Contains(t, css, ".favorites-sidebar-tag-filter {")
51+ assert.Contains(t, css, ".favorites-sidebar-form {")
52+ assert.Equal(t, strings.Count(css, "{"), strings.Count(css, "}"))
53+}
@@ -0,0 +1,53 @@
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-active",
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, ".favorites-sidebar-tag-filter-active {", "active filters use sidebar-owned styling")
29+ assert.NotContains(t, css, ".suggested-action", "native suggested-action geometry must not affect tag filters")
30+ assert.Contains(t, css, "font-size: 0.66em;", "tags and shortcut badges stay compact")
31+ assert.Contains(t, css, "border-bottom: 0.0625em solid var(--border);", "filters are separated from the list")
32+ assert.Contains(t, css, "border-top: 0.0625em solid var(--border);", "the form is separated from the list")
33+}
34+
35+func TestGenerateFavoritesSidebarCSS_UsesSharedThemeVariables(t *testing.T) {
36+ css := generateFavoritesSidebarCSS(DefaultDarkPalette())
37+
38+ assert.Contains(t, css, "var(--surface)")
39+ assert.Contains(t, css, "var(--surface-variant)")
40+ assert.Contains(t, css, "var(--accent)")
41+ assert.NotContains(t, css, DefaultDarkPalette().Accent)
42+ assert.Equal(t, css, generateFavoritesSidebarCSS(DefaultLightPalette()))
43+}
44+
45+func TestGenerateFavoritesSidebarCSS_IsRegisteredWithTheFullTheme(t *testing.T) {
46+ css := GenerateCSSFull(DefaultDarkPalette(), 1, DefaultFontConfig(), DefaultModeColors())
47+
48+ assert.Contains(t, css, "Native Sidebar Foundation")
49+ assert.Contains(t, css, "Favorites Sidebar Styling")
50+ assert.Contains(t, css, ".favorites-sidebar-tag-filter {")
51+ assert.Contains(t, css, ".favorites-sidebar-form {")
52+ assert.Equal(t, strings.Count(css, "{"), strings.Count(css, "}"))
53+}
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 +65 -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,28 @@ 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_UsesCompactBalancedSearchChrome(t *testing.T) {
120+ css := generateSidebarCSS(DefaultDarkPalette())
121+
122+ assert.Contains(t, css, ".sidebar-search-box {\n\tpadding: 0.5em 0.5em 0.375em;")
123+ assert.Contains(t, css, ".sidebar-search {\n\tmin-height: 0;\n\tpadding: 0.125em 0.375em;")
124+}
125+
126+func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) {
127+ css := generateSidebarCSS(DefaultDarkPalette())
133128
134129 assert.Contains(t, css, "transition:",
135130 "CSS should include transition property for smooth hover effects")
@@ -149,27 +144,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) {
149144 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {
150145 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
151146
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")
147+ assert.Contains(t, fullCSS, ".sidebar-outer {",
148+ "GenerateCSSFull must include shared sidebar outer selector for dark palette")
149+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
150+ "GenerateCSSFull must include shared sidebar row title selector for dark palette")
151+ assert.Contains(t, fullCSS, ".sidebar-search {",
152+ "GenerateCSSFull must include shared sidebar search selector for dark palette")
153+ assert.Contains(t, fullCSS, ".sidebar-empty {",
154+ "GenerateCSSFull must include shared sidebar empty selector for dark palette")
160155 }
161156
162157 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {
163158 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
164159
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")
160+ assert.Contains(t, fullCSS, ".sidebar-outer {",
161+ "GenerateCSSFull must include shared sidebar outer selector for light palette")
162+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
163+ "GenerateCSSFull must include shared sidebar row title selector for light palette")
164+ assert.Contains(t, fullCSS, ".sidebar-search {",
165+ "GenerateCSSFull must include shared sidebar search selector for light palette")
166+ assert.Contains(t, fullCSS, ".sidebar-empty {",
167+ "GenerateCSSFull must include shared sidebar empty selector for light palette")
173168 }
174169
175170 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {
@@ -181,8 +176,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T
181176 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
182177
183178 // 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")
179+ assert.Contains(t, darkCSS, ".sidebar-outer {", "dark CSS must have shared sidebar styles")
180+ assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles")
186181
187182 // The CSS must differ between palettes (different color values).
188183 if darkCSS == lightCSS {
@@ -211,14 +206,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t *
211206
212207 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)
213208
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")
209+ assert.Contains(t, fullCSS, ".sidebar-outer {",
210+ "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors")
211+ assert.Contains(t, fullCSS, ".sidebar-row:hover {",
212+ "GenerateCSSFull must include hover state for shared sidebar rows")
213+ assert.Contains(t, fullCSS, ".sidebar-row:selected {",
214+ "GenerateCSSFull must include selected state for shared sidebar rows")
215+ assert.Contains(t, fullCSS, ".sidebar-row:focus {",
216+ "GenerateCSSFull must include focus state for shared sidebar rows")
222217 }
223218
224219 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the
@@ -247,21 +242,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) {
247242 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
248243 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
249244
250- // All three must contain the full set of history sidebar selectors.
245+ // All three must contain the shared foundation and history-specific rules.
251246 sidebarSelectors := []string{
252- ".history-sidebar-outer",
253- ".history-sidebar-search-box",
254- ".history-sidebar-search",
255- ".history-sidebar-groups",
247+ ".sidebar-outer",
248+ ".sidebar-search-box",
249+ ".sidebar-search",
250+ ".sidebar-list",
251+ ".sidebar-row",
252+ ".sidebar-row:hover",
253+ ".sidebar-row:selected",
254+ ".sidebar-row:focus",
255+ ".sidebar-row-title",
256+ ".sidebar-row-subtitle",
257+ ".sidebar-empty",
256258 ".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",
263259 ".history-sidebar-row-time",
264- ".history-sidebar-empty",
265260 ".history-sidebar-loading",
266261 }
267262
@@ -324,9 +319,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) {
324319 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")
325320 }
326321
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 {")
322+ // Shared sidebar selectors must be present in both.
323+ assert.Contains(t, cssBefore, ".sidebar-row:selected {")
324+ assert.Contains(t, cssAfter, ".sidebar-row:selected {")
330325
331326 // The before and after CSS must be different (palette changed).
332327 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,28 @@ 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_UsesCompactBalancedSearchChrome(t *testing.T) {
132- css := generateHistorySidebarCSS(DefaultDarkPalette())120+ css := generateSidebarCSS(DefaultDarkPalette())
121+
122+ assert.Contains(t, css, ".sidebar-search-box {\n\tpadding: 0.5em 0.5em 0.375em;")
123+ assert.Contains(t, css, ".sidebar-search {\n\tmin-height: 0;\n\tpadding: 0.125em 0.375em;")
124+}
125+
126+func TestGenerateSidebarCSS_ContainsTransition(t *testing.T) {
127+ css := generateSidebarCSS(DefaultDarkPalette())
133 128
134 assert.Contains(t, css, "transition:",129 assert.Contains(t, css, "transition:",
135 "CSS should include transition property for smooth hover effects")130 "CSS should include transition property for smooth hover effects")
@@ -149,27 +144,27 @@ func TestGenerateHistorySidebarCSS_ContainsUppercaseGroupHeader(t *testing.T) {
149 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {144 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_DarkPalette(t *testing.T) {
150 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())145 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
151 146
152- assert.Contains(t, fullCSS, ".history-sidebar-outer {",147+ assert.Contains(t, fullCSS, ".sidebar-outer {",
153- "GenerateCSSFull must include history sidebar outer selector for dark palette")148+ "GenerateCSSFull must include shared sidebar outer selector for dark palette")
154- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",149+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
155- "GenerateCSSFull must include history sidebar row title selector for dark palette")150+ "GenerateCSSFull must include shared sidebar row title selector for dark palette")
156- assert.Contains(t, fullCSS, ".history-sidebar-search {",151+ assert.Contains(t, fullCSS, ".sidebar-search {",
157- "GenerateCSSFull must include history sidebar search selector for dark palette")152+ "GenerateCSSFull must include shared sidebar search selector for dark palette")
158- assert.Contains(t, fullCSS, ".history-sidebar-empty {",153+ assert.Contains(t, fullCSS, ".sidebar-empty {",
159- "GenerateCSSFull must include history sidebar empty selector for dark palette")154+ "GenerateCSSFull must include shared sidebar empty selector for dark palette")
160 }155 }
161 156
162 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {157 func TestGenerateHistorySidebarCSS_InGenerateCSSFull_LightPalette(t *testing.T) {
163 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())158 fullCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
164 159
165- assert.Contains(t, fullCSS, ".history-sidebar-outer {",160+ assert.Contains(t, fullCSS, ".sidebar-outer {",
166- "GenerateCSSFull must include history sidebar outer selector for light palette")161+ "GenerateCSSFull must include shared sidebar outer selector for light palette")
167- assert.Contains(t, fullCSS, ".history-sidebar-row-title {",162+ assert.Contains(t, fullCSS, ".sidebar-row-title {",
168- "GenerateCSSFull must include history sidebar row title selector for light palette")163+ "GenerateCSSFull must include shared sidebar row title selector for light palette")
169- assert.Contains(t, fullCSS, ".history-sidebar-search {",164+ assert.Contains(t, fullCSS, ".sidebar-search {",
170- "GenerateCSSFull must include history sidebar search selector for light palette")165+ "GenerateCSSFull must include shared sidebar search selector for light palette")
171- assert.Contains(t, fullCSS, ".history-sidebar-empty {",166+ assert.Contains(t, fullCSS, ".sidebar-empty {",
172- "GenerateCSSFull must include history sidebar empty selector for light palette")167+ "GenerateCSSFull must include shared sidebar empty selector for light palette")
173 }168 }
174 169
175 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {170 func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T) {
@@ -181,8 +176,8 @@ func TestGenerateHistorySidebarCSS_LiveReloadSeamIncludesUpdatedCSS(t *testing.T
181 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())176 lightCSS := GenerateCSSFull(DefaultLightPalette(), 1.0, DefaultFontConfig(), DefaultModeColors())
182 177
183 // Both must contain history sidebar selectors.178 // Both must contain history sidebar selectors.
184- assert.Contains(t, darkCSS, ".history-sidebar-outer {", "dark CSS must have sidebar styles")179+ 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")180+ assert.Contains(t, lightCSS, ".sidebar-outer {", "light CSS must have shared sidebar styles")
186 181
187 // The CSS must differ between palettes (different color values).182 // The CSS must differ between palettes (different color values).
188 if darkCSS == lightCSS {183 if darkCSS == lightCSS {
@@ -211,14 +206,14 @@ func TestGenerateHistorySidebarCSS_InGenerateCSSFull_WithCustomScaleAndFonts(t *
211 206
212 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)207 fullCSS := GenerateCSSFull(DefaultDarkPalette(), 2.0, customFonts, customModeColors)
213 208
214- assert.Contains(t, fullCSS, ".history-sidebar-outer {",209+ assert.Contains(t, fullCSS, ".sidebar-outer {",
215- "GenerateCSSFull with custom scale/fonts must include history sidebar selectors")210+ "GenerateCSSFull with custom scale/fonts must include shared sidebar selectors")
216- assert.Contains(t, fullCSS, ".history-sidebar-row:hover {",211+ assert.Contains(t, fullCSS, ".sidebar-row:hover {",
217- "GenerateCSSFull must include hover state for history rows")212+ "GenerateCSSFull must include hover state for shared sidebar rows")
218- assert.Contains(t, fullCSS, ".history-sidebar-row:selected {",213+ assert.Contains(t, fullCSS, ".sidebar-row:selected {",
219- "GenerateCSSFull must include selected state for history rows")214+ "GenerateCSSFull must include selected state for shared sidebar rows")
220- assert.Contains(t, fullCSS, ".history-sidebar-row:focus {",215+ assert.Contains(t, fullCSS, ".sidebar-row:focus {",
221- "GenerateCSSFull must include focus state for history rows")216+ "GenerateCSSFull must include focus state for shared sidebar rows")
222 }217 }
223 218
224 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the219 // TestGenerateHistorySidebarCSS_LiveReloadFullPath verifies that the
@@ -247,21 +242,21 @@ func TestGenerateHistorySidebarCSS_LiveReloadFullPath(t *testing.T) {
247 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())242 lightCSS := GenerateCSSFull(lightPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
248 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())243 customCSS := GenerateCSSFull(customPalette, 1.0, DefaultFontConfig(), DefaultModeColors())
249 244
250- // All three must contain the full set of history sidebar selectors.245+ // All three must contain the shared foundation and history-specific rules.
251 sidebarSelectors := []string{246 sidebarSelectors := []string{
252- ".history-sidebar-outer",247+ ".sidebar-outer",
253- ".history-sidebar-search-box",248+ ".sidebar-search-box",
254- ".history-sidebar-search",249+ ".sidebar-search",
255- ".history-sidebar-groups",250+ ".sidebar-list",
251+ ".sidebar-row",
252+ ".sidebar-row:hover",
253+ ".sidebar-row:selected",
254+ ".sidebar-row:focus",
255+ ".sidebar-row-title",
256+ ".sidebar-row-subtitle",
257+ ".sidebar-empty",
256 ".history-sidebar-group-header",258 ".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",259 ".history-sidebar-row-time",
264- ".history-sidebar-empty",
265 ".history-sidebar-loading",260 ".history-sidebar-loading",
266 }261 }
267 262
@@ -324,9 +319,9 @@ func TestGenerateHistorySidebarCSS_LiveReloadPaletteSwitch(t *testing.T) {
324 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")319 assert.NotContains(t, cssAfter, "\n --accent: "+darkAccent+";\n")
325 }320 }
326 321
327- // History sidebar selectors must be present in both.322+ // Shared sidebar selectors must be present in both.
328- assert.Contains(t, cssBefore, ".history-sidebar-row:selected {")323+ assert.Contains(t, cssBefore, ".sidebar-row:selected {")
329- assert.Contains(t, cssAfter, ".history-sidebar-row:selected {")324+ assert.Contains(t, cssAfter, ".sidebar-row:selected {")
330 325
331 // The before and after CSS must be different (palette changed).326 // The before and after CSS must be different (palette changed).
332 assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches")327 assert.NotEqual(t, cssBefore, cssAfter, "CSS must change when palette switches")
A internal/ui/theme/sidebar_css.go +68 -0
@@ -0,0 +1,68 @@
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.5em 0.5em 0.375em;
16+ border-bottom: 0.0625em solid var(--border);
17+ background-color: var(--surface);
18+}
19+
20+.sidebar-search {
21+ min-height: 0;
22+ padding: 0.125em 0.375em;
23+ font-size: 0.85em;
24+}
25+
26+.sidebar-list {
27+ background-color: var(--surface);
28+}
29+
30+.sidebar-row {
31+ padding: 0.1875em 0.625em;
32+ min-height: 0;
33+ border-bottom: 0.0625em solid alpha(var(--border), 0.4);
34+ background-color: var(--surface);
35+ transition: background-color 100ms ease;
36+}
37+
38+.sidebar-row:hover {
39+ background-color: alpha(var(--accent), 0.18);
40+}
41+
42+.sidebar-row:selected {
43+ background-color: alpha(var(--accent), 0.18);
44+}
45+
46+.sidebar-row:focus {
47+ background-color: alpha(var(--accent), 0.18);
48+}
49+
50+.sidebar-row-title {
51+ font-size: 0.82em;
52+ color: var(--text);
53+ font-weight: 500;
54+}
55+
56+.sidebar-row-subtitle {
57+ font-size: 0.72em;
58+ color: var(--muted);
59+}
60+
61+.sidebar-empty {
62+ padding: 1.5em 0.75em;
63+ font-size: 0.82em;
64+ color: var(--muted);
65+ font-style: italic;
66+}
67+`
68+}
@@ -0,0 +1,68 @@
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.5em 0.5em 0.375em;
16+ border-bottom: 0.0625em solid var(--border);
17+ background-color: var(--surface);
18+}
19+
20+.sidebar-search {
21+ min-height: 0;
22+ padding: 0.125em 0.375em;
23+ font-size: 0.85em;
24+}
25+
26+.sidebar-list {
27+ background-color: var(--surface);
28+}
29+
30+.sidebar-row {
31+ padding: 0.1875em 0.625em;
32+ min-height: 0;
33+ border-bottom: 0.0625em solid alpha(var(--border), 0.4);
34+ background-color: var(--surface);
35+ transition: background-color 100ms ease;
36+}
37+
38+.sidebar-row:hover {
39+ background-color: alpha(var(--accent), 0.18);
40+}
41+
42+.sidebar-row:selected {
43+ background-color: alpha(var(--accent), 0.18);
44+}
45+
46+.sidebar-row:focus {
47+ background-color: alpha(var(--accent), 0.18);
48+}
49+
50+.sidebar-row-title {
51+ font-size: 0.82em;
52+ color: var(--text);
53+ font-weight: 500;
54+}
55+
56+.sidebar-row-subtitle {
57+ font-size: 0.72em;
58+ color: var(--muted);
59+}
60+
61+.sidebar-empty {
62+ padding: 1.5em 0.75em;
63+ font-size: 0.82em;
64+ color: var(--muted);
65+ font-style: italic;
66+}
67+`
68+}

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