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