1package component
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/dumber/internal/ui/adapter"
16 "github.com/bnema/dumber/internal/ui/layout"
17 "github.com/jwijenbergh/puregotk/v4/gdk"
18 "github.com/jwijenbergh/puregotk/v4/glib"
19 "github.com/jwijenbergh/puregotk/v4/gtk"
20)
21
22const (
23 omniboxWidthPct = 0.8 // 80% of parent window width
24 omniboxMaxWidth = 800 // Maximum width in pixels
25 omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated
26 omniboxHeightPct = 0.6 // 60% of parent window height
27 omniboxMaxResults = 10
28 omniboxTopMarginPct = 0.2 // 20% from top
29 debounceDelayMs = 150
30)
31
32// ViewMode distinguishes history search from favorites display.
33type ViewMode string
34
35const (
36 ViewModeHistory ViewMode = "history"
37 ViewModeFavorites ViewMode = "favorites"
38)
39
40// Suggestion represents a search result from history.
41type Suggestion struct {
42 URL string
43 Title string
44 FaviconURL string
45}
46
47// Favorite represents a bookmarked URL.
48type Favorite struct {
49 ID int64
50 URL string
51 Title string
52 FaviconURL string
53 Position int
54}
55
56// Omnibox is the native GTK4 address bar / command palette.
57type Omnibox struct {
58 // GTK widgets
59 outerBox *gtk.Box // Outer container for positioning
60 mainBox *gtk.Box // Main content box
61 headerBox *gtk.Box
62 historyBtn *gtk.Button
63 favoritesBtn *gtk.Button
64 zoomLabel *gtk.Label
65 entry *gtk.SearchEntry
66 scrolledWin *gtk.ScrolledWindow
67 listBox *gtk.ListBox
68
69 // Parent overlay reference for sizing (set via SetParentOverlay)
70 parentOverlay layout.OverlayWidget
71
72 // State
73 mu sync.RWMutex
74 visible bool
75 viewMode ViewMode
76 selectedIndex int
77 suggestions []Suggestion
78 favorites []Favorite
79 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
80
81 // Dependencies
82 historyUC *usecase.SearchHistoryUseCase
83 favoritesUC *usecase.ManageFavoritesUseCase
84 faviconAdapter *adapter.FaviconAdapter
85 copyURLUC *usecase.CopyURLUseCase
86 shortcuts map[string]config.SearchShortcut
87 defaultSearch string
88 initialBehavior string
89 ctx context.Context
90
91 // Callbacks
92 onNavigate func(url string)
93 onClose func()
94 onToast func(message string)
95
96 // Debouncing
97 debounceTimer *time.Timer
98 debounceMu sync.Mutex
99 lastQuery string // Prevent duplicate searches
100
101 // Scaling
102 uiScale float64
103
104 // Cached measurements (populated after first layout)
105 measuredHeights struct {
106 header int // headerBox natural height
107 entry int // entry natural height
108 singleRow int // single ListBoxRow natural height
109 valid bool // whether cache is valid
110 }
111
112 // Callback retention: keep GTK signal callbacks reachable by Go GC.
113 retainedCallbacks []interface{}
114}
115
116// OmniboxConfig holds configuration for creating an Omnibox.
117type OmniboxConfig struct {
118 HistoryUC *usecase.SearchHistoryUseCase
119 FavoritesUC *usecase.ManageFavoritesUseCase
120 FaviconAdapter *adapter.FaviconAdapter
121 CopyURLUC *usecase.CopyURLUseCase
122 Shortcuts map[string]config.SearchShortcut
123 DefaultSearch string
124 InitialBehavior string
125 UIScale float64 // UI scale for favicon sizing
126 OnNavigate func(url string) // Callback when user navigates via omnibox
127 OnToast func(message string) // Callback to show toast notification
128}
129
130// NewOmnibox creates a new native GTK4 omnibox widget.
131// Call SetParentOverlay() before Show() to set the parent for sizing.
132func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
133 log := logging.FromContext(ctx)
134
135 uiScale := cfg.UIScale
136 if uiScale <= 0 {
137 uiScale = 1.0
138 }
139
140 o := &Omnibox{
141 viewMode: ViewModeHistory,
142 selectedIndex: -1,
143 historyUC: cfg.HistoryUC,
144 favoritesUC: cfg.FavoritesUC,
145 faviconAdapter: cfg.FaviconAdapter,
146 copyURLUC: cfg.CopyURLUC,
147 shortcuts: cfg.Shortcuts,
148 defaultSearch: cfg.DefaultSearch,
149 initialBehavior: cfg.InitialBehavior,
150 onToast: cfg.OnToast,
151 ctx: ctx,
152 uiScale: uiScale,
153 }
154
155 if err := o.createWidgets(); err != nil {
156 log.Error().Err(err).Msg("failed to create omnibox widgets")
157 return nil
158 }
159
160 o.setupKeyboardHandling()
161 o.setupEntryChanged()
162
163 // Set navigation callback if provided
164 if cfg.OnNavigate != nil {
165 o.onNavigate = cfg.OnNavigate
166 }
167
168 log.Debug().Msg("omnibox created")
169 return o
170}
171
172// SetParentOverlay sets the overlay widget used for sizing calculations.
173// Must be called before Show().
174func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
175 o.mu.Lock()
176 defer o.mu.Unlock()
177 o.parentOverlay = overlay
178}
179
180// WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
181// This is useful for adding the omnibox to a PaneView overlay.
182func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
183 if o.outerBox == nil {
184 return nil
185 }
186 return factory.WrapWidget(&o.outerBox.Widget)
187}
188
189// resizeAndCenter adjusts the omnibox size based on content and centers it.
190// rowCount is the number of result rows to display (0 = no content, max 10).
191func (o *Omnibox) resizeAndCenter(rowCount int) {
192 if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil {
193 return
194 }
195
196 // Use GetAllocatedWidth for actual rendered size (height not needed here)
197 parentWidth := o.parentOverlay.GetAllocatedWidth()
198 if parentWidth <= 0 {
199 parentWidth = omniboxMaxWidth
200 }
201
202 width := int(float64(parentWidth) * omniboxWidthPct)
203 if width > omniboxMaxWidth {
204 width = omniboxMaxWidth
205 }
206
207 // Cap at max results
208 if rowCount > omniboxMaxResults {
209 rowCount = omniboxMaxResults
210 }
211
212 // Schedule measurement after GTK has laid out widgets
213 // This ensures we get accurate heights from actual rendered content
214 var cb glib.SourceFunc = func(uintptr) bool {
215 o.measureAndResize(width, rowCount)
216 return false // One-shot
217 }
218 glib.IdleAdd(&cb, 0)
219}
220
221// measureAndResize calculates and sets the omnibox height based on row count.
222// Uses measured widget heights when available, falls back to estimates.
223func (o *Omnibox) measureAndResize(width, rowCount int) {
224 if o.outerBox == nil || o.mainBox == nil {
225 return
226 }
227
228 log := logging.FromContext(o.ctx)
229
230 var rowHeight int
231
232 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
233 // Use measured values from GTK4 Measure API
234 rowHeight = o.measuredHeights.singleRow
235 } else {
236 // Fallback to hardcoded estimate (before first measurement)
237 const fallbackRowHeight = 60
238 rowHeight = int(fallbackRowHeight * o.uiScale)
239 }
240
241 // Calculate content height for the scrolled window
242 // This is the key to dynamic sizing - limit the ScrolledWindow's max height
243 contentHeight := 0
244 if rowCount > 0 {
245 contentHeight = rowCount * rowHeight
246 }
247
248 // Cap at max results
249 maxContentHeight := omniboxMaxResults * rowHeight
250 if contentHeight > maxContentHeight {
251 contentHeight = maxContentHeight
252 }
253
254 // Set size constraints on ScrolledWindow to control dynamic sizing
255 if o.scrolledWin != nil {
256 // Reset min first to avoid assertion failure when shrinking
257 // GTK requires min <= max, so we reset min before setting new values
258 o.scrolledWin.SetMinContentHeight(-1)
259 o.scrolledWin.SetMaxContentHeight(contentHeight)
260 o.scrolledWin.SetMinContentHeight(contentHeight)
261 }
262
263 // Force layout recalculation
264 o.outerBox.QueueResize()
265
266 log.Debug().
267 Int("width", width).
268 Int("contentHeight", contentHeight).
269 Int("rowHeight", rowHeight).
270 Int("rows", rowCount).
271 Bool("measured", o.measuredHeights.valid).
272 Msg("omnibox resized")
273}
274
275// createWidgets builds the GTK widget hierarchy.
276func (o *Omnibox) createWidgets() error {
277 if err := o.initOuterBox(); err != nil {
278 return err
279 }
280 if err := o.initMainBox(); err != nil {
281 return err
282 }
283 if err := o.initHeader(); err != nil {
284 return err
285 }
286 if err := o.initEntry(); err != nil {
287 return err
288 }
289 if err := o.initList(); err != nil {
290 return err
291 }
292 o.assembleWidgets()
293 return nil
294}
295
296// setupKeyboardHandling adds keyboard event handling.
297func (o *Omnibox) setupKeyboardHandling() {
298 log := logging.FromContext(o.ctx)
299 controller := gtk.NewEventControllerKey()
300 if controller == nil {
301 log.Error().Msg("failed to create event controller key")
302 return
303 }
304
305 // Set capture phase to intercept before entry
306 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
307
308 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
309 return o.handleKeyPress(keyval, state)
310 }
311 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
312 controller.ConnectKeyPressed(&keyPressedCb)
313
314 o.outerBox.AddController(&controller.EventController)
315}
316
317func (o *Omnibox) initOuterBox() error {
318 o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
319 if o.outerBox == nil {
320 return errNilWidget("outerBox")
321 }
322 o.outerBox.AddCssClass("omnibox-outer")
323 o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
324 o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
325 o.outerBox.SetVisible(false) // Hidden by default
326 return nil
327}
328
329func (o *Omnibox) initMainBox() error {
330 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
331 if o.mainBox == nil {
332 return errNilWidget("mainBox")
333 }
334 o.mainBox.AddCssClass("omnibox-container")
335 return nil
336}
337
338func (o *Omnibox) initHeader() error {
339 o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
340 if o.headerBox == nil {
341 return errNilWidget("headerBox")
342 }
343 o.headerBox.AddCssClass("omnibox-header")
344 o.headerBox.SetHexpand(true)
345
346 o.historyBtn = gtk.NewButtonWithLabel("History")
347 if o.historyBtn == nil {
348 return errNilWidget("historyBtn")
349 }
350 o.historyBtn.AddCssClass("omnibox-header-btn")
351 o.historyBtn.AddCssClass("omnibox-header-active")
352 o.historyBtn.SetCanFocus(false)
353
354 o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
355 if o.favoritesBtn == nil {
356 return errNilWidget("favoritesBtn")
357 }
358 o.favoritesBtn.AddCssClass("omnibox-header-btn")
359 o.favoritesBtn.SetCanFocus(false)
360
361 historyClickCb := func(_ gtk.Button) {
362 o.setViewMode(ViewModeHistory)
363 }
364 o.retainedCallbacks = append(o.retainedCallbacks, historyClickCb)
365 o.historyBtn.ConnectClicked(&historyClickCb)
366
367 favoritesClickCb := func(_ gtk.Button) {
368 o.setViewMode(ViewModeFavorites)
369 }
370 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
371 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
372
373 o.zoomLabel = gtk.NewLabel(nil)
374 if o.zoomLabel != nil {
375 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
376 o.zoomLabel.SetVisible(false)
377 o.zoomLabel.SetHalign(gtk.AlignEndValue)
378 o.zoomLabel.SetHexpand(true)
379 }
380 return nil
381}
382
383func (o *Omnibox) initEntry() error {
384 o.entry = gtk.NewSearchEntry()
385 if o.entry == nil {
386 return errNilWidget("entry")
387 }
388 o.entry.AddCssClass("omnibox-entry")
389 o.entry.SetHexpand(true)
390
391 placeholder := "Search history or enter URL..."
392 o.entry.SetPlaceholderText(&placeholder)
393 return nil
394}
395
396func (o *Omnibox) initList() error {
397 o.scrolledWin = gtk.NewScrolledWindow()
398 if o.scrolledWin == nil {
399 return errNilWidget("scrolledWin")
400 }
401 o.scrolledWin.AddCssClass("omnibox-scrolled")
402 o.scrolledWin.SetVexpand(true)
403 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
404 o.scrolledWin.SetPropagateNaturalHeight(true)
405
406 o.listBox = gtk.NewListBox()
407 if o.listBox == nil {
408 return errNilWidget("listBox")
409 }
410 o.listBox.AddCssClass("omnibox-listbox")
411 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
412
413 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
414 if rowPtr == 0 {
415 o.mu.Lock()
416 o.selectedIndex = -1
417 o.mu.Unlock()
418 return
419 }
420 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
421 if row != nil {
422 o.mu.Lock()
423 o.selectedIndex = row.GetIndex()
424 o.mu.Unlock()
425 }
426 }
427 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
428 o.listBox.ConnectRowSelected(&rowSelectedCb)
429 return nil
430}
431
432func (o *Omnibox) assembleWidgets() {
433 o.headerBox.Append(&o.historyBtn.Widget)
434 o.headerBox.Append(&o.favoritesBtn.Widget)
435 if o.zoomLabel != nil {
436 o.headerBox.Append(&o.zoomLabel.Widget)
437 }
438
439 o.scrolledWin.SetChild(&o.listBox.Widget)
440 o.mainBox.Append(&o.headerBox.Widget)
441 o.mainBox.Append(&o.entry.Widget)
442 o.mainBox.Append(&o.scrolledWin.Widget)
443 o.outerBox.Append(&o.mainBox.Widget)
444}
445
446// setupEntryChanged wires entry text changes to debounced search.
447func (o *Omnibox) setupEntryChanged() {
448 // SearchEntry has built-in debouncing via search-changed signal
449 changedCb := func(_ gtk.SearchEntry) {
450 o.onEntryChanged()
451 }
452 o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
453 o.entry.ConnectSearchChanged(&changedCb)
454}
455
456// handleKeyPress processes keyboard events.
457// Returns true if the event was handled.
458func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
459 ctrl := state&gdk.ControlMaskValue != 0
460
461 switch keyval {
462 case uint(gdk.KEY_Escape):
463 text := o.entry.GetText()
464 if text != "" {
465 o.entry.SetText("")
466 return true
467 }
468 o.Hide(o.ctx)
469 return true
470
471 case uint(gdk.KEY_l), uint(gdk.KEY_L):
472 // Ctrl+L toggles omnibox (hides when visible)
473 if ctrl {
474 o.Hide(o.ctx)
475 return true
476 }
477
478 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
479 o.navigateToSelected()
480 return true
481
482 case uint(gdk.KEY_Up):
483 o.selectPrevious()
484 return true
485
486 case uint(gdk.KEY_Down):
487 o.selectNext()
488 return true
489
490 case uint(gdk.KEY_Tab):
491 o.toggleViewMode()
492 return true
493
494 case uint(gdk.KEY_space):
495 // Space toggles favorite only if user has navigated with arrow keys
496 // Otherwise, let space pass through to entry for typing
497 o.mu.RLock()
498 navigated := o.hasNavigated
499 o.mu.RUnlock()
500 if navigated {
501 o.toggleFavorite()
502 return true
503 }
504 return false // Let entry handle space for typing
505
506 case uint(gdk.KEY_y):
507 // 'y' yanks (copies) the selected URL to clipboard when navigating
508 o.mu.RLock()
509 navigated := o.hasNavigated
510 o.mu.RUnlock()
511 if navigated {
512 o.yankSelectedURL()
513 return true
514 }
515 return false // Let entry handle 'y' for typing
516
517 default:
518 return o.handleCtrlNumberShortcut(keyval, ctrl)
519 }
520
521 return false // Let entry handle the key
522}
523
524// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
525func (o *Omnibox) handleCtrlNumberShortcut(keyval uint, ctrl bool) bool {
526 if !ctrl {
527 return false
528 }
529
530 const tenthItemIndex = 9
531 switch keyval {
532 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
533 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
534 index := int(keyval - uint(gdk.KEY_1))
535 o.selectAndNavigate(index)
536 return true
537 case uint(gdk.KEY_0):
538 o.selectAndNavigate(tenthItemIndex)
539 return true
540 }
541 return false
542}
543
544// onEntryChanged handles text input changes with debouncing.
545func (o *Omnibox) onEntryChanged() {
546 // Reset navigation state when user types - space should type, not toggle favorite
547 o.mu.Lock()
548 o.hasNavigated = false
549 o.mu.Unlock()
550
551 o.debounceMu.Lock()
552 if o.debounceTimer != nil {
553 o.debounceTimer.Stop()
554 }
555 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
556 o.performSearch()
557 })
558 o.debounceMu.Unlock()
559}
560
561// performSearch executes the search based on current view mode and query.
562func (o *Omnibox) performSearch() {
563 o.mu.RLock()
564 visible := o.visible
565 mode := o.viewMode
566 o.mu.RUnlock()
567
568 // Skip search if omnibox is hidden
569 if !visible {
570 return
571 }
572
573 query := o.entry.GetText()
574
575 // Skip duplicate queries
576 o.debounceMu.Lock()
577 if query == o.lastQuery && query != "" {
578 o.debounceMu.Unlock()
579 return
580 }
581 o.lastQuery = query
582 o.debounceMu.Unlock()
583
584 if mode == ViewModeFavorites {
585 o.loadFavorites(query)
586 return
587 }
588
589 // History search
590 if query == "" {
591 o.loadInitialHistory()
592 return
593 }
594
595 // Perform fuzzy search
596 go func() {
597 ctx := o.ctx
598 log := logging.FromContext(ctx)
599 if o.historyUC == nil {
600 return
601 }
602
603 input := usecase.SearchInput{
604 Query: query,
605 Limit: omniboxMaxResults,
606 }
607 output, err := o.historyUC.Search(ctx, input)
608 if err != nil {
609 log.Error().Err(err).Msg("history search failed")
610 return
611 }
612
613 suggestions := make([]Suggestion, 0, len(output.Matches))
614 for _, r := range output.Matches {
615 suggestions = append(suggestions, Suggestion{
616 URL: r.Entry.URL,
617 Title: r.Entry.Title,
618 })
619 }
620
621 // Marshal back to GTK main thread
622 o.idleAddUpdateSuggestions(suggestions)
623 }()
624}
625
626// loadInitialHistory loads history based on InitialBehavior config.
627func (o *Omnibox) loadInitialHistory() {
628 if o.historyUC == nil {
629 return
630 }
631
632 go func() {
633 ctx := o.ctx
634 log := logging.FromContext(ctx)
635 var suggestions []Suggestion
636
637 switch o.initialBehavior {
638 case "none":
639 o.idleAddUpdateSuggestions(nil)
640 return
641
642 case "most_visited", "recent", "":
643 // TODO: Implement GetMostVisited in use case if needed
644 results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)
645 if err != nil {
646 log.Error().Err(err).Msg("failed to load recent history")
647 return
648 }
649 suggestions = make([]Suggestion, 0, len(results))
650 for _, r := range results {
651 suggestions = append(suggestions, Suggestion{
652 URL: r.URL,
653 Title: r.Title,
654 })
655 }
656 }
657
658 o.idleAddUpdateSuggestions(suggestions)
659 }()
660}
661
662// loadFavorites loads favorites, optionally filtered by query.
663func (o *Omnibox) loadFavorites(query string) {
664 if o.favoritesUC == nil {
665 return
666 }
667
668 go func() {
669 ctx := o.ctx
670 log := logging.FromContext(ctx)
671 results, err := o.favoritesUC.GetAll(ctx)
672 if err != nil {
673 log.Error().Err(err).Msg("failed to load favorites")
674 return
675 }
676
677 favorites := make([]Favorite, 0, len(results))
678 queryLower := strings.ToLower(query)
679 for i, r := range results {
680 // Filter by query if provided
681 if query != "" {
682 titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
683 urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
684 if !titleMatch && !urlMatch {
685 continue
686 }
687 }
688 favorites = append(favorites, Favorite{
689 ID: int64(r.ID),
690 URL: r.URL,
691 Title: r.Title,
692 Position: i,
693 })
694 }
695
696 o.idleAddUpdateFavorites(favorites)
697 }()
698}
699
700// updateSuggestions updates the list with history suggestions.
701func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
702 o.mu.Lock()
703 o.suggestions = suggestions
704 o.selectedIndex = -1
705 o.mu.Unlock()
706
707 o.rebuildList()
708
709 // Hide scrolled window when there are no suggestions
710 rowCount := len(suggestions)
711 if o.scrolledWin != nil {
712 o.scrolledWin.SetVisible(rowCount > 0)
713 }
714 o.resizeAndCenter(rowCount)
715
716 // Select first item if available
717 if rowCount > 0 {
718 o.selectIndex(0)
719 }
720}
721
722// updateFavorites updates the list with favorites.
723func (o *Omnibox) updateFavorites(favorites []Favorite) {
724 o.mu.Lock()
725 o.favorites = favorites
726 o.selectedIndex = -1
727 o.mu.Unlock()
728
729 o.rebuildList()
730
731 // Hide scrolled window when there are no favorites
732 rowCount := len(favorites)
733 if o.scrolledWin != nil {
734 o.scrolledWin.SetVisible(rowCount > 0)
735 }
736 o.resizeAndCenter(rowCount)
737
738 // Select first item if available
739 if rowCount > 0 {
740 o.selectIndex(0)
741 }
742}
743
744// rebuildList rebuilds the ListBox contents.
745func (o *Omnibox) rebuildList() {
746 // Clear existing rows
747 o.listBox.RemoveAll()
748
749 o.mu.RLock()
750 mode := o.viewMode
751 suggestions := o.suggestions
752 favorites := o.favorites
753 o.mu.RUnlock()
754
755 if mode == ViewModeHistory {
756 for i, s := range suggestions {
757 row := o.createSuggestionRow(s, i)
758 if row != nil {
759 o.listBox.Append(&row.Widget)
760 }
761 }
762 } else {
763 for i, f := range favorites {
764 row := o.createFavoriteRow(f, i)
765 if row != nil {
766 o.listBox.Append(&row.Widget)
767 }
768 }
769 }
770
771 // Schedule measurement if not yet cached and we have rows
772 if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
773 var cb glib.SourceFunc = func(uintptr) bool {
774 if o.parentOverlay == nil {
775 return false
776 }
777 width := o.parentOverlay.GetAllocatedWidth()
778 if width <= 0 {
779 return false // Overlay not allocated yet, skip
780 }
781 forWidth := int(float64(width) * omniboxWidthPct)
782 if o.measureComponentHeights(forWidth) {
783 // Re-trigger resize with accurate measurements
784 o.mu.RLock()
785 var count int
786 if o.viewMode == ViewModeHistory {
787 count = len(o.suggestions)
788 } else {
789 count = len(o.favorites)
790 }
791 o.mu.RUnlock()
792 o.measureAndResize(forWidth, count)
793 }
794 return false
795 }
796 glib.IdleAdd(&cb, 0)
797 }
798}
799
800// createFaviconImage creates a favicon image with async loading from cache.
801func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
802 favicon := gtk.NewImage()
803 if favicon == nil {
804 return nil
805 }
806 favicon.SetFromIconName(&fallbackIcon)
807 favicon.SetPixelSize(int(16 * o.uiScale))
808 favicon.AddCssClass("omnibox-favicon")
809
810 // Async load favicon from cache
811 if o.faviconAdapter != nil && rawURL != "" {
812 o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
813 if texture != nil {
814 var cb glib.SourceFunc = func(data uintptr) bool {
815 favicon.SetFromPaintable(texture)
816 return false
817 }
818 glib.IdleAdd(&cb, 0)
819 }
820 })
821 }
822
823 return favicon
824}
825
826// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
827func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
828 row := gtk.NewListBoxRow()
829 if row == nil {
830 return nil
831 }
832 row.AddCssClass("omnibox-row")
833
834 const rowSpacing = 8
835 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
836 if hbox == nil {
837 return nil
838 }
839 hbox.SetHexpand(true)
840
841 // Favicon image (vertically centered)
842 if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {
843 favicon.SetValign(gtk.AlignCenterValue)
844 hbox.Append(&favicon.Widget)
845 }
846
847 // Vertical box for title + URL
848 textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
849 if textBox == nil {
850 return nil
851 }
852 textBox.SetHexpand(true)
853 textBox.SetValign(gtk.AlignCenterValue)
854
855 // Title label (or URL if no title)
856 displayTitle := title
857 if displayTitle == "" {
858 displayTitle = rawURL
859 }
860 titleLabel := gtk.NewLabel(nil)
861 if titleLabel != nil {
862 titleLabel.SetText(displayTitle)
863 titleLabel.AddCssClass("omnibox-suggestion-title")
864 titleLabel.SetHalign(gtk.AlignStartValue)
865 titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
866 textBox.Append(&titleLabel.Widget)
867 }
868
869 // URL label (only if title exists and differs from URL)
870 if title != "" && title != rawURL {
871 urlLabel := gtk.NewLabel(nil)
872 if urlLabel != nil {
873 urlLabel.SetText(rawURL)
874 urlLabel.AddCssClass("omnibox-suggestion-url")
875 urlLabel.SetHalign(gtk.AlignStartValue)
876 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
877 textBox.Append(&urlLabel.Widget)
878 }
879 }
880
881 hbox.Append(&textBox.Widget)
882
883 // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
884 const maxShortcutIndex = 9
885 if index <= maxShortcutIndex {
886 shortcutLabel := gtk.NewLabel(nil)
887 if shortcutLabel != nil {
888 if index < maxShortcutIndex {
889 shortcutLabel.SetText(formatShortcut(index + 1))
890 } else {
891 shortcutLabel.SetText("Ctrl+0")
892 }
893 shortcutLabel.AddCssClass("omnibox-shortcut-badge")
894 shortcutLabel.SetValign(gtk.AlignCenterValue)
895 hbox.Append(&shortcutLabel.Widget)
896 }
897 }
898
899 row.SetChild(&hbox.Widget)
900 return row
901}
902
903// createSuggestionRow creates a ListBoxRow for a suggestion.
904func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
905 return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
906}
907
908// createFavoriteRow creates a ListBoxRow for a favorite.
909func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
910 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
911}
912
913// selectIndex selects a row by index.
914func (o *Omnibox) selectIndex(index int) {
915 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
916 // which also acquires the mutex, causing deadlock
917 row := o.listBox.GetRowAtIndex(index)
918 if row != nil {
919 o.listBox.SelectRow(row)
920 // selectedIndex is updated by rowSelectedCb
921 }
922}
923
924// selectNext moves selection down.
925func (o *Omnibox) selectNext() {
926 o.mu.Lock()
927 current := o.selectedIndex
928 mode := o.viewMode
929 var maxIndex int
930 if mode == ViewModeHistory {
931 maxIndex = len(o.suggestions) - 1
932 } else {
933 maxIndex = len(o.favorites) - 1
934 }
935 o.hasNavigated = true // User is navigating with arrow keys
936 o.mu.Unlock()
937
938 if maxIndex < 0 {
939 return
940 }
941
942 newIndex := current + 1
943 if newIndex > maxIndex {
944 newIndex = 0 // Wrap around
945 }
946 o.selectIndex(newIndex)
947}
948
949// selectPrevious moves selection up.
950func (o *Omnibox) selectPrevious() {
951 o.mu.Lock()
952 current := o.selectedIndex
953 mode := o.viewMode
954 var maxIndex int
955 if mode == ViewModeHistory {
956 maxIndex = len(o.suggestions) - 1
957 } else {
958 maxIndex = len(o.favorites) - 1
959 }
960 o.hasNavigated = true // User is navigating with arrow keys
961 o.mu.Unlock()
962
963 if maxIndex < 0 {
964 return
965 }
966
967 newIndex := current - 1
968 if newIndex < 0 {
969 newIndex = maxIndex // Wrap around
970 }
971 o.selectIndex(newIndex)
972}
973
974// selectAndNavigate selects an index and navigates to it.
975func (o *Omnibox) selectAndNavigate(index int) {
976 o.selectIndex(index)
977 o.navigateToSelected()
978}
979
980// navigateToSelected navigates to the currently selected item or typed URL.
981// If the user typed a URL-like string, prioritize navigating to that directly.
982func (o *Omnibox) navigateToSelected() {
983 o.mu.RLock()
984 mode := o.viewMode
985 idx := o.selectedIndex
986 suggestions := o.suggestions
987 favorites := o.favorites
988 o.mu.RUnlock()
989
990 entryText := o.entry.GetText()
991 var targetURL string
992
993 // Check if user typed a URL-like string (contains . and no spaces)
994 // If so, navigate to that URL directly instead of the selection
995 if o.looksLikeURL(entryText) {
996 targetURL = o.buildURL(entryText)
997 } else if idx < 0 {
998 // No selection - use entry text as URL/search
999 targetURL = o.buildURL(entryText)
1000 } else if mode == ViewModeHistory {
1001 if idx < len(suggestions) {
1002 targetURL = suggestions[idx].URL
1003 }
1004 } else {
1005 if idx < len(favorites) {
1006 targetURL = favorites[idx].URL
1007 }
1008 }
1009
1010 if targetURL == "" {
1011 return
1012 }
1013
1014 o.Hide(o.ctx)
1015
1016 if o.onNavigate != nil {
1017 o.onNavigate(targetURL)
1018 }
1019}
1020
1021// looksLikeURL checks if the input appears to be a URL (not a search query).
1022// Returns true for strings like "github.com", "google.com/search", etc.
1023func (o *Omnibox) looksLikeURL(input string) bool {
1024 return url.LooksLikeURL(input)
1025}
1026
1027// toggleFavorite adds or removes the selected item from favorites.
1028// In History mode: adds the selected item to favorites
1029// In Favorites mode: removes the selected item from favorites
1030func (o *Omnibox) toggleFavorite() {
1031 log := logging.FromContext(o.ctx)
1032
1033 o.mu.RLock()
1034 mode := o.viewMode
1035 idx := o.selectedIndex
1036 suggestions := o.suggestions
1037 favorites := o.favorites
1038 o.mu.RUnlock()
1039
1040 if o.favoritesUC == nil {
1041 log.Warn().Msg("toggle favorite: favoritesUC is nil")
1042 return
1043 }
1044
1045 if mode == ViewModeHistory {
1046 // Add to favorites
1047 if idx < 0 || idx >= len(suggestions) {
1048 log.Debug().Int("index", idx).Msg("add favorite: invalid selection")
1049 return
1050 }
1051
1052 s := suggestions[idx]
1053 if s.URL == "" {
1054 log.Debug().Msg("add favorite: empty URL")
1055 return
1056 }
1057
1058 go func() {
1059 ctx := o.ctx
1060 goLog := logging.FromContext(ctx)
1061
1062 goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")
1063
1064 input := usecase.AddFavoriteInput{
1065 URL: s.URL,
1066 Title: s.Title,
1067 }
1068
1069 fav, err := o.favoritesUC.Add(ctx, input)
1070 if err != nil {
1071 goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite")
1072 return
1073 }
1074
1075 goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox")
1076 }()
1077 } else {
1078 // Remove from favorites
1079 if idx < 0 || idx >= len(favorites) {
1080 log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
1081 return
1082 }
1083
1084 f := favorites[idx]
1085 if f.ID == 0 {
1086 log.Debug().Msg("remove favorite: invalid ID")
1087 return
1088 }
1089
1090 go func() {
1091 ctx := o.ctx
1092 log := logging.FromContext(ctx)
1093
1094 log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")
1095
1096 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
1097 if err != nil {
1098 log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
1099 return
1100 }
1101
1102 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
1103
1104 // Refresh favorites list
1105 o.loadFavorites(o.entry.GetText())
1106 }()
1107 }
1108}
1109
1110// yankSelectedURL copies the URL of the selected item to clipboard.
1111func (o *Omnibox) yankSelectedURL() {
1112 log := logging.FromContext(o.ctx)
1113
1114 if o.copyURLUC == nil {
1115 log.Warn().Msg("yank URL: copy URL use case is nil")
1116 return
1117 }
1118
1119 o.mu.RLock()
1120 mode := o.viewMode
1121 idx := o.selectedIndex
1122 suggestions := o.suggestions
1123 favorites := o.favorites
1124 o.mu.RUnlock()
1125
1126 var selectedURL string
1127 if mode == ViewModeHistory {
1128 if idx < 0 || idx >= len(suggestions) {
1129 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1130 return
1131 }
1132 selectedURL = suggestions[idx].URL
1133 } else {
1134 if idx < 0 || idx >= len(favorites) {
1135 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1136 return
1137 }
1138 selectedURL = favorites[idx].URL
1139 }
1140
1141 if selectedURL == "" {
1142 log.Debug().Msg("yank URL: empty URL")
1143 return
1144 }
1145
1146 go func() {
1147 ctx := o.ctx
1148 if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
1149 return // Use case already logs the error
1150 }
1151
1152 // Show toast notification on success (must run on GTK main thread)
1153 if o.onToast != nil {
1154 cb := glib.SourceFunc(func(_ uintptr) bool {
1155 o.onToast("URL copied")
1156 return false // Don't repeat
1157 })
1158 glib.IdleAdd(&cb, 0)
1159 }
1160 }()
1161}
1162
1163// buildURL constructs a URL from input, handling search shortcuts.
1164func (o *Omnibox) buildURL(input string) string {
1165 return url.BuildSearchURL(input, o.shortcutURLs(), o.defaultSearch)
1166}
1167
1168// shortcutURLs returns a map of shortcut keys to URL templates.
1169func (o *Omnibox) shortcutURLs() map[string]string {
1170 result := make(map[string]string, len(o.shortcuts))
1171 for key, shortcut := range o.shortcuts {
1172 result[key] = shortcut.URL
1173 }
1174 return result
1175}
1176
1177// toggleViewMode switches between history and favorites.
1178func (o *Omnibox) toggleViewMode() {
1179 o.mu.RLock()
1180 current := o.viewMode
1181 o.mu.RUnlock()
1182
1183 if current == ViewModeHistory {
1184 o.setViewMode(ViewModeFavorites)
1185 } else {
1186 o.setViewMode(ViewModeHistory)
1187 }
1188}
1189
1190// setViewMode changes the view mode and updates UI.
1191func (o *Omnibox) setViewMode(mode ViewMode) {
1192 o.mu.Lock()
1193 o.viewMode = mode
1194 o.mu.Unlock()
1195
1196 // Update header button styling
1197 if mode == ViewModeHistory {
1198 o.historyBtn.AddCssClass("omnibox-header-active")
1199 o.favoritesBtn.RemoveCssClass("omnibox-header-active")
1200 } else {
1201 o.historyBtn.RemoveCssClass("omnibox-header-active")
1202 o.favoritesBtn.AddCssClass("omnibox-header-active")
1203 }
1204
1205 // Reload data
1206 o.performSearch()
1207}
1208
1209// Show opens the omnibox with optional initial query.
1210func (o *Omnibox) Show(ctx context.Context, query string) {
1211 log := logging.FromContext(ctx)
1212 log.Debug().Str("query", query).Msg("showing omnibox")
1213
1214 o.mu.Lock()
1215 if o.visible {
1216 o.mu.Unlock()
1217 return
1218 }
1219 o.visible = true
1220 o.mu.Unlock()
1221
1222 // Set initial query
1223 o.entry.SetText(query)
1224
1225 // Determine if we expect content initially
1226 // No content expected if: no query AND initialBehavior is "none"
1227 expectContent := query != "" || o.initialBehavior != "none"
1228
1229 // Hide scrolled window if no content expected
1230 if o.scrolledWin != nil {
1231 o.scrolledWin.SetVisible(expectContent)
1232 // Reset content height constraints - will be updated when results arrive
1233 o.scrolledWin.SetMinContentHeight(-1)
1234 o.scrolledWin.SetMaxContentHeight(0)
1235 }
1236
1237 // Get parent dimensions with fallback if not yet allocated
1238 parentWidth := o.parentOverlay.GetAllocatedWidth()
1239 parentHeight := o.parentOverlay.GetAllocatedHeight()
1240 if parentWidth <= 0 {
1241 parentWidth = omniboxMaxWidth
1242 }
1243 if parentHeight <= 0 {
1244 parentHeight = omniboxFallbackHeight
1245 }
1246
1247 // Set width and vertical positioning (20% from top)
1248 width := int(float64(parentWidth) * omniboxWidthPct)
1249 if width > omniboxMaxWidth {
1250 width = omniboxMaxWidth
1251 }
1252 marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
1253
1254 o.mainBox.SetSizeRequest(width, -1)
1255 o.outerBox.SetMarginTop(marginTop)
1256
1257 // Show the omnibox
1258 o.outerBox.SetVisible(true)
1259
1260 // Trigger initial resize (will be updated when results arrive)
1261 o.resizeAndCenter(0)
1262
1263 // Focus the entry
1264 o.entry.GrabFocus()
1265
1266 // Load initial data (may update size later if results found)
1267 o.performSearch()
1268}
1269
1270// Hide closes the omnibox.
1271func (o *Omnibox) Hide(ctx context.Context) {
1272 log := logging.FromContext(ctx)
1273 log.Debug().Msg("hiding omnibox")
1274
1275 o.mu.Lock()
1276 if !o.visible {
1277 o.mu.Unlock()
1278 return
1279 }
1280 o.visible = false
1281 o.mu.Unlock()
1282
1283 // Cancel any pending search
1284 o.debounceMu.Lock()
1285 if o.debounceTimer != nil {
1286 o.debounceTimer.Stop()
1287 o.debounceTimer = nil
1288 }
1289 o.debounceMu.Unlock()
1290
1291 // Hide omnibox
1292 o.outerBox.SetVisible(false)
1293
1294 // Clear state
1295 o.entry.SetText("")
1296 o.listBox.RemoveAll()
1297
1298 if o.onClose != nil {
1299 o.onClose()
1300 }
1301}
1302
1303// Toggle shows if hidden, hides if visible.
1304func (o *Omnibox) Toggle(ctx context.Context) {
1305 o.mu.RLock()
1306 visible := o.visible
1307 o.mu.RUnlock()
1308
1309 if visible {
1310 o.Hide(ctx)
1311 } else {
1312 o.Show(ctx, "")
1313 }
1314}
1315
1316// IsVisible returns whether the omnibox is currently shown.
1317func (o *Omnibox) IsVisible() bool {
1318 o.mu.RLock()
1319 defer o.mu.RUnlock()
1320 return o.visible
1321}
1322
1323// SetOnNavigate sets the callback for URL navigation.
1324func (o *Omnibox) SetOnNavigate(fn func(url string)) {
1325 o.onNavigate = fn
1326}
1327
1328// SetOnClose sets the callback for omnibox close events.
1329func (o *Omnibox) SetOnClose(fn func()) {
1330 o.onClose = fn
1331}
1332
1333// UpdateZoomIndicator updates the zoom percentage display.
1334// Shows the indicator when zoom != 100%, hides it when at 100%.
1335func (o *Omnibox) UpdateZoomIndicator(factor float64) {
1336 var cb glib.SourceFunc = func(data uintptr) bool {
1337 if o.zoomLabel == nil {
1338 return false
1339 }
1340 if factor == 1.0 {
1341 o.zoomLabel.SetVisible(false)
1342 } else {
1343 percentage := int(factor * 100)
1344 o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
1345 o.zoomLabel.SetVisible(true)
1346 }
1347 return false
1348 }
1349 glib.IdleAdd(&cb, 0)
1350}
1351
1352// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
1353func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
1354 var cb glib.SourceFunc = func(data uintptr) bool {
1355 o.updateSuggestions(suggestions)
1356 return false // One-shot callback
1357 }
1358 glib.IdleAdd(&cb, 0)
1359}
1360
1361// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
1362func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
1363 var cb glib.SourceFunc = func(data uintptr) bool {
1364 o.updateFavorites(favorites)
1365 return false // One-shot callback, return false to remove source
1366 }
1367 glib.IdleAdd(&cb, 0)
1368}
1369
1370// Widget returns the omnibox widget for embedding in an overlay.
1371func (o *Omnibox) Widget() *gtk.Widget {
1372 if o.outerBox == nil {
1373 return nil
1374 }
1375 return &o.outerBox.Widget
1376}
1377
1378// Destroy cleans up omnibox resources.
1379func (o *Omnibox) Destroy() {
1380 o.debounceMu.Lock()
1381 if o.debounceTimer != nil {
1382 o.debounceTimer.Stop()
1383 }
1384 o.debounceMu.Unlock()
1385
1386 o.mu.Lock()
1387 o.visible = false
1388 o.mu.Unlock()
1389
1390 o.parentOverlay = nil
1391 o.retainedCallbacks = nil
1392
1393 if o.outerBox != nil {
1394 o.outerBox.Unparent()
1395 o.outerBox = nil
1396 }
1397}
1398
1399// formatShortcut formats a shortcut number.
1400func formatShortcut(n int) string {
1401 return "Ctrl+" + string(rune('0'+n))
1402}
1403
1404// measureWidgetHeight returns the natural height of a widget for the given width.
1405// Returns 0 if widget is nil or measurement fails.
1406func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
1407 if widget == nil {
1408 return 0
1409 }
1410 var minHeight, naturalHeight, minBaseline, naturalBaseline int
1411 widget.Measure(
1412 gtk.OrientationVerticalValue,
1413 forWidth,
1414 &minHeight,
1415 &naturalHeight,
1416 &minBaseline,
1417 &naturalBaseline,
1418 )
1419 return naturalHeight
1420}
1421
1422// measureComponentHeights measures and caches all component heights.
1423// Must be called on GTK main thread after widgets have been laid out.
1424func (o *Omnibox) measureComponentHeights(forWidth int) bool {
1425 if o.headerBox == nil || o.entry == nil || o.listBox == nil {
1426 return false
1427 }
1428
1429 headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
1430 entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
1431
1432 // Measure first row if it exists
1433 var rowH int
1434 if row := o.listBox.GetRowAtIndex(0); row != nil {
1435 rowH = measureWidgetHeight(&row.Widget, forWidth)
1436 }
1437
1438 // Validate measurements (header and entry must be positive)
1439 if headerH <= 0 || entryH <= 0 {
1440 return false
1441 }
1442
1443 o.measuredHeights.header = headerH
1444 o.measuredHeights.entry = entryH
1445 if rowH > 0 {
1446 o.measuredHeights.singleRow = rowH
1447 o.measuredHeights.valid = true
1448 }
1449 return true
1450}
1451
1452// errNilWidget creates an error for nil widget creation.
1453func errNilWidget(name string) error {
1454 return &widgetError{name: name}
1455}
1456
1457type widgetError struct {
1458 name string
1459}
1460
1461func (e *widgetError) Error() string {
1462 return "failed to create widget: " + e.name
1463}