detached 09cb23d

feat(ui): implement native GTK4 omnibox with keyboard navigation and styling

commit
09cb23dc0fd984d4db537411c161f3742d9bd2eb
author
brice · · 2025-12-14 17:13 UTC
committer
brice · · 2025-12-14 21:24 UTC
parent
aab0b08
signature
unsigned
2 files · +1062 -0
A internal/ui/component/omnibox.go +928 -0
@@ -0,0 +1,928 @@
1+package component
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+ "github.com/jwijenbergh/puregotk/v4/gtk"
15+)
16+
17+const (
18+ omniboxWidth = 600
19+ omniboxMaxResults = 10
20+ debounceDelayMs = 120
21+)
22+
23+// ViewMode distinguishes history search from favorites display.
24+type ViewMode string
25+
26+const (
27+ ViewModeHistory ViewMode = "history"
28+ ViewModeFavorites ViewMode = "favorites"
29+)
30+
31+// Suggestion represents a search result from history.
32+type Suggestion struct {
33+ URL string
34+ Title string
35+ FaviconURL string
36+}
37+
38+// Favorite represents a bookmarked URL.
39+type Favorite struct {
40+ ID int64
41+ URL string
42+ Title string
43+ FaviconURL string
44+ Position int
45+}
46+
47+// Omnibox is the native GTK4 address bar / command palette.
48+type Omnibox struct {
49+ // GTK widgets
50+ window *gtk.Window
51+ mainBox *gtk.Box
52+ headerBox *gtk.Box
53+ historyBtn *gtk.Button
54+ favoritesBtn *gtk.Button
55+ entry *gtk.SearchEntry
56+ scrolledWin *gtk.ScrolledWindow
57+ listBox *gtk.ListBox
58+
59+ // Parent window reference for positioning
60+ parentWindow *gtk.ApplicationWindow
61+
62+ // State
63+ mu sync.RWMutex
64+ visible bool
65+ viewMode ViewMode
66+ selectedIndex int
67+ suggestions []Suggestion
68+ favorites []Favorite
69+
70+ // Dependencies
71+ historyUC *usecase.SearchHistoryUseCase
72+ favoritesUC *usecase.ManageFavoritesUseCase
73+ shortcuts map[string]config.SearchShortcut
74+ defaultSearch string
75+ initialBehavior string
76+ ctx context.Context
77+
78+ // Callbacks
79+ onNavigate func(url string)
80+ onClose func()
81+
82+ // Debouncing
83+ debounceTimer *time.Timer
84+ debounceMu sync.Mutex
85+}
86+
87+// OmniboxConfig holds configuration for creating an Omnibox.
88+type OmniboxConfig struct {
89+ HistoryUC *usecase.SearchHistoryUseCase
90+ FavoritesUC *usecase.ManageFavoritesUseCase
91+ Shortcuts map[string]config.SearchShortcut
92+ DefaultSearch string
93+ InitialBehavior string
94+}
95+
96+// NewOmnibox creates a new native GTK4 omnibox widget.
97+func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxConfig) *Omnibox {
98+ log := logging.FromContext(ctx)
99+
100+ o := &Omnibox{
101+ parentWindow: parent,
102+ viewMode: ViewModeHistory,
103+ selectedIndex: -1,
104+ historyUC: cfg.HistoryUC,
105+ favoritesUC: cfg.FavoritesUC,
106+ shortcuts: cfg.Shortcuts,
107+ defaultSearch: cfg.DefaultSearch,
108+ initialBehavior: cfg.InitialBehavior,
109+ ctx: ctx,
110+ }
111+
112+ if err := o.createWidgets(); err != nil {
113+ log.Error().Err(err).Msg("failed to create omnibox widgets")
114+ return nil
115+ }
116+
117+ o.setupKeyboardHandling()
118+ o.setupEntryChanged()
119+
120+ log.Debug().Msg("omnibox created")
121+ return o
122+}
123+
124+// createWidgets builds the GTK widget hierarchy.
125+func (o *Omnibox) createWidgets() error {
126+ // Create the popup window
127+ o.window = gtk.NewWindow()
128+ if o.window == nil {
129+ return errNilWidget("window")
130+ }
131+
132+ o.window.SetModal(true)
133+ o.window.SetDecorated(false)
134+ o.window.SetDefaultSize(omniboxWidth, 400)
135+ o.window.AddCssClass("omnibox-window")
136+
137+ // Set transient for parent window (positioning)
138+ if o.parentWindow != nil {
139+ o.window.SetTransientFor(&o.parentWindow.Window)
140+ }
141+
142+ // Main vertical box
143+ o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
144+ if o.mainBox == nil {
145+ return errNilWidget("mainBox")
146+ }
147+ o.mainBox.AddCssClass("omnibox-container")
148+
149+ // Header with History/Favorites toggle
150+ o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
151+ if o.headerBox == nil {
152+ return errNilWidget("headerBox")
153+ }
154+ o.headerBox.AddCssClass("omnibox-header")
155+ o.headerBox.SetHexpand(true)
156+
157+ o.historyBtn = gtk.NewButtonWithLabel("History")
158+ if o.historyBtn == nil {
159+ return errNilWidget("historyBtn")
160+ }
161+ o.historyBtn.AddCssClass("omnibox-header-btn")
162+ o.historyBtn.AddCssClass("omnibox-header-active")
163+ o.historyBtn.SetCanFocus(false)
164+
165+ o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
166+ if o.favoritesBtn == nil {
167+ return errNilWidget("favoritesBtn")
168+ }
169+ o.favoritesBtn.AddCssClass("omnibox-header-btn")
170+ o.favoritesBtn.SetCanFocus(false)
171+
172+ // Connect header button clicks
173+ historyClickCb := func(btn gtk.Button) {
174+ o.setViewMode(ViewModeHistory)
175+ }
176+ o.historyBtn.ConnectClicked(&historyClickCb)
177+
178+ favoritesClickCb := func(btn gtk.Button) {
179+ o.setViewMode(ViewModeFavorites)
180+ }
181+ o.favoritesBtn.ConnectClicked(&favoritesClickCb)
182+
183+ o.headerBox.Append(&o.historyBtn.Widget)
184+ o.headerBox.Append(&o.favoritesBtn.Widget)
185+
186+ // Search entry field
187+ o.entry = gtk.NewSearchEntry()
188+ if o.entry == nil {
189+ return errNilWidget("entry")
190+ }
191+ o.entry.AddCssClass("omnibox-entry")
192+ o.entry.SetHexpand(true)
193+
194+ placeholder := "Search history or enter URL..."
195+ o.entry.SetPlaceholderText(&placeholder)
196+
197+ // Scrolled window for list
198+ o.scrolledWin = gtk.NewScrolledWindow()
199+ if o.scrolledWin == nil {
200+ return errNilWidget("scrolledWin")
201+ }
202+ o.scrolledWin.AddCssClass("omnibox-scrolled")
203+ o.scrolledWin.SetVexpand(true)
204+ o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
205+
206+ // List box for suggestions
207+ o.listBox = gtk.NewListBox()
208+ if o.listBox == nil {
209+ return errNilWidget("listBox")
210+ }
211+ o.listBox.AddCssClass("omnibox-listbox")
212+ o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
213+
214+ // Connect row selection
215+ rowSelectedCb := func(lb gtk.ListBox, rowPtr uintptr) {
216+ if rowPtr == 0 {
217+ o.mu.Lock()
218+ o.selectedIndex = -1
219+ o.mu.Unlock()
220+ return
221+ }
222+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
223+ if row != nil {
224+ o.mu.Lock()
225+ o.selectedIndex = row.GetIndex()
226+ o.mu.Unlock()
227+ }
228+ }
229+ o.listBox.ConnectRowSelected(&rowSelectedCb)
230+
231+ // Assemble hierarchy
232+ o.scrolledWin.SetChild(&o.listBox.Widget)
233+ o.mainBox.Append(&o.headerBox.Widget)
234+ o.mainBox.Append(&o.entry.Widget)
235+ o.mainBox.Append(&o.scrolledWin.Widget)
236+ o.window.SetChild(&o.mainBox.Widget)
237+
238+ return nil
239+}
240+
241+// setupKeyboardHandling adds keyboard event handling.
242+func (o *Omnibox) setupKeyboardHandling() {
243+ log := logging.FromContext(o.ctx)
244+ controller := gtk.NewEventControllerKey()
245+ if controller == nil {
246+ log.Error().Msg("failed to create event controller key")
247+ return
248+ }
249+
250+ // Set capture phase to intercept before entry
251+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
252+
253+ keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
254+ return o.handleKeyPress(keyval, state)
255+ }
256+ controller.ConnectKeyPressed(&keyPressedCb)
257+
258+ o.window.AddController(&controller.EventController)
259+}
260+
261+// setupEntryChanged wires entry text changes to debounced search.
262+func (o *Omnibox) setupEntryChanged() {
263+ // SearchEntry has built-in debouncing via search-changed signal
264+ changedCb := func(_ gtk.SearchEntry) {
265+ o.onEntryChanged()
266+ }
267+ o.entry.ConnectSearchChanged(&changedCb)
268+}
269+
270+// handleKeyPress processes keyboard events.
271+// Returns true if the event was handled.
272+func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
273+ ctrl := state&gdk.ControlMaskValue != 0
274+
275+ switch keyval {
276+ case uint(gdk.KEY_Escape):
277+ text := o.entry.GetText()
278+ if text != "" {
279+ o.entry.SetText("")
280+ return true
281+ }
282+ o.Hide(o.ctx)
283+ return true
284+
285+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
286+ o.navigateToSelected()
287+ return true
288+
289+ case uint(gdk.KEY_Up):
290+ o.selectPrevious()
291+ return true
292+
293+ case uint(gdk.KEY_Down):
294+ o.selectNext()
295+ return true
296+
297+ case uint(gdk.KEY_Tab):
298+ o.toggleViewMode()
299+ return true
300+
301+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
302+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
303+ if ctrl {
304+ index := int(keyval - uint(gdk.KEY_1)) // 0-8
305+ o.selectAndNavigate(index)
306+ return true
307+ }
308+
309+ case uint(gdk.KEY_0):
310+ if ctrl {
311+ o.selectAndNavigate(9) // 10th item
312+ return true
313+ }
314+ }
315+
316+ return false // Let entry handle the key
317+}
318+
319+// onEntryChanged handles text input changes with debouncing.
320+func (o *Omnibox) onEntryChanged() {
321+ o.debounceMu.Lock()
322+ if o.debounceTimer != nil {
323+ o.debounceTimer.Stop()
324+ }
325+ o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
326+ o.performSearch()
327+ })
328+ o.debounceMu.Unlock()
329+}
330+
331+// performSearch executes the search based on current view mode and query.
332+func (o *Omnibox) performSearch() {
333+ o.mu.RLock()
334+ mode := o.viewMode
335+ o.mu.RUnlock()
336+
337+ query := o.entry.GetText()
338+
339+ if mode == ViewModeFavorites {
340+ o.loadFavorites(query)
341+ return
342+ }
343+
344+ // History search
345+ if query == "" {
346+ o.loadInitialHistory()
347+ return
348+ }
349+
350+ // Perform fuzzy search
351+ go func() {
352+ ctx := o.ctx
353+ log := logging.FromContext(ctx)
354+ if o.historyUC == nil {
355+ log.Debug().Msg("omnibox search: historyUC is nil")
356+ return
357+ }
358+
359+ log.Debug().Str("query", query).Int("limit", omniboxMaxResults).Msg("omnibox performing history search")
360+
361+ input := usecase.SearchInput{
362+ Query: query,
363+ Limit: omniboxMaxResults,
364+ }
365+ output, err := o.historyUC.Search(ctx, input)
366+ if err != nil {
367+ log.Error().Err(err).Msg("history search failed")
368+ return
369+ }
370+
371+ log.Debug().Int("results", len(output.Matches)).Str("query", query).Msg("omnibox search completed")
372+
373+ suggestions := make([]Suggestion, 0, len(output.Matches))
374+ for _, r := range output.Matches {
375+ suggestions = append(suggestions, Suggestion{
376+ URL: r.Entry.URL,
377+ Title: r.Entry.Title,
378+ })
379+ }
380+
381+ // Marshal back to GTK main thread
382+ o.idleAddUpdateSuggestions(suggestions)
383+ }()
384+}
385+
386+// loadInitialHistory loads history based on InitialBehavior config.
387+func (o *Omnibox) loadInitialHistory() {
388+ if o.historyUC == nil {
389+ return
390+ }
391+
392+ go func() {
393+ ctx := o.ctx
394+ log := logging.FromContext(ctx)
395+ var suggestions []Suggestion
396+
397+ log.Debug().Str("behavior", o.initialBehavior).Msg("omnibox loading initial history")
398+
399+ switch o.initialBehavior {
400+ case "none":
401+ // Show nothing
402+ log.Debug().Msg("omnibox initial behavior: none")
403+ o.idleAddUpdateSuggestions(nil)
404+ return
405+
406+ case "most_visited":
407+ // TODO: Implement GetMostVisited in use case if needed
408+ // For now, fall through to recent
409+ fallthrough
410+
411+ case "recent", "":
412+ results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)
413+ if err != nil {
414+ log.Error().Err(err).Msg("failed to load recent history")
415+ return
416+ }
417+ log.Debug().Int("results", len(results)).Msg("omnibox loaded recent history")
418+ suggestions = make([]Suggestion, 0, len(results))
419+ for _, r := range results {
420+ suggestions = append(suggestions, Suggestion{
421+ URL: r.URL,
422+ Title: r.Title,
423+ })
424+ }
425+ }
426+
427+ o.idleAddUpdateSuggestions(suggestions)
428+ }()
429+}
430+
431+// loadFavorites loads favorites, optionally filtered by query.
432+func (o *Omnibox) loadFavorites(query string) {
433+ if o.favoritesUC == nil {
434+ return
435+ }
436+
437+ go func() {
438+ ctx := o.ctx
439+ log := logging.FromContext(ctx)
440+ results, err := o.favoritesUC.GetAll(ctx)
441+ if err != nil {
442+ log.Error().Err(err).Msg("failed to load favorites")
443+ return
444+ }
445+
446+ favorites := make([]Favorite, 0, len(results))
447+ queryLower := strings.ToLower(query)
448+ for i, r := range results {
449+ // Filter by query if provided
450+ if query != "" {
451+ titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
452+ urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
453+ if !titleMatch && !urlMatch {
454+ continue
455+ }
456+ }
457+ favorites = append(favorites, Favorite{
458+ ID: int64(r.ID),
459+ URL: r.URL,
460+ Title: r.Title,
461+ Position: i,
462+ })
463+ }
464+
465+ o.idleAddUpdateFavorites(favorites)
466+ }()
467+}
468+
469+// updateSuggestions updates the list with history suggestions.
470+func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
471+ o.mu.Lock()
472+ o.suggestions = suggestions
473+ o.selectedIndex = -1
474+ o.mu.Unlock()
475+
476+ o.rebuildList()
477+
478+ // Select first item if available
479+ if len(suggestions) > 0 {
480+ o.selectIndex(0)
481+ }
482+}
483+
484+// updateFavorites updates the list with favorites.
485+func (o *Omnibox) updateFavorites(favorites []Favorite) {
486+ o.mu.Lock()
487+ o.favorites = favorites
488+ o.selectedIndex = -1
489+ o.mu.Unlock()
490+
491+ o.rebuildList()
492+
493+ // Select first item if available
494+ if len(favorites) > 0 {
495+ o.selectIndex(0)
496+ }
497+}
498+
499+// rebuildList rebuilds the ListBox contents.
500+func (o *Omnibox) rebuildList() {
501+ // Clear existing rows
502+ o.listBox.RemoveAll()
503+
504+ o.mu.RLock()
505+ mode := o.viewMode
506+ suggestions := o.suggestions
507+ favorites := o.favorites
508+ o.mu.RUnlock()
509+
510+ if mode == ViewModeHistory {
511+ for i, s := range suggestions {
512+ row := o.createSuggestionRow(s, i)
513+ if row != nil {
514+ o.listBox.Append(&row.Widget)
515+ }
516+ }
517+ } else {
518+ for i, f := range favorites {
519+ row := o.createFavoriteRow(f, i)
520+ if row != nil {
521+ o.listBox.Append(&row.Widget)
522+ }
523+ }
524+ }
525+}
526+
527+// createSuggestionRow creates a ListBoxRow for a suggestion.
528+func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
529+ row := gtk.NewListBoxRow()
530+ if row == nil {
531+ return nil
532+ }
533+ row.AddCssClass("omnibox-row")
534+
535+ box := gtk.NewBox(gtk.OrientationHorizontalValue, 8)
536+ if box == nil {
537+ return nil
538+ }
539+ box.SetHexpand(true)
540+
541+ // URL/Title label
542+ displayText := s.Title
543+ if displayText == "" {
544+ displayText = s.URL
545+ }
546+ label := gtk.NewLabel(&displayText)
547+ if label != nil {
548+ label.AddCssClass("omnibox-suggestion-title")
549+ label.SetHalign(gtk.AlignStartValue)
550+ label.SetHexpand(true)
551+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
552+ box.Append(&label.Widget)
553+ }
554+
555+ // Shortcut label (Ctrl+1-9)
556+ if index < 9 {
557+ shortcutText := formatShortcut(index + 1)
558+ shortcutLabel := gtk.NewLabel(&shortcutText)
559+ if shortcutLabel != nil {
560+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
561+ box.Append(&shortcutLabel.Widget)
562+ }
563+ } else if index == 9 {
564+ shortcutText := "Ctrl+0"
565+ shortcutLabel := gtk.NewLabel(&shortcutText)
566+ if shortcutLabel != nil {
567+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
568+ box.Append(&shortcutLabel.Widget)
569+ }
570+ }
571+
572+ row.SetChild(&box.Widget)
573+ return row
574+}
575+
576+// createFavoriteRow creates a ListBoxRow for a favorite.
577+func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
578+ row := gtk.NewListBoxRow()
579+ if row == nil {
580+ return nil
581+ }
582+ row.AddCssClass("omnibox-row")
583+
584+ box := gtk.NewBox(gtk.OrientationHorizontalValue, 8)
585+ if box == nil {
586+ return nil
587+ }
588+ box.SetHexpand(true)
589+
590+ // Title label
591+ displayText := f.Title
592+ if displayText == "" {
593+ displayText = f.URL
594+ }
595+ label := gtk.NewLabel(&displayText)
596+ if label != nil {
597+ label.AddCssClass("omnibox-suggestion-title")
598+ label.SetHalign(gtk.AlignStartValue)
599+ label.SetHexpand(true)
600+ label.SetEllipsize(2)
601+ box.Append(&label.Widget)
602+ }
603+
604+ // Shortcut label
605+ if index < 9 {
606+ shortcutText := formatShortcut(index + 1)
607+ shortcutLabel := gtk.NewLabel(&shortcutText)
608+ if shortcutLabel != nil {
609+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
610+ box.Append(&shortcutLabel.Widget)
611+ }
612+ } else if index == 9 {
613+ shortcutText := "Ctrl+0"
614+ shortcutLabel := gtk.NewLabel(&shortcutText)
615+ if shortcutLabel != nil {
616+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
617+ box.Append(&shortcutLabel.Widget)
618+ }
619+ }
620+
621+ row.SetChild(&box.Widget)
622+ return row
623+}
624+
625+// selectIndex selects a row by index.
626+func (o *Omnibox) selectIndex(index int) {
627+ o.mu.Lock()
628+ defer o.mu.Unlock()
629+
630+ row := o.listBox.GetRowAtIndex(index)
631+ if row != nil {
632+ o.listBox.SelectRow(row)
633+ o.selectedIndex = index
634+ }
635+}
636+
637+// selectNext moves selection down.
638+func (o *Omnibox) selectNext() {
639+ o.mu.RLock()
640+ current := o.selectedIndex
641+ mode := o.viewMode
642+ var maxIndex int
643+ if mode == ViewModeHistory {
644+ maxIndex = len(o.suggestions) - 1
645+ } else {
646+ maxIndex = len(o.favorites) - 1
647+ }
648+ o.mu.RUnlock()
649+
650+ if maxIndex < 0 {
651+ return
652+ }
653+
654+ newIndex := current + 1
655+ if newIndex > maxIndex {
656+ newIndex = 0 // Wrap around
657+ }
658+ o.selectIndex(newIndex)
659+}
660+
661+// selectPrevious moves selection up.
662+func (o *Omnibox) selectPrevious() {
663+ o.mu.RLock()
664+ current := o.selectedIndex
665+ mode := o.viewMode
666+ var maxIndex int
667+ if mode == ViewModeHistory {
668+ maxIndex = len(o.suggestions) - 1
669+ } else {
670+ maxIndex = len(o.favorites) - 1
671+ }
672+ o.mu.RUnlock()
673+
674+ if maxIndex < 0 {
675+ return
676+ }
677+
678+ newIndex := current - 1
679+ if newIndex < 0 {
680+ newIndex = maxIndex // Wrap around
681+ }
682+ o.selectIndex(newIndex)
683+}
684+
685+// selectAndNavigate selects an index and navigates to it.
686+func (o *Omnibox) selectAndNavigate(index int) {
687+ o.selectIndex(index)
688+ o.navigateToSelected()
689+}
690+
691+// navigateToSelected navigates to the currently selected item.
692+func (o *Omnibox) navigateToSelected() {
693+ o.mu.RLock()
694+ mode := o.viewMode
695+ idx := o.selectedIndex
696+ suggestions := o.suggestions
697+ favorites := o.favorites
698+ o.mu.RUnlock()
699+
700+ var url string
701+
702+ if idx < 0 {
703+ // No selection - use entry text as URL
704+ url = o.buildURL(o.entry.GetText())
705+ } else if mode == ViewModeHistory {
706+ if idx < len(suggestions) {
707+ url = suggestions[idx].URL
708+ }
709+ } else {
710+ if idx < len(favorites) {
711+ url = favorites[idx].URL
712+ }
713+ }
714+
715+ if url == "" {
716+ return
717+ }
718+
719+ o.Hide(o.ctx)
720+
721+ if o.onNavigate != nil {
722+ o.onNavigate(url)
723+ }
724+}
725+
726+// buildURL constructs a URL from input, handling search shortcuts.
727+func (o *Omnibox) buildURL(input string) string {
728+ if input == "" {
729+ return ""
730+ }
731+
732+ // Check for search shortcut (e.g., "g:query")
733+ if colonIdx := strings.Index(input, ":"); colonIdx > 0 && colonIdx < 10 {
734+ prefix := input[:colonIdx]
735+ if shortcut, ok := o.shortcuts[prefix]; ok {
736+ query := strings.TrimSpace(input[colonIdx+1:])
737+ return strings.Replace(shortcut.URL, "%s", query, 1)
738+ }
739+ }
740+
741+ // Check if it looks like a URL
742+ if strings.Contains(input, ".") && !strings.Contains(input, " ") {
743+ if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
744+ return "https://" + input
745+ }
746+ return input
747+ }
748+
749+ // Use default search
750+ if o.defaultSearch != "" {
751+ return strings.Replace(o.defaultSearch, "%s", input, 1)
752+ }
753+
754+ return input
755+}
756+
757+// toggleViewMode switches between history and favorites.
758+func (o *Omnibox) toggleViewMode() {
759+ o.mu.RLock()
760+ current := o.viewMode
761+ o.mu.RUnlock()
762+
763+ if current == ViewModeHistory {
764+ o.setViewMode(ViewModeFavorites)
765+ } else {
766+ o.setViewMode(ViewModeHistory)
767+ }
768+}
769+
770+// setViewMode changes the view mode and updates UI.
771+func (o *Omnibox) setViewMode(mode ViewMode) {
772+ o.mu.Lock()
773+ o.viewMode = mode
774+ o.mu.Unlock()
775+
776+ // Update header button styling
777+ if mode == ViewModeHistory {
778+ o.historyBtn.AddCssClass("omnibox-header-active")
779+ o.favoritesBtn.RemoveCssClass("omnibox-header-active")
780+ } else {
781+ o.historyBtn.RemoveCssClass("omnibox-header-active")
782+ o.favoritesBtn.AddCssClass("omnibox-header-active")
783+ }
784+
785+ // Reload data
786+ o.performSearch()
787+}
788+
789+// Show opens the omnibox with optional initial query.
790+func (o *Omnibox) Show(ctx context.Context, query string) {
791+ log := logging.FromContext(ctx)
792+ log.Debug().Str("query", query).Msg("showing omnibox")
793+
794+ o.mu.Lock()
795+ if o.visible {
796+ o.mu.Unlock()
797+ return
798+ }
799+ o.visible = true
800+ o.mu.Unlock()
801+
802+ // Set initial query
803+ o.entry.SetText(query)
804+
805+ // Load initial data
806+ o.performSearch()
807+
808+ // Present the window
809+ o.window.Present()
810+
811+ // Focus the entry
812+ o.entry.GrabFocus()
813+}
814+
815+// Hide closes the omnibox.
816+func (o *Omnibox) Hide(ctx context.Context) {
817+ log := logging.FromContext(ctx)
818+ log.Debug().Msg("hiding omnibox")
819+
820+ o.mu.Lock()
821+ if !o.visible {
822+ o.mu.Unlock()
823+ return
824+ }
825+ o.visible = false
826+ o.mu.Unlock()
827+
828+ // Cancel any pending search
829+ o.debounceMu.Lock()
830+ if o.debounceTimer != nil {
831+ o.debounceTimer.Stop()
832+ o.debounceTimer = nil
833+ }
834+ o.debounceMu.Unlock()
835+
836+ // Hide window
837+ o.window.SetVisible(false)
838+
839+ // Clear state
840+ o.entry.SetText("")
841+ o.listBox.RemoveAll()
842+
843+ if o.onClose != nil {
844+ o.onClose()
845+ }
846+}
847+
848+// Toggle shows if hidden, hides if visible.
849+func (o *Omnibox) Toggle(ctx context.Context) {
850+ o.mu.RLock()
851+ visible := o.visible
852+ o.mu.RUnlock()
853+
854+ if visible {
855+ o.Hide(ctx)
856+ } else {
857+ o.Show(ctx, "")
858+ }
859+}
860+
861+// IsVisible returns whether the omnibox is currently shown.
862+func (o *Omnibox) IsVisible() bool {
863+ o.mu.RLock()
864+ defer o.mu.RUnlock()
865+ return o.visible
866+}
867+
868+// SetOnNavigate sets the callback for URL navigation.
869+func (o *Omnibox) SetOnNavigate(fn func(url string)) {
870+ o.onNavigate = fn
871+}
872+
873+// SetOnClose sets the callback for omnibox close events.
874+func (o *Omnibox) SetOnClose(fn func()) {
875+ o.onClose = fn
876+}
877+
878+// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
879+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
880+ // Store suggestions in a channel to pass to idle callback
881+ // Using a closure that captures the suggestions
882+ var cb glib.SourceFunc = func(data uintptr) bool {
883+ o.updateSuggestions(suggestions)
884+ return false // One-shot callback, return false to remove source
885+ }
886+ glib.IdleAdd(&cb, 0)
887+}
888+
889+// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
890+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
891+ var cb glib.SourceFunc = func(data uintptr) bool {
892+ o.updateFavorites(favorites)
893+ return false // One-shot callback, return false to remove source
894+ }
895+ glib.IdleAdd(&cb, 0)
896+}
897+
898+// Destroy cleans up omnibox resources.
899+func (o *Omnibox) Destroy() {
900+ o.debounceMu.Lock()
901+ if o.debounceTimer != nil {
902+ o.debounceTimer.Stop()
903+ }
904+ o.debounceMu.Unlock()
905+
906+ if o.window != nil {
907+ o.window.Destroy()
908+ o.window = nil
909+ }
910+}
911+
912+// formatShortcut formats a shortcut number.
913+func formatShortcut(n int) string {
914+ return "Ctrl+" + string(rune('0'+n))
915+}
916+
917+// errNilWidget creates an error for nil widget creation.
918+func errNilWidget(name string) error {
919+ return &widgetError{name: name}
920+}
921+
922+type widgetError struct {
923+ name string
924+}
925+
926+func (e *widgetError) Error() string {
927+ return "failed to create widget: " + e.name
928+}
@@ -0,0 +1,928 @@
1+package component
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+ "github.com/jwijenbergh/puregotk/v4/gtk"
15+)
16+
17+const (
18+ omniboxWidth = 600
19+ omniboxMaxResults = 10
20+ debounceDelayMs = 120
21+)
22+
23+// ViewMode distinguishes history search from favorites display.
24+type ViewMode string
25+
26+const (
27+ ViewModeHistory ViewMode = "history"
28+ ViewModeFavorites ViewMode = "favorites"
29+)
30+
31+// Suggestion represents a search result from history.
32+type Suggestion struct {
33+ URL string
34+ Title string
35+ FaviconURL string
36+}
37+
38+// Favorite represents a bookmarked URL.
39+type Favorite struct {
40+ ID int64
41+ URL string
42+ Title string
43+ FaviconURL string
44+ Position int
45+}
46+
47+// Omnibox is the native GTK4 address bar / command palette.
48+type Omnibox struct {
49+ // GTK widgets
50+ window *gtk.Window
51+ mainBox *gtk.Box
52+ headerBox *gtk.Box
53+ historyBtn *gtk.Button
54+ favoritesBtn *gtk.Button
55+ entry *gtk.SearchEntry
56+ scrolledWin *gtk.ScrolledWindow
57+ listBox *gtk.ListBox
58+
59+ // Parent window reference for positioning
60+ parentWindow *gtk.ApplicationWindow
61+
62+ // State
63+ mu sync.RWMutex
64+ visible bool
65+ viewMode ViewMode
66+ selectedIndex int
67+ suggestions []Suggestion
68+ favorites []Favorite
69+
70+ // Dependencies
71+ historyUC *usecase.SearchHistoryUseCase
72+ favoritesUC *usecase.ManageFavoritesUseCase
73+ shortcuts map[string]config.SearchShortcut
74+ defaultSearch string
75+ initialBehavior string
76+ ctx context.Context
77+
78+ // Callbacks
79+ onNavigate func(url string)
80+ onClose func()
81+
82+ // Debouncing
83+ debounceTimer *time.Timer
84+ debounceMu sync.Mutex
85+}
86+
87+// OmniboxConfig holds configuration for creating an Omnibox.
88+type OmniboxConfig struct {
89+ HistoryUC *usecase.SearchHistoryUseCase
90+ FavoritesUC *usecase.ManageFavoritesUseCase
91+ Shortcuts map[string]config.SearchShortcut
92+ DefaultSearch string
93+ InitialBehavior string
94+}
95+
96+// NewOmnibox creates a new native GTK4 omnibox widget.
97+func NewOmnibox(ctx context.Context, parent *gtk.ApplicationWindow, cfg OmniboxConfig) *Omnibox {
98+ log := logging.FromContext(ctx)
99+
100+ o := &Omnibox{
101+ parentWindow: parent,
102+ viewMode: ViewModeHistory,
103+ selectedIndex: -1,
104+ historyUC: cfg.HistoryUC,
105+ favoritesUC: cfg.FavoritesUC,
106+ shortcuts: cfg.Shortcuts,
107+ defaultSearch: cfg.DefaultSearch,
108+ initialBehavior: cfg.InitialBehavior,
109+ ctx: ctx,
110+ }
111+
112+ if err := o.createWidgets(); err != nil {
113+ log.Error().Err(err).Msg("failed to create omnibox widgets")
114+ return nil
115+ }
116+
117+ o.setupKeyboardHandling()
118+ o.setupEntryChanged()
119+
120+ log.Debug().Msg("omnibox created")
121+ return o
122+}
123+
124+// createWidgets builds the GTK widget hierarchy.
125+func (o *Omnibox) createWidgets() error {
126+ // Create the popup window
127+ o.window = gtk.NewWindow()
128+ if o.window == nil {
129+ return errNilWidget("window")
130+ }
131+
132+ o.window.SetModal(true)
133+ o.window.SetDecorated(false)
134+ o.window.SetDefaultSize(omniboxWidth, 400)
135+ o.window.AddCssClass("omnibox-window")
136+
137+ // Set transient for parent window (positioning)
138+ if o.parentWindow != nil {
139+ o.window.SetTransientFor(&o.parentWindow.Window)
140+ }
141+
142+ // Main vertical box
143+ o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
144+ if o.mainBox == nil {
145+ return errNilWidget("mainBox")
146+ }
147+ o.mainBox.AddCssClass("omnibox-container")
148+
149+ // Header with History/Favorites toggle
150+ o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
151+ if o.headerBox == nil {
152+ return errNilWidget("headerBox")
153+ }
154+ o.headerBox.AddCssClass("omnibox-header")
155+ o.headerBox.SetHexpand(true)
156+
157+ o.historyBtn = gtk.NewButtonWithLabel("History")
158+ if o.historyBtn == nil {
159+ return errNilWidget("historyBtn")
160+ }
161+ o.historyBtn.AddCssClass("omnibox-header-btn")
162+ o.historyBtn.AddCssClass("omnibox-header-active")
163+ o.historyBtn.SetCanFocus(false)
164+
165+ o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
166+ if o.favoritesBtn == nil {
167+ return errNilWidget("favoritesBtn")
168+ }
169+ o.favoritesBtn.AddCssClass("omnibox-header-btn")
170+ o.favoritesBtn.SetCanFocus(false)
171+
172+ // Connect header button clicks
173+ historyClickCb := func(btn gtk.Button) {
174+ o.setViewMode(ViewModeHistory)
175+ }
176+ o.historyBtn.ConnectClicked(&historyClickCb)
177+
178+ favoritesClickCb := func(btn gtk.Button) {
179+ o.setViewMode(ViewModeFavorites)
180+ }
181+ o.favoritesBtn.ConnectClicked(&favoritesClickCb)
182+
183+ o.headerBox.Append(&o.historyBtn.Widget)
184+ o.headerBox.Append(&o.favoritesBtn.Widget)
185+
186+ // Search entry field
187+ o.entry = gtk.NewSearchEntry()
188+ if o.entry == nil {
189+ return errNilWidget("entry")
190+ }
191+ o.entry.AddCssClass("omnibox-entry")
192+ o.entry.SetHexpand(true)
193+
194+ placeholder := "Search history or enter URL..."
195+ o.entry.SetPlaceholderText(&placeholder)
196+
197+ // Scrolled window for list
198+ o.scrolledWin = gtk.NewScrolledWindow()
199+ if o.scrolledWin == nil {
200+ return errNilWidget("scrolledWin")
201+ }
202+ o.scrolledWin.AddCssClass("omnibox-scrolled")
203+ o.scrolledWin.SetVexpand(true)
204+ o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
205+
206+ // List box for suggestions
207+ o.listBox = gtk.NewListBox()
208+ if o.listBox == nil {
209+ return errNilWidget("listBox")
210+ }
211+ o.listBox.AddCssClass("omnibox-listbox")
212+ o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
213+
214+ // Connect row selection
215+ rowSelectedCb := func(lb gtk.ListBox, rowPtr uintptr) {
216+ if rowPtr == 0 {
217+ o.mu.Lock()
218+ o.selectedIndex = -1
219+ o.mu.Unlock()
220+ return
221+ }
222+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
223+ if row != nil {
224+ o.mu.Lock()
225+ o.selectedIndex = row.GetIndex()
226+ o.mu.Unlock()
227+ }
228+ }
229+ o.listBox.ConnectRowSelected(&rowSelectedCb)
230+
231+ // Assemble hierarchy
232+ o.scrolledWin.SetChild(&o.listBox.Widget)
233+ o.mainBox.Append(&o.headerBox.Widget)
234+ o.mainBox.Append(&o.entry.Widget)
235+ o.mainBox.Append(&o.scrolledWin.Widget)
236+ o.window.SetChild(&o.mainBox.Widget)
237+
238+ return nil
239+}
240+
241+// setupKeyboardHandling adds keyboard event handling.
242+func (o *Omnibox) setupKeyboardHandling() {
243+ log := logging.FromContext(o.ctx)
244+ controller := gtk.NewEventControllerKey()
245+ if controller == nil {
246+ log.Error().Msg("failed to create event controller key")
247+ return
248+ }
249+
250+ // Set capture phase to intercept before entry
251+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
252+
253+ keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
254+ return o.handleKeyPress(keyval, state)
255+ }
256+ controller.ConnectKeyPressed(&keyPressedCb)
257+
258+ o.window.AddController(&controller.EventController)
259+}
260+
261+// setupEntryChanged wires entry text changes to debounced search.
262+func (o *Omnibox) setupEntryChanged() {
263+ // SearchEntry has built-in debouncing via search-changed signal
264+ changedCb := func(_ gtk.SearchEntry) {
265+ o.onEntryChanged()
266+ }
267+ o.entry.ConnectSearchChanged(&changedCb)
268+}
269+
270+// handleKeyPress processes keyboard events.
271+// Returns true if the event was handled.
272+func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
273+ ctrl := state&gdk.ControlMaskValue != 0
274+
275+ switch keyval {
276+ case uint(gdk.KEY_Escape):
277+ text := o.entry.GetText()
278+ if text != "" {
279+ o.entry.SetText("")
280+ return true
281+ }
282+ o.Hide(o.ctx)
283+ return true
284+
285+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
286+ o.navigateToSelected()
287+ return true
288+
289+ case uint(gdk.KEY_Up):
290+ o.selectPrevious()
291+ return true
292+
293+ case uint(gdk.KEY_Down):
294+ o.selectNext()
295+ return true
296+
297+ case uint(gdk.KEY_Tab):
298+ o.toggleViewMode()
299+ return true
300+
301+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
302+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
303+ if ctrl {
304+ index := int(keyval - uint(gdk.KEY_1)) // 0-8
305+ o.selectAndNavigate(index)
306+ return true
307+ }
308+
309+ case uint(gdk.KEY_0):
310+ if ctrl {
311+ o.selectAndNavigate(9) // 10th item
312+ return true
313+ }
314+ }
315+
316+ return false // Let entry handle the key
317+}
318+
319+// onEntryChanged handles text input changes with debouncing.
320+func (o *Omnibox) onEntryChanged() {
321+ o.debounceMu.Lock()
322+ if o.debounceTimer != nil {
323+ o.debounceTimer.Stop()
324+ }
325+ o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
326+ o.performSearch()
327+ })
328+ o.debounceMu.Unlock()
329+}
330+
331+// performSearch executes the search based on current view mode and query.
332+func (o *Omnibox) performSearch() {
333+ o.mu.RLock()
334+ mode := o.viewMode
335+ o.mu.RUnlock()
336+
337+ query := o.entry.GetText()
338+
339+ if mode == ViewModeFavorites {
340+ o.loadFavorites(query)
341+ return
342+ }
343+
344+ // History search
345+ if query == "" {
346+ o.loadInitialHistory()
347+ return
348+ }
349+
350+ // Perform fuzzy search
351+ go func() {
352+ ctx := o.ctx
353+ log := logging.FromContext(ctx)
354+ if o.historyUC == nil {
355+ log.Debug().Msg("omnibox search: historyUC is nil")
356+ return
357+ }
358+
359+ log.Debug().Str("query", query).Int("limit", omniboxMaxResults).Msg("omnibox performing history search")
360+
361+ input := usecase.SearchInput{
362+ Query: query,
363+ Limit: omniboxMaxResults,
364+ }
365+ output, err := o.historyUC.Search(ctx, input)
366+ if err != nil {
367+ log.Error().Err(err).Msg("history search failed")
368+ return
369+ }
370+
371+ log.Debug().Int("results", len(output.Matches)).Str("query", query).Msg("omnibox search completed")
372+
373+ suggestions := make([]Suggestion, 0, len(output.Matches))
374+ for _, r := range output.Matches {
375+ suggestions = append(suggestions, Suggestion{
376+ URL: r.Entry.URL,
377+ Title: r.Entry.Title,
378+ })
379+ }
380+
381+ // Marshal back to GTK main thread
382+ o.idleAddUpdateSuggestions(suggestions)
383+ }()
384+}
385+
386+// loadInitialHistory loads history based on InitialBehavior config.
387+func (o *Omnibox) loadInitialHistory() {
388+ if o.historyUC == nil {
389+ return
390+ }
391+
392+ go func() {
393+ ctx := o.ctx
394+ log := logging.FromContext(ctx)
395+ var suggestions []Suggestion
396+
397+ log.Debug().Str("behavior", o.initialBehavior).Msg("omnibox loading initial history")
398+
399+ switch o.initialBehavior {
400+ case "none":
401+ // Show nothing
402+ log.Debug().Msg("omnibox initial behavior: none")
403+ o.idleAddUpdateSuggestions(nil)
404+ return
405+
406+ case "most_visited":
407+ // TODO: Implement GetMostVisited in use case if needed
408+ // For now, fall through to recent
409+ fallthrough
410+
411+ case "recent", "":
412+ results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)
413+ if err != nil {
414+ log.Error().Err(err).Msg("failed to load recent history")
415+ return
416+ }
417+ log.Debug().Int("results", len(results)).Msg("omnibox loaded recent history")
418+ suggestions = make([]Suggestion, 0, len(results))
419+ for _, r := range results {
420+ suggestions = append(suggestions, Suggestion{
421+ URL: r.URL,
422+ Title: r.Title,
423+ })
424+ }
425+ }
426+
427+ o.idleAddUpdateSuggestions(suggestions)
428+ }()
429+}
430+
431+// loadFavorites loads favorites, optionally filtered by query.
432+func (o *Omnibox) loadFavorites(query string) {
433+ if o.favoritesUC == nil {
434+ return
435+ }
436+
437+ go func() {
438+ ctx := o.ctx
439+ log := logging.FromContext(ctx)
440+ results, err := o.favoritesUC.GetAll(ctx)
441+ if err != nil {
442+ log.Error().Err(err).Msg("failed to load favorites")
443+ return
444+ }
445+
446+ favorites := make([]Favorite, 0, len(results))
447+ queryLower := strings.ToLower(query)
448+ for i, r := range results {
449+ // Filter by query if provided
450+ if query != "" {
451+ titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
452+ urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
453+ if !titleMatch && !urlMatch {
454+ continue
455+ }
456+ }
457+ favorites = append(favorites, Favorite{
458+ ID: int64(r.ID),
459+ URL: r.URL,
460+ Title: r.Title,
461+ Position: i,
462+ })
463+ }
464+
465+ o.idleAddUpdateFavorites(favorites)
466+ }()
467+}
468+
469+// updateSuggestions updates the list with history suggestions.
470+func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
471+ o.mu.Lock()
472+ o.suggestions = suggestions
473+ o.selectedIndex = -1
474+ o.mu.Unlock()
475+
476+ o.rebuildList()
477+
478+ // Select first item if available
479+ if len(suggestions) > 0 {
480+ o.selectIndex(0)
481+ }
482+}
483+
484+// updateFavorites updates the list with favorites.
485+func (o *Omnibox) updateFavorites(favorites []Favorite) {
486+ o.mu.Lock()
487+ o.favorites = favorites
488+ o.selectedIndex = -1
489+ o.mu.Unlock()
490+
491+ o.rebuildList()
492+
493+ // Select first item if available
494+ if len(favorites) > 0 {
495+ o.selectIndex(0)
496+ }
497+}
498+
499+// rebuildList rebuilds the ListBox contents.
500+func (o *Omnibox) rebuildList() {
501+ // Clear existing rows
502+ o.listBox.RemoveAll()
503+
504+ o.mu.RLock()
505+ mode := o.viewMode
506+ suggestions := o.suggestions
507+ favorites := o.favorites
508+ o.mu.RUnlock()
509+
510+ if mode == ViewModeHistory {
511+ for i, s := range suggestions {
512+ row := o.createSuggestionRow(s, i)
513+ if row != nil {
514+ o.listBox.Append(&row.Widget)
515+ }
516+ }
517+ } else {
518+ for i, f := range favorites {
519+ row := o.createFavoriteRow(f, i)
520+ if row != nil {
521+ o.listBox.Append(&row.Widget)
522+ }
523+ }
524+ }
525+}
526+
527+// createSuggestionRow creates a ListBoxRow for a suggestion.
528+func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
529+ row := gtk.NewListBoxRow()
530+ if row == nil {
531+ return nil
532+ }
533+ row.AddCssClass("omnibox-row")
534+
535+ box := gtk.NewBox(gtk.OrientationHorizontalValue, 8)
536+ if box == nil {
537+ return nil
538+ }
539+ box.SetHexpand(true)
540+
541+ // URL/Title label
542+ displayText := s.Title
543+ if displayText == "" {
544+ displayText = s.URL
545+ }
546+ label := gtk.NewLabel(&displayText)
547+ if label != nil {
548+ label.AddCssClass("omnibox-suggestion-title")
549+ label.SetHalign(gtk.AlignStartValue)
550+ label.SetHexpand(true)
551+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
552+ box.Append(&label.Widget)
553+ }
554+
555+ // Shortcut label (Ctrl+1-9)
556+ if index < 9 {
557+ shortcutText := formatShortcut(index + 1)
558+ shortcutLabel := gtk.NewLabel(&shortcutText)
559+ if shortcutLabel != nil {
560+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
561+ box.Append(&shortcutLabel.Widget)
562+ }
563+ } else if index == 9 {
564+ shortcutText := "Ctrl+0"
565+ shortcutLabel := gtk.NewLabel(&shortcutText)
566+ if shortcutLabel != nil {
567+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
568+ box.Append(&shortcutLabel.Widget)
569+ }
570+ }
571+
572+ row.SetChild(&box.Widget)
573+ return row
574+}
575+
576+// createFavoriteRow creates a ListBoxRow for a favorite.
577+func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
578+ row := gtk.NewListBoxRow()
579+ if row == nil {
580+ return nil
581+ }
582+ row.AddCssClass("omnibox-row")
583+
584+ box := gtk.NewBox(gtk.OrientationHorizontalValue, 8)
585+ if box == nil {
586+ return nil
587+ }
588+ box.SetHexpand(true)
589+
590+ // Title label
591+ displayText := f.Title
592+ if displayText == "" {
593+ displayText = f.URL
594+ }
595+ label := gtk.NewLabel(&displayText)
596+ if label != nil {
597+ label.AddCssClass("omnibox-suggestion-title")
598+ label.SetHalign(gtk.AlignStartValue)
599+ label.SetHexpand(true)
600+ label.SetEllipsize(2)
601+ box.Append(&label.Widget)
602+ }
603+
604+ // Shortcut label
605+ if index < 9 {
606+ shortcutText := formatShortcut(index + 1)
607+ shortcutLabel := gtk.NewLabel(&shortcutText)
608+ if shortcutLabel != nil {
609+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
610+ box.Append(&shortcutLabel.Widget)
611+ }
612+ } else if index == 9 {
613+ shortcutText := "Ctrl+0"
614+ shortcutLabel := gtk.NewLabel(&shortcutText)
615+ if shortcutLabel != nil {
616+ shortcutLabel.AddCssClass("omnibox-shortcut-label")
617+ box.Append(&shortcutLabel.Widget)
618+ }
619+ }
620+
621+ row.SetChild(&box.Widget)
622+ return row
623+}
624+
625+// selectIndex selects a row by index.
626+func (o *Omnibox) selectIndex(index int) {
627+ o.mu.Lock()
628+ defer o.mu.Unlock()
629+
630+ row := o.listBox.GetRowAtIndex(index)
631+ if row != nil {
632+ o.listBox.SelectRow(row)
633+ o.selectedIndex = index
634+ }
635+}
636+
637+// selectNext moves selection down.
638+func (o *Omnibox) selectNext() {
639+ o.mu.RLock()
640+ current := o.selectedIndex
641+ mode := o.viewMode
642+ var maxIndex int
643+ if mode == ViewModeHistory {
644+ maxIndex = len(o.suggestions) - 1
645+ } else {
646+ maxIndex = len(o.favorites) - 1
647+ }
648+ o.mu.RUnlock()
649+
650+ if maxIndex < 0 {
651+ return
652+ }
653+
654+ newIndex := current + 1
655+ if newIndex > maxIndex {
656+ newIndex = 0 // Wrap around
657+ }
658+ o.selectIndex(newIndex)
659+}
660+
661+// selectPrevious moves selection up.
662+func (o *Omnibox) selectPrevious() {
663+ o.mu.RLock()
664+ current := o.selectedIndex
665+ mode := o.viewMode
666+ var maxIndex int
667+ if mode == ViewModeHistory {
668+ maxIndex = len(o.suggestions) - 1
669+ } else {
670+ maxIndex = len(o.favorites) - 1
671+ }
672+ o.mu.RUnlock()
673+
674+ if maxIndex < 0 {
675+ return
676+ }
677+
678+ newIndex := current - 1
679+ if newIndex < 0 {
680+ newIndex = maxIndex // Wrap around
681+ }
682+ o.selectIndex(newIndex)
683+}
684+
685+// selectAndNavigate selects an index and navigates to it.
686+func (o *Omnibox) selectAndNavigate(index int) {
687+ o.selectIndex(index)
688+ o.navigateToSelected()
689+}
690+
691+// navigateToSelected navigates to the currently selected item.
692+func (o *Omnibox) navigateToSelected() {
693+ o.mu.RLock()
694+ mode := o.viewMode
695+ idx := o.selectedIndex
696+ suggestions := o.suggestions
697+ favorites := o.favorites
698+ o.mu.RUnlock()
699+
700+ var url string
701+
702+ if idx < 0 {
703+ // No selection - use entry text as URL
704+ url = o.buildURL(o.entry.GetText())
705+ } else if mode == ViewModeHistory {
706+ if idx < len(suggestions) {
707+ url = suggestions[idx].URL
708+ }
709+ } else {
710+ if idx < len(favorites) {
711+ url = favorites[idx].URL
712+ }
713+ }
714+
715+ if url == "" {
716+ return
717+ }
718+
719+ o.Hide(o.ctx)
720+
721+ if o.onNavigate != nil {
722+ o.onNavigate(url)
723+ }
724+}
725+
726+// buildURL constructs a URL from input, handling search shortcuts.
727+func (o *Omnibox) buildURL(input string) string {
728+ if input == "" {
729+ return ""
730+ }
731+
732+ // Check for search shortcut (e.g., "g:query")
733+ if colonIdx := strings.Index(input, ":"); colonIdx > 0 && colonIdx < 10 {
734+ prefix := input[:colonIdx]
735+ if shortcut, ok := o.shortcuts[prefix]; ok {
736+ query := strings.TrimSpace(input[colonIdx+1:])
737+ return strings.Replace(shortcut.URL, "%s", query, 1)
738+ }
739+ }
740+
741+ // Check if it looks like a URL
742+ if strings.Contains(input, ".") && !strings.Contains(input, " ") {
743+ if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
744+ return "https://" + input
745+ }
746+ return input
747+ }
748+
749+ // Use default search
750+ if o.defaultSearch != "" {
751+ return strings.Replace(o.defaultSearch, "%s", input, 1)
752+ }
753+
754+ return input
755+}
756+
757+// toggleViewMode switches between history and favorites.
758+func (o *Omnibox) toggleViewMode() {
759+ o.mu.RLock()
760+ current := o.viewMode
761+ o.mu.RUnlock()
762+
763+ if current == ViewModeHistory {
764+ o.setViewMode(ViewModeFavorites)
765+ } else {
766+ o.setViewMode(ViewModeHistory)
767+ }
768+}
769+
770+// setViewMode changes the view mode and updates UI.
771+func (o *Omnibox) setViewMode(mode ViewMode) {
772+ o.mu.Lock()
773+ o.viewMode = mode
774+ o.mu.Unlock()
775+
776+ // Update header button styling
777+ if mode == ViewModeHistory {
778+ o.historyBtn.AddCssClass("omnibox-header-active")
779+ o.favoritesBtn.RemoveCssClass("omnibox-header-active")
780+ } else {
781+ o.historyBtn.RemoveCssClass("omnibox-header-active")
782+ o.favoritesBtn.AddCssClass("omnibox-header-active")
783+ }
784+
785+ // Reload data
786+ o.performSearch()
787+}
788+
789+// Show opens the omnibox with optional initial query.
790+func (o *Omnibox) Show(ctx context.Context, query string) {
791+ log := logging.FromContext(ctx)
792+ log.Debug().Str("query", query).Msg("showing omnibox")
793+
794+ o.mu.Lock()
795+ if o.visible {
796+ o.mu.Unlock()
797+ return
798+ }
799+ o.visible = true
800+ o.mu.Unlock()
801+
802+ // Set initial query
803+ o.entry.SetText(query)
804+
805+ // Load initial data
806+ o.performSearch()
807+
808+ // Present the window
809+ o.window.Present()
810+
811+ // Focus the entry
812+ o.entry.GrabFocus()
813+}
814+
815+// Hide closes the omnibox.
816+func (o *Omnibox) Hide(ctx context.Context) {
817+ log := logging.FromContext(ctx)
818+ log.Debug().Msg("hiding omnibox")
819+
820+ o.mu.Lock()
821+ if !o.visible {
822+ o.mu.Unlock()
823+ return
824+ }
825+ o.visible = false
826+ o.mu.Unlock()
827+
828+ // Cancel any pending search
829+ o.debounceMu.Lock()
830+ if o.debounceTimer != nil {
831+ o.debounceTimer.Stop()
832+ o.debounceTimer = nil
833+ }
834+ o.debounceMu.Unlock()
835+
836+ // Hide window
837+ o.window.SetVisible(false)
838+
839+ // Clear state
840+ o.entry.SetText("")
841+ o.listBox.RemoveAll()
842+
843+ if o.onClose != nil {
844+ o.onClose()
845+ }
846+}
847+
848+// Toggle shows if hidden, hides if visible.
849+func (o *Omnibox) Toggle(ctx context.Context) {
850+ o.mu.RLock()
851+ visible := o.visible
852+ o.mu.RUnlock()
853+
854+ if visible {
855+ o.Hide(ctx)
856+ } else {
857+ o.Show(ctx, "")
858+ }
859+}
860+
861+// IsVisible returns whether the omnibox is currently shown.
862+func (o *Omnibox) IsVisible() bool {
863+ o.mu.RLock()
864+ defer o.mu.RUnlock()
865+ return o.visible
866+}
867+
868+// SetOnNavigate sets the callback for URL navigation.
869+func (o *Omnibox) SetOnNavigate(fn func(url string)) {
870+ o.onNavigate = fn
871+}
872+
873+// SetOnClose sets the callback for omnibox close events.
874+func (o *Omnibox) SetOnClose(fn func()) {
875+ o.onClose = fn
876+}
877+
878+// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
879+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
880+ // Store suggestions in a channel to pass to idle callback
881+ // Using a closure that captures the suggestions
882+ var cb glib.SourceFunc = func(data uintptr) bool {
883+ o.updateSuggestions(suggestions)
884+ return false // One-shot callback, return false to remove source
885+ }
886+ glib.IdleAdd(&cb, 0)
887+}
888+
889+// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
890+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
891+ var cb glib.SourceFunc = func(data uintptr) bool {
892+ o.updateFavorites(favorites)
893+ return false // One-shot callback, return false to remove source
894+ }
895+ glib.IdleAdd(&cb, 0)
896+}
897+
898+// Destroy cleans up omnibox resources.
899+func (o *Omnibox) Destroy() {
900+ o.debounceMu.Lock()
901+ if o.debounceTimer != nil {
902+ o.debounceTimer.Stop()
903+ }
904+ o.debounceMu.Unlock()
905+
906+ if o.window != nil {
907+ o.window.Destroy()
908+ o.window = nil
909+ }
910+}
911+
912+// formatShortcut formats a shortcut number.
913+func formatShortcut(n int) string {
914+ return "Ctrl+" + string(rune('0'+n))
915+}
916+
917+// errNilWidget creates an error for nil widget creation.
918+func errNilWidget(name string) error {
919+ return &widgetError{name: name}
920+}
921+
922+type widgetError struct {
923+ name string
924+}
925+
926+func (e *widgetError) Error() string {
927+ return "failed to create widget: " + e.name
928+}
M internal/ui/theme/css.go +134 -0
@@ -43,6 +43,140 @@ button.tab-button.tab-button-active {
4343 color: #ffffff;
4444 font-weight: 500;
4545 }
46+
47+/* ===== Omnibox Styling ===== */
48+/* Matches Svelte5 omnibox with GTK4-native CSS */
49+
50+/* Omnibox window - floating popup with subtle depth */
51+window.omnibox-window {
52+ background-color: #2d2d2d;
53+ border: 1px solid #454545;
54+ border-radius: 3px;
55+}
56+
57+/* Main container */
58+.omnibox-container {
59+ padding: 0;
60+ background-color: transparent;
61+}
62+
63+/* Header with History/Favorites toggle - slightly elevated */
64+.omnibox-header {
65+ background-color: shade(#2d2d2d, 1.1);
66+ border-bottom: 1px solid #404040;
67+ padding: 6px 12px;
68+}
69+
70+.omnibox-header-btn {
71+ background-color: transparent;
72+ background-image: none;
73+ border: none;
74+ border-radius: 2px;
75+ padding: 4px 12px;
76+ margin-right: 8px;
77+ font-size: 13px;
78+ font-weight: 500;
79+ color: #909090;
80+ transition: all 100ms ease-in-out;
81+}
82+
83+.omnibox-header-btn:hover {
84+ background-color: alpha(#4a90e2, 0.15);
85+ color: #cccccc;
86+}
87+
88+.omnibox-header-btn.omnibox-header-active {
89+ background-color: alpha(#4a90e2, 0.2);
90+ color: #4a90e2;
91+ font-weight: 600;
92+}
93+
94+/* Search entry field - recessed appearance */
95+.omnibox-entry {
96+ background-color: #1a1a1a;
97+ color: #e8e8e8;
98+ border: 1px solid #404040;
99+ border-radius: 2px;
100+ padding: 10px 12px;
101+ margin: 8px 12px;
102+ font-size: 16px;
103+ caret-color: #4a90e2;
104+}
105+
106+.omnibox-entry:focus {
107+ border-color: #4a90e2;
108+ background-color: #1e1e1e;
109+}
110+
111+/* Scrolled window for suggestions */
112+.omnibox-scrolled {
113+ background-color: #2a2a2a;
114+ border-top: 1px solid #404040;
115+}
116+
117+/* List box */
118+.omnibox-listbox {
119+ background-color: transparent;
120+}
121+
122+/* Suggestion rows - with left accent border */
123+.omnibox-row {
124+ padding: 8px 12px;
125+ margin: 0;
126+ border-radius: 0;
127+ border-left: 3px solid transparent;
128+ border-bottom: 1px solid alpha(#505050, 0.5);
129+ transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out;
130+}
131+
132+.omnibox-row:last-child {
133+ border-bottom: none;
134+}
135+
136+.omnibox-row:hover {
137+ background-color: alpha(#4a90e2, 0.12);
138+ border-left-color: #4a90e2;
139+}
140+
141+.omnibox-row:selected {
142+ background-color: alpha(#4a90e2, 0.2);
143+ border-left-color: #4a90e2;
144+}
145+
146+/* Suggestion title/URL */
147+.omnibox-suggestion-title {
148+ font-size: 14px;
149+ color: #e0e0e0;
150+ font-weight: 400;
151+}
152+
153+.omnibox-row:selected .omnibox-suggestion-title {
154+ color: #ffffff;
155+}
156+
157+/* Keyboard shortcut label (Ctrl+1-9) */
158+.omnibox-shortcut-label {
159+ font-size: 11px;
160+ color: #707070;
161+ font-family: monospace;
162+}
163+
164+.omnibox-row:hover .omnibox-shortcut-label,
165+.omnibox-row:selected .omnibox-shortcut-label {
166+ color: alpha(#ffffff, 0.6);
167+}
168+
169+/* Search shortcut badge */
170+.omnibox-shortcut-badge {
171+ background-color: #4a90e2;
172+ color: #ffffff;
173+ border-radius: 0;
174+ border: 1px solid #3a80d2;
175+ padding: 2px 8px;
176+ font-size: 11px;
177+ font-weight: 600;
178+ margin-right: 8px;
179+}
46180 `
47181
48182 // ApplyCSS loads tab bar styling into the display.
@@ -43,6 +43,140 @@ button.tab-button.tab-button-active {
43 color: #ffffff;43 color: #ffffff;
44 font-weight: 500;44 font-weight: 500;
45 }45 }
46+
47+/* ===== Omnibox Styling ===== */
48+/* Matches Svelte5 omnibox with GTK4-native CSS */
49+
50+/* Omnibox window - floating popup with subtle depth */
51+window.omnibox-window {
52+ background-color: #2d2d2d;
53+ border: 1px solid #454545;
54+ border-radius: 3px;
55+}
56+
57+/* Main container */
58+.omnibox-container {
59+ padding: 0;
60+ background-color: transparent;
61+}
62+
63+/* Header with History/Favorites toggle - slightly elevated */
64+.omnibox-header {
65+ background-color: shade(#2d2d2d, 1.1);
66+ border-bottom: 1px solid #404040;
67+ padding: 6px 12px;
68+}
69+
70+.omnibox-header-btn {
71+ background-color: transparent;
72+ background-image: none;
73+ border: none;
74+ border-radius: 2px;
75+ padding: 4px 12px;
76+ margin-right: 8px;
77+ font-size: 13px;
78+ font-weight: 500;
79+ color: #909090;
80+ transition: all 100ms ease-in-out;
81+}
82+
83+.omnibox-header-btn:hover {
84+ background-color: alpha(#4a90e2, 0.15);
85+ color: #cccccc;
86+}
87+
88+.omnibox-header-btn.omnibox-header-active {
89+ background-color: alpha(#4a90e2, 0.2);
90+ color: #4a90e2;
91+ font-weight: 600;
92+}
93+
94+/* Search entry field - recessed appearance */
95+.omnibox-entry {
96+ background-color: #1a1a1a;
97+ color: #e8e8e8;
98+ border: 1px solid #404040;
99+ border-radius: 2px;
100+ padding: 10px 12px;
101+ margin: 8px 12px;
102+ font-size: 16px;
103+ caret-color: #4a90e2;
104+}
105+
106+.omnibox-entry:focus {
107+ border-color: #4a90e2;
108+ background-color: #1e1e1e;
109+}
110+
111+/* Scrolled window for suggestions */
112+.omnibox-scrolled {
113+ background-color: #2a2a2a;
114+ border-top: 1px solid #404040;
115+}
116+
117+/* List box */
118+.omnibox-listbox {
119+ background-color: transparent;
120+}
121+
122+/* Suggestion rows - with left accent border */
123+.omnibox-row {
124+ padding: 8px 12px;
125+ margin: 0;
126+ border-radius: 0;
127+ border-left: 3px solid transparent;
128+ border-bottom: 1px solid alpha(#505050, 0.5);
129+ transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out;
130+}
131+
132+.omnibox-row:last-child {
133+ border-bottom: none;
134+}
135+
136+.omnibox-row:hover {
137+ background-color: alpha(#4a90e2, 0.12);
138+ border-left-color: #4a90e2;
139+}
140+
141+.omnibox-row:selected {
142+ background-color: alpha(#4a90e2, 0.2);
143+ border-left-color: #4a90e2;
144+}
145+
146+/* Suggestion title/URL */
147+.omnibox-suggestion-title {
148+ font-size: 14px;
149+ color: #e0e0e0;
150+ font-weight: 400;
151+}
152+
153+.omnibox-row:selected .omnibox-suggestion-title {
154+ color: #ffffff;
155+}
156+
157+/* Keyboard shortcut label (Ctrl+1-9) */
158+.omnibox-shortcut-label {
159+ font-size: 11px;
160+ color: #707070;
161+ font-family: monospace;
162+}
163+
164+.omnibox-row:hover .omnibox-shortcut-label,
165+.omnibox-row:selected .omnibox-shortcut-label {
166+ color: alpha(#ffffff, 0.6);
167+}
168+
169+/* Search shortcut badge */
170+.omnibox-shortcut-badge {
171+ background-color: #4a90e2;
172+ color: #ffffff;
173+ border-radius: 0;
174+ border: 1px solid #3a80d2;
175+ padding: 2px 8px;
176+ font-size: 11px;
177+ font-weight: 600;
178+ margin-right: 8px;
179+}
46 `180 `
47 181
48 // ApplyCSS loads tab bar styling into the display.182 // ApplyCSS loads tab bar styling into the display.

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