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