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