1package component
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "strings"
8 "sync"
9 "time"
10 "unicode/utf8"
11
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/autocomplete"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/adapter"
18 "github.com/bnema/dumber/internal/ui/input"
19 "github.com/bnema/dumber/internal/ui/layout"
20 "github.com/jwijenbergh/puregotk/v4/gdk"
21 "github.com/jwijenbergh/puregotk/v4/glib"
22 "github.com/jwijenbergh/puregotk/v4/graphene"
23 "github.com/jwijenbergh/puregotk/v4/gtk"
24 "github.com/jwijenbergh/puregotk/v4/pango"
25)
26
27const (
28 debounceDelayMs = 50
29 endBoxSpacing = 6
30)
31
32// ViewMode distinguishes history search from favorites display.
33type ViewMode string
34
35const (
36 ViewModeHistory ViewMode = "history"
37 ViewModeFavorites ViewMode = "favorites"
38)
39
40// Suggestion represents a search result from history.
41type Suggestion struct {
42 URL string
43 Title string
44 FaviconURL string
45 IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
46}
47
48// BangSuggestion represents a configured bang shortcut.
49// A bang is invoked by typing "!<key>".
50type BangSuggestion struct {
51 Key string
52 Description string
53}
54
55// Favorite represents a bookmarked URL.
56type Favorite struct {
57 ID int64
58 URL string
59 Title string
60 FaviconURL string
61 Position int
62}
63
64// Omnibox is the native GTK4 address bar / command palette.
65type Omnibox struct {
66 // GTK widgets
67 outerBox *gtk.Box // Outer container for positioning
68 mainBox *gtk.Box // Main content box
69 headerBox *gtk.Box
70 historyBtn *gtk.Button
71 favoritesBtn *gtk.Button
72 zoomLabel *gtk.Label
73 bangBadge *gtk.Label
74 entry *gtk.SearchEntry
75 entryOverlay *gtk.Overlay // Overlay for ghost text
76 ghostLabel *gtk.Label // Ghost text completion label
77 scrolledWin *gtk.ScrolledWindow
78 listBox *gtk.ListBox
79
80 // Parent overlay reference for sizing (set via SetParentOverlay)
81 parentOverlay layout.OverlayWidget
82
83 // State
84 mu sync.RWMutex
85 visible bool
86 viewMode ViewMode
87 selectedIndex int
88 suggestions []Suggestion
89 favorites []Favorite
90 bangSuggestions []BangSuggestion
91 bangMode bool
92 detectedBang string
93 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
94
95 // Ghost text state
96 realInput string // What user actually typed (without ghost suffix)
97 ghostSuffix string // Completion suffix to display
98 ghostFullText string // Full text that ghost completes to
99 hasGhostText bool // Quick check for Tab behavior
100 isAcceptingGhost bool // Prevent re-trigger during acceptance
101
102 // Dependencies
103 historyUC *usecase.SearchHistoryUseCase
104 favoritesUC *usecase.ManageFavoritesUseCase
105 faviconAdapter *adapter.FaviconAdapter
106 copyURLUC *usecase.CopyURLUseCase
107 shortcutsUC *usecase.SearchShortcutsUseCase
108 autocompleteUC *usecase.AutocompleteUseCase
109 defaultSearch string
110 initialBehavior string
111 ctx context.Context
112
113 // Callbacks
114 onNavigate func(url string)
115 onClose func()
116 onToast func(ctx context.Context, message string, level ToastLevel)
117
118 // Debouncing
119 debounceTimer *time.Timer
120 debounceMu sync.Mutex
121 lastQuery string // Prevent duplicate searches
122
123 // Scaling
124 uiScale float64
125
126 // Cached measurements (populated after first layout)
127 measuredHeights struct {
128 header int // headerBox natural height
129 entry int // entry natural height
130 singleRow int // single ListBoxRow natural height
131 valid bool // whether cache is valid
132 }
133
134 // Callback retention: keep GTK signal callbacks reachable by Go GC.
135 retainedCallbacks []interface{}
136
137 // Click outside handler
138 clickOutsideController *gtk.GestureClick
139 clickOutsideCb func(gtk.GestureClick, int, float64, float64)
140}
141
142// OmniboxConfig holds configuration for creating an Omnibox.
143type OmniboxConfig struct {
144 HistoryUC *usecase.SearchHistoryUseCase
145 FavoritesUC *usecase.ManageFavoritesUseCase
146 FaviconAdapter *adapter.FaviconAdapter
147 CopyURLUC *usecase.CopyURLUseCase
148 ShortcutsUC *usecase.SearchShortcutsUseCase
149 AutocompleteUC *usecase.AutocompleteUseCase
150 DefaultSearch string
151 InitialBehavior string
152 UIScale float64 // UI scale for favicon sizing
153 OnNavigate func(url string) // Callback when user navigates via omnibox
154 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
155 OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
156 OnFocusOut func() // Callback when entry loses focus
157}
158
159// NewOmnibox creates a new native GTK4 omnibox widget.
160// Call SetParentOverlay() before Show() to set the parent for sizing.
161func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
162 log := logging.FromContext(ctx)
163
164 uiScale := cfg.UIScale
165 if uiScale <= 0 {
166 uiScale = 1.0
167 }
168
169 o := &Omnibox{
170 viewMode: ViewModeHistory,
171 selectedIndex: -1,
172 historyUC: cfg.HistoryUC,
173 favoritesUC: cfg.FavoritesUC,
174 faviconAdapter: cfg.FaviconAdapter,
175 copyURLUC: cfg.CopyURLUC,
176 shortcutsUC: cfg.ShortcutsUC,
177 autocompleteUC: cfg.AutocompleteUC,
178 defaultSearch: cfg.DefaultSearch,
179 initialBehavior: cfg.InitialBehavior,
180 onToast: cfg.OnToast,
181 ctx: ctx,
182 uiScale: uiScale,
183 }
184
185 if err := o.createWidgets(); err != nil {
186 log.Error().Err(err).Msg("failed to create omnibox widgets")
187 return nil
188 }
189
190 o.setupKeyboardHandling()
191 o.setupEntryChanged()
192 o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
193
194 // Set navigation callback if provided
195 if cfg.OnNavigate != nil {
196 o.onNavigate = cfg.OnNavigate
197 }
198
199 log.Debug().Msg("omnibox created")
200 return o
201}
202
203// SetParentOverlay sets the overlay widget used for sizing calculations.
204// Must be called before Show().
205func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
206 o.mu.Lock()
207 defer o.mu.Unlock()
208 o.parentOverlay = overlay
209 o.setupClickOutsideHandler()
210}
211
212// setupClickOutsideHandler attaches a click gesture to the parent overlay
213// that hides the omnibox when clicking outside its bounds.
214func (o *Omnibox) setupClickOutsideHandler() {
215 if o.parentOverlay == nil {
216 return
217 }
218
219 log := logging.FromContext(o.ctx)
220
221 // Create gesture click controller
222 o.clickOutsideController = gtk.NewGestureClick()
223 if o.clickOutsideController == nil {
224 log.Error().Msg("failed to create click outside controller")
225 return
226 }
227
228 // Listen to primary button only
229 o.clickOutsideController.SetButton(1) // GDK_BUTTON_PRIMARY
230
231 // Set capture phase so we see clicks before they reach the omnibox
232 o.clickOutsideController.SetPropagationPhase(gtk.PhaseCaptureValue)
233
234 // Connect pressed handler
235 o.clickOutsideCb = func(_ gtk.GestureClick, _ int, x, y float64) {
236 o.handleClickOutside(x, y)
237 }
238 o.clickOutsideController.ConnectPressed(&o.clickOutsideCb)
239
240 // Add controller to parent overlay
241 gtkWidget := o.parentOverlay.GtkWidget()
242 if gtkWidget != nil {
243 gtkWidget.AddController(&o.clickOutsideController.EventController)
244 }
245
246 log.Debug().Msg("click outside handler attached to parent overlay")
247}
248
249// handleClickOutside checks if a click is outside the omnibox bounds and hides if so.
250func (o *Omnibox) handleClickOutside(clickX, clickY float64) {
251 o.mu.RLock()
252 visible := o.visible
253 o.mu.RUnlock()
254
255 if !visible || o.outerBox == nil || o.parentOverlay == nil {
256 return
257 }
258
259 // Get omnibox position relative to parent overlay using graphene points
260 srcPoint := &graphene.Point{X: 0, Y: 0}
261 outPoint := &graphene.Point{}
262 ok := o.outerBox.ComputePoint(o.parentOverlay.GtkWidget(), srcPoint, outPoint)
263 if !ok {
264 return
265 }
266
267 boxX := float64(outPoint.X)
268 boxY := float64(outPoint.Y)
269
270 // Get omnibox dimensions
271 boxWidth := float64(o.outerBox.GetAllocatedWidth())
272 boxHeight := float64(o.outerBox.GetAllocatedHeight())
273
274 // Check if click is inside omnibox bounds
275 isInside := clickX >= boxX && clickX <= boxX+boxWidth &&
276 clickY >= boxY && clickY <= boxY+boxHeight
277
278 if !isInside {
279 logging.FromContext(o.ctx).Debug().
280 Float64("clickX", clickX).
281 Float64("clickY", clickY).
282 Float64("boxX", boxX).
283 Float64("boxY", boxY).
284 Float64("boxWidth", boxWidth).
285 Float64("boxHeight", boxHeight).
286 Msg("click outside omnibox, hiding")
287 o.Hide(o.ctx)
288 }
289}
290
291// WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
292// This is useful for adding the omnibox to a PaneView overlay.
293func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
294 if o.outerBox == nil {
295 return nil
296 }
297 return factory.WrapWidget(&o.outerBox.Widget)
298}
299
300// resizeAndCenter adjusts the omnibox size based on content and centers it.
301// rowCount is the number of result rows to display (0 = no content, max 10).
302func (o *Omnibox) resizeAndCenter(rowCount int) {
303 if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil {
304 return
305 }
306
307 // Calculate width using shared helper
308 width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
309
310 // Cap at max results
311 if rowCount > OmniboxListDefaults.MaxVisibleRows {
312 rowCount = OmniboxListDefaults.MaxVisibleRows
313 }
314
315 // Schedule measurement after GTK has laid out widgets
316 // This ensures we get accurate heights from actual rendered content
317 var cb glib.SourceFunc = func(uintptr) bool {
318 o.measureAndResize(width, rowCount)
319 return false // One-shot
320 }
321 glib.IdleAdd(&cb, 0)
322}
323
324// measureAndResize calculates and sets the omnibox height based on row count.
325// Uses measured widget heights when available, falls back to estimates.
326func (o *Omnibox) measureAndResize(width, rowCount int) {
327 if o.outerBox == nil || o.mainBox == nil {
328 return
329 }
330
331 log := logging.FromContext(o.ctx)
332
333 var rowHeight int
334
335 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
336 // Use measured values from GTK4 Measure API
337 rowHeight = o.measuredHeights.singleRow
338 } else {
339 // Fallback to scaled estimate (before first measurement)
340 rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
341 }
342
343 // Calculate content height for the scrolled window
344 // This is the key to dynamic sizing - limit the ScrolledWindow's max height
345 contentHeight := 0
346 if rowCount > 0 {
347 contentHeight = rowCount * rowHeight
348 }
349
350 // Cap at max results
351 maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
352 if contentHeight > maxContentHeight {
353 contentHeight = maxContentHeight
354 }
355
356 // Set size constraints on ScrolledWindow using shared helper
357 SetScrolledWindowHeight(o.scrolledWin, contentHeight)
358
359 // Force layout recalculation
360 o.outerBox.QueueResize()
361
362 log.Debug().
363 Int("width", width).
364 Int("contentHeight", contentHeight).
365 Int("rowHeight", rowHeight).
366 Int("rows", rowCount).
367 Bool("measured", o.measuredHeights.valid).
368 Msg("omnibox resized")
369}
370
371// createWidgets builds the GTK widget hierarchy.
372func (o *Omnibox) createWidgets() error {
373 if err := o.initOuterBox(); err != nil {
374 return err
375 }
376 if err := o.initMainBox(); err != nil {
377 return err
378 }
379 if err := o.initHeader(); err != nil {
380 return err
381 }
382 if err := o.initEntry(); err != nil {
383 return err
384 }
385 if err := o.initList(); err != nil {
386 return err
387 }
388 o.assembleWidgets()
389 return nil
390}
391
392// setupKeyboardHandling adds keyboard event handling.
393func (o *Omnibox) setupKeyboardHandling() {
394 log := logging.FromContext(o.ctx)
395 controller := gtk.NewEventControllerKey()
396 if controller == nil {
397 log.Error().Msg("failed to create event controller key")
398 return
399 }
400
401 // Set capture phase to intercept before entry
402 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
403
404 keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
405 return o.handleKeyPress(keyval, keycode, state)
406 }
407 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
408 controller.ConnectKeyPressed(&keyPressedCb)
409
410 o.outerBox.AddController(&controller.EventController)
411}
412
413func (o *Omnibox) initOuterBox() error {
414 o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
415 if o.outerBox == nil {
416 return errNilWidget("outerBox")
417 }
418 o.outerBox.AddCssClass("omnibox-outer")
419 o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
420 o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
421 o.outerBox.SetVisible(false) // Hidden by default
422 return nil
423}
424
425func (o *Omnibox) initMainBox() error {
426 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
427 if o.mainBox == nil {
428 return errNilWidget("mainBox")
429 }
430 o.mainBox.AddCssClass("omnibox-container")
431 return nil
432}
433
434func (o *Omnibox) initHeader() error {
435 o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
436 if o.headerBox == nil {
437 return errNilWidget("headerBox")
438 }
439 o.headerBox.AddCssClass("omnibox-header")
440 o.headerBox.SetHexpand(true)
441
442 o.historyBtn = gtk.NewButtonWithLabel("History")
443 if o.historyBtn == nil {
444 return errNilWidget("historyBtn")
445 }
446 o.historyBtn.AddCssClass("omnibox-header-btn")
447 o.historyBtn.AddCssClass("omnibox-header-active")
448 o.historyBtn.SetCanFocus(false)
449
450 o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
451 if o.favoritesBtn == nil {
452 return errNilWidget("favoritesBtn")
453 }
454 o.favoritesBtn.AddCssClass("omnibox-header-btn")
455 o.favoritesBtn.SetCanFocus(false)
456
457 historyClickCb := func(_ gtk.Button) {
458 o.setViewMode(ViewModeHistory)
459 }
460 o.retainedCallbacks = append(o.retainedCallbacks, historyClickCb)
461 o.historyBtn.ConnectClicked(&historyClickCb)
462
463 favoritesClickCb := func(_ gtk.Button) {
464 o.setViewMode(ViewModeFavorites)
465 }
466 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
467 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
468
469 o.bangBadge = gtk.NewLabel(nil)
470 if o.bangBadge != nil {
471 o.bangBadge.AddCssClass("omnibox-bang-badge")
472 o.bangBadge.SetVisible(false)
473 }
474
475 o.zoomLabel = gtk.NewLabel(nil)
476 if o.zoomLabel != nil {
477 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
478 o.zoomLabel.SetVisible(false)
479 }
480 return nil
481}
482
483func (o *Omnibox) initEntry() error {
484 o.entry = gtk.NewSearchEntry()
485 if o.entry == nil {
486 return errNilWidget("entry")
487 }
488 o.entry.AddCssClass("omnibox-entry")
489 o.entry.SetHexpand(true)
490
491 placeholder := "Search history or enter URL… (! lists bangs)"
492 o.entry.SetPlaceholderText(&placeholder)
493
494 // Create overlay for ghost text
495 o.entryOverlay = gtk.NewOverlay()
496 if o.entryOverlay == nil {
497 return errNilWidget("entryOverlay")
498 }
499 o.entryOverlay.SetChild(&o.entry.Widget)
500 o.entryOverlay.SetHexpand(true)
501
502 // Create ghost label for autocomplete suffix
503 o.ghostLabel = gtk.NewLabel(nil)
504 if o.ghostLabel == nil {
505 return errNilWidget("ghostLabel")
506 }
507 o.ghostLabel.AddCssClass("omnibox-ghost")
508 o.ghostLabel.SetHalign(gtk.AlignStartValue)
509 o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
510 o.ghostLabel.SetXalign(0)
511 o.ghostLabel.SetVisible(false)
512 o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
513 o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
514
515 o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
516 o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
517
518 return nil
519}
520
521func (o *Omnibox) initList() error {
522 o.scrolledWin = gtk.NewScrolledWindow()
523 if o.scrolledWin == nil {
524 return errNilWidget("scrolledWin")
525 }
526 o.scrolledWin.AddCssClass("omnibox-scrolled")
527 o.scrolledWin.SetVexpand(true)
528 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
529 o.scrolledWin.SetPropagateNaturalHeight(true)
530
531 o.listBox = gtk.NewListBox()
532 if o.listBox == nil {
533 return errNilWidget("listBox")
534 }
535 o.listBox.AddCssClass("omnibox-listbox")
536 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
537
538 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
539 if rowPtr == 0 {
540 o.mu.Lock()
541 o.selectedIndex = -1
542 o.mu.Unlock()
543 return
544 }
545 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
546 if row != nil {
547 o.mu.Lock()
548 o.selectedIndex = row.GetIndex()
549 o.mu.Unlock()
550 }
551 }
552 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
553 o.listBox.ConnectRowSelected(&rowSelectedCb)
554
555 // Handle row activation (click or Enter) - navigate directly to the URL
556 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
557 if rowPtr == 0 {
558 return
559 }
560 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
561 if row == nil {
562 return
563 }
564 idx := row.GetIndex()
565 o.mu.RLock()
566 mode := o.viewMode
567 bangMode := o.bangMode
568 suggestions := o.suggestions
569 favorites := o.favorites
570 o.mu.RUnlock()
571
572 // Don't navigate in bang mode - activating a bang should fill it in
573 if bangMode {
574 return
575 }
576
577 var targetURL string
578 if mode == ViewModeHistory {
579 if idx >= 0 && idx < len(suggestions) {
580 targetURL = suggestions[idx].URL
581 }
582 } else {
583 if idx >= 0 && idx < len(favorites) {
584 targetURL = favorites[idx].URL
585 }
586 }
587
588 if targetURL != "" && o.onNavigate != nil {
589 o.Hide(o.ctx)
590 o.onNavigate(targetURL)
591 }
592 }
593 o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
594 o.listBox.ConnectRowActivated(&rowActivatedCb)
595
596 return nil
597}
598
599func (o *Omnibox) assembleWidgets() {
600 o.headerBox.Append(&o.historyBtn.Widget)
601 o.headerBox.Append(&o.favoritesBtn.Widget)
602
603 endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
604 if endBox != nil {
605 endBox.SetHexpand(true)
606 endBox.SetHalign(gtk.AlignEndValue)
607 if o.bangBadge != nil {
608 endBox.Append(&o.bangBadge.Widget)
609 }
610 if o.zoomLabel != nil {
611 endBox.Append(&o.zoomLabel.Widget)
612 }
613 o.headerBox.Append(&endBox.Widget)
614 }
615
616 o.scrolledWin.SetChild(&o.listBox.Widget)
617 o.mainBox.Append(&o.headerBox.Widget)
618 o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
619 o.mainBox.Append(&o.scrolledWin.Widget)
620 o.outerBox.Append(&o.mainBox.Widget)
621}
622
623// setupEntryChanged wires entry text changes to debounced search.
624func (o *Omnibox) setupEntryChanged() {
625 // SearchEntry has built-in debouncing via search-changed signal
626 changedCb := func(_ gtk.SearchEntry) {
627 o.onEntryChanged()
628 }
629 o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
630 o.entry.ConnectSearchChanged(&changedCb)
631}
632
633// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
634func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
635 if onFocusIn == nil && onFocusOut == nil {
636 return
637 }
638
639 focusController := gtk.NewEventControllerFocus()
640 if focusController == nil {
641 return
642 }
643
644 if onFocusIn != nil {
645 entry := o.entry // Capture for closure
646 focusInCb := func(_ gtk.EventControllerFocus) {
647 onFocusIn(entry)
648 }
649 o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
650 focusController.ConnectEnter(&focusInCb)
651 }
652
653 if onFocusOut != nil {
654 focusOutCb := func(_ gtk.EventControllerFocus) {
655 onFocusOut()
656 }
657 o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
658 focusController.ConnectLeave(&focusOutCb)
659 }
660
661 o.entry.AddController(&focusController.EventController)
662}
663
664// hasGhost returns whether ghost text is currently visible.
665func (o *Omnibox) hasGhost() bool {
666 o.mu.RLock()
667 defer o.mu.RUnlock()
668 return o.hasGhostText
669}
670
671// hasUserNavigated returns whether user has navigated with arrow keys.
672func (o *Omnibox) hasUserNavigated() bool {
673 o.mu.RLock()
674 defer o.mu.RUnlock()
675 return o.hasNavigated
676}
677
678// handleKeyPress processes keyboard events.
679// Returns true if the event was handled.
680func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
681 ctrl := state&gdk.ControlMaskValue != 0
682
683 switch keyval {
684 case uint(gdk.KEY_Escape):
685 text := o.entry.GetText()
686 if text != "" {
687 o.entry.SetText("")
688 return true
689 }
690 o.Hide(o.ctx)
691 return true
692
693 case uint(gdk.KEY_l), uint(gdk.KEY_L):
694 // Ctrl+L toggles omnibox (hides when visible)
695 if ctrl {
696 o.Hide(o.ctx)
697 return true
698 }
699
700 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
701 o.navigateToSelected()
702 return true
703
704 case uint(gdk.KEY_Up):
705 o.selectPrevious()
706 o.updateGhostFromSelection()
707 return true
708
709 case uint(gdk.KEY_Down):
710 o.selectNext()
711 o.updateGhostFromSelection()
712 return true
713
714 case uint(gdk.KEY_Tab):
715 // Tab accepts ghost text if present, otherwise toggles view mode
716 if o.hasGhost() {
717 o.acceptGhostCompletion()
718 return true
719 }
720 o.toggleViewMode()
721 return true
722
723 case uint(gdk.KEY_Right):
724 // Arrow Right accepts ghost text if present
725 if o.hasGhost() {
726 o.acceptGhostCompletion()
727 return true
728 }
729 return false // Let entry handle normal cursor movement
730
731 case uint(gdk.KEY_space):
732 // Space toggles favorite only if user has navigated with arrow keys
733 if o.hasUserNavigated() {
734 o.toggleFavorite()
735 return true
736 }
737 return false // Let entry handle space for typing
738
739 case uint(gdk.KEY_y):
740 // 'y' yanks (copies) the selected URL to clipboard when navigating
741 if o.hasUserNavigated() {
742 o.yankSelectedURL()
743 return true
744 }
745 return false // Let entry handle 'y' for typing
746
747 default:
748 return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
749 }
750
751 return false // Let entry handle the key
752}
753
754// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
755// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
756func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
757 if !ctrl {
758 return false
759 }
760
761 const tenthItemIndex = 9
762
763 // First try keyval (works for QWERTY layouts)
764 switch keyval {
765 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
766 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
767 idx := int(keyval - uint(gdk.KEY_1))
768 o.selectAndNavigate(idx)
769 return true
770 case uint(gdk.KEY_0):
771 o.selectAndNavigate(tenthItemIndex)
772 return true
773 }
774
775 // Fallback: use hardware keycode for non-QWERTY layouts
776 // This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
777 if index, ok := input.KeycodeToDigitIndex(keycode); ok {
778 log := logging.FromContext(o.ctx)
779 log.Debug().
780 Uint("keycode", keycode).
781 Int("index", index).
782 Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
783 o.selectAndNavigate(index)
784 return true
785 }
786
787 return false
788}
789
790// onEntryChanged handles text input changes with debouncing.
791func (o *Omnibox) onEntryChanged() {
792 // Skip if we're programmatically accepting ghost text
793 o.mu.RLock()
794 isAccepting := o.isAcceptingGhost
795 o.mu.RUnlock()
796 if isAccepting {
797 return
798 }
799
800 // Reset navigation state when user types - space should type, not toggle favorite
801 o.mu.Lock()
802 o.hasNavigated = false
803 // Update realInput with what user actually typed
804 o.realInput = o.entry.GetText()
805 o.mu.Unlock()
806
807 // Clear ghost text when user types (it will be recalculated after search)
808 o.clearGhostText()
809
810 o.debounceMu.Lock()
811 if o.debounceTimer != nil {
812 o.debounceTimer.Stop()
813 }
814 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
815 o.performSearch()
816 })
817 o.debounceMu.Unlock()
818}
819
820// setGhostText displays a ghost completion suffix in the overlay label.
821// The ghost text shows what would complete the current input.
822// originalInput is the input that was used for the autocomplete query.
823// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
824func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
825 if o.ghostLabel == nil || suffix == "" {
826 o.clearGhostText()
827 return
828 }
829
830 // Store ghost state optimistically - will be validated on main thread
831 o.mu.Lock()
832 o.ghostSuffix = suffix
833 o.ghostFullText = fullText
834 o.hasGhostText = true
835 o.mu.Unlock()
836
837 var cb glib.SourceFunc = func(uintptr) bool {
838 if o.ghostLabel == nil || o.entry == nil {
839 return false
840 }
841
842 // Check if input has changed since the autocomplete query was made
843 // This check runs on GTK main thread where entry.GetText() is reliable
844 // Skip this check when originalInput is empty (row selection replaces full input)
845 if originalInput != "" && o.entry.GetText() != originalInput {
846 // Input changed, skip this stale result - a newer query will handle it
847 return false
848 }
849
850 // Get the entry's Pango context and create a layout to measure text width
851 pangoCtx := o.entry.GetPangoContext()
852 if pangoCtx == nil {
853 return false
854 }
855
856 // Create layout to measure the input text width
857 layout := pango.NewLayout(pangoCtx)
858 layout.SetText(originalInput, len(originalInput))
859
860 // Get the width in pixels
861 var widthPx, heightPx int
862 layout.GetPixelSize(&widthPx, &heightPx)
863
864 // Set the ghost label text (just the suffix) and position it horizontally
865 o.ghostLabel.SetText(suffix)
866
867 marginStart := widthPx
868 delegate := o.entry.GetDelegate()
869 if delegate != nil && o.entryOverlay != nil {
870 textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
871 if textWidget != nil {
872 var textBounds graphene.Rect
873 if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
874 cursorPos := uint(utf8.RuneCountInString(originalInput))
875 var strongRect, weakRect graphene.Rect
876 textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
877 cursorX := float64(textBounds.GetX() + strongRect.GetX())
878 marginStart = int(math.Round(cursorX))
879 }
880 }
881 }
882
883 // Clamp horizontal position to entry bounds
884 if marginStart < 0 {
885 marginStart = 0
886 }
887 if o.entryOverlay != nil {
888 var entryBounds graphene.Rect
889 if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
890 minStart := int(math.Round(float64(entryBounds.GetX())))
891 if marginStart < minStart {
892 marginStart = minStart
893 }
894 }
895 }
896
897 o.ghostLabel.SetMarginStart(marginStart)
898 // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
899 o.ghostLabel.SetVisible(true)
900
901 // Hide placeholder text so it doesn't show through ghost text
902 emptyPlaceholder := ""
903 o.entry.SetPlaceholderText(&emptyPlaceholder)
904
905 return false
906 }
907 glib.IdleAdd(&cb, 0)
908}
909
910// clearGhostText hides the ghost completion text.
911func (o *Omnibox) clearGhostText() {
912 o.mu.Lock()
913 o.ghostSuffix = ""
914 o.ghostFullText = ""
915 o.hasGhostText = false
916 o.mu.Unlock()
917
918 if o.ghostLabel == nil {
919 return
920 }
921
922 var cb glib.SourceFunc = func(uintptr) bool {
923 if o.ghostLabel != nil {
924 o.ghostLabel.SetVisible(false)
925 o.ghostLabel.SetText("")
926 }
927 // Restore placeholder text
928 if o.entry != nil {
929 placeholder := "Search history or enter URL… (! lists bangs)"
930 o.entry.SetPlaceholderText(&placeholder)
931 }
932 return false
933 }
934 glib.IdleAdd(&cb, 0)
935}
936
937// acceptGhostCompletion accepts the ghost text and fills the input.
938func (o *Omnibox) acceptGhostCompletion() {
939 o.mu.Lock()
940 if !o.hasGhostText || o.ghostFullText == "" {
941 o.mu.Unlock()
942 return
943 }
944 fullText := o.ghostFullText
945 o.isAcceptingGhost = true
946 o.realInput = fullText
947 o.ghostSuffix = ""
948 o.ghostFullText = ""
949 o.hasGhostText = false
950 o.mu.Unlock()
951
952 // Update entry text and clear ghost label
953 o.entry.SetText(fullText)
954 o.entry.SetPosition(-1) // Move cursor to end
955
956 if o.ghostLabel != nil {
957 o.ghostLabel.SetVisible(false)
958 }
959
960 o.mu.Lock()
961 o.isAcceptingGhost = false
962 o.mu.Unlock()
963}
964
965// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
966// The query parameter is the search query that was used, ensuring we don't use stale input.
967func (o *Omnibox) updateGhostFromSuggestion(query string) {
968 if o.autocompleteUC == nil {
969 return
970 }
971
972 o.mu.RLock()
973 bangMode := o.bangMode
974 o.mu.RUnlock()
975
976 // Use the query that triggered this search, not realInput which may have changed
977 userInput := query
978
979 // Don't show ghost text in bang mode (it has its own completion)
980 if bangMode || userInput == "" {
981 o.clearGhostText()
982 return
983 }
984
985 go func() {
986 output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
987 if !output.Found || output.Suggestion == nil {
988 o.clearGhostText()
989 return
990 }
991 // Pass the original input so setGhostText can verify it's still current
992 o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
993 }()
994}
995
996// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
997// When a row is selected via arrow keys, show the URL as ghost text even if
998// the user's input isn't a prefix - this shows what Tab would fill in.
999func (o *Omnibox) updateGhostFromURL(targetURL string) {
1000 if targetURL == "" {
1001 o.clearGhostText()
1002 return
1003 }
1004
1005 o.mu.RLock()
1006 userInput := o.realInput
1007 o.mu.RUnlock()
1008
1009 // Strip protocol from URL for cleaner display
1010 displayURL := autocomplete.StripProtocol(targetURL)
1011
1012 // Try to compute proper suffix if input is a prefix of the URL
1013 if o.autocompleteUC != nil && userInput != "" {
1014 output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)
1015 if output.Found && output.Suggestion != nil {
1016 o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
1017 return
1018 }
1019 }
1020
1021 // If user has typed something but it's not a prefix match, don't show ghost text
1022 // This prevents confusing ghost text when scrolling through history
1023 if userInput != "" {
1024 o.clearGhostText()
1025 return
1026 }
1027
1028 // Only show full URL as ghost text when input is empty (initial state)
1029 o.setGhostText("", displayURL, displayURL)
1030}
1031
1032// updateGhostFromSelection updates ghost text based on the currently selected row.
1033func (o *Omnibox) updateGhostFromSelection() {
1034 o.mu.RLock()
1035 idx := o.selectedIndex
1036 mode := o.viewMode
1037 bangMode := o.bangMode
1038 suggestions := o.suggestions
1039 favorites := o.favorites
1040 o.mu.RUnlock()
1041
1042 // No ghost text in bang mode
1043 if bangMode {
1044 return
1045 }
1046
1047 var targetURL string
1048 if mode == ViewModeHistory {
1049 if idx >= 0 && idx < len(suggestions) {
1050 targetURL = suggestions[idx].URL
1051 }
1052 } else {
1053 if idx >= 0 && idx < len(favorites) {
1054 targetURL = favorites[idx].URL
1055 }
1056 }
1057
1058 if targetURL == "" {
1059 o.clearGhostText()
1060 return
1061 }
1062
1063 o.updateGhostFromURL(targetURL)
1064}
1065
1066// performSearch executes the search based on current view mode and query.
1067func (o *Omnibox) performSearch() {
1068 o.mu.RLock()
1069 visible := o.visible
1070 mode := o.viewMode
1071 o.mu.RUnlock()
1072
1073 // Skip search if omnibox is hidden
1074 if !visible {
1075 return
1076 }
1077
1078 query := o.entry.GetText()
1079
1080 // Skip duplicate queries
1081 o.debounceMu.Lock()
1082 if query == o.lastQuery && query != "" {
1083 o.debounceMu.Unlock()
1084 return
1085 }
1086 o.lastQuery = query
1087 o.debounceMu.Unlock()
1088
1089 if strings.HasPrefix(query, "!") {
1090 o.updateBangDetection(query)
1091 o.loadBangSuggestions(query)
1092 return
1093 }
1094
1095 o.clearBangState()
1096
1097 if mode == ViewModeFavorites {
1098 o.loadFavorites(query)
1099 return
1100 }
1101
1102 // History search
1103 if query == "" {
1104 o.loadInitialHistory()
1105 return
1106 }
1107
1108 // Perform fuzzy search
1109 go func() {
1110 ctx := o.ctx
1111 log := logging.FromContext(ctx)
1112 if o.historyUC == nil {
1113 return
1114 }
1115
1116 // Run history search and favorite URL fetch in parallel
1117 type searchResult struct {
1118 output *usecase.SearchOutput
1119 err error
1120 }
1121 searchCh := make(chan searchResult, 1)
1122 favCh := make(chan map[string]struct{}, 1)
1123
1124 go func() {
1125 searchInput := usecase.SearchInput{
1126 Query: query,
1127 Limit: OmniboxListDefaults.MaxResults,
1128 }
1129 output, err := o.historyUC.Search(ctx, searchInput)
1130 searchCh <- searchResult{output, err}
1131 }()
1132
1133 go func() {
1134 favCh <- o.getFavoriteURLs(ctx)
1135 }()
1136
1137 // Wait for both results
1138 sr := <-searchCh
1139 favoriteURLs := <-favCh
1140
1141 if sr.err != nil {
1142 log.Error().Err(sr.err).Msg("history search failed")
1143 return
1144 }
1145 if sr.output == nil {
1146 return
1147 }
1148
1149 suggestions := make([]Suggestion, 0, len(sr.output.Matches))
1150 for _, r := range sr.output.Matches {
1151 _, isFav := favoriteURLs[r.Entry.URL]
1152 suggestions = append(suggestions, Suggestion{
1153 URL: r.Entry.URL,
1154 Title: r.Entry.Title,
1155 IsFavorite: isFav,
1156 })
1157 }
1158
1159 // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input
1160 o.idleAddUpdateSuggestions(suggestions, query)
1161 }()
1162}
1163
1164// loadInitialHistory loads history based on InitialBehavior config.
1165func (o *Omnibox) loadInitialHistory() {
1166 if o.historyUC == nil {
1167 return
1168 }
1169
1170 go func() {
1171 ctx := o.ctx
1172 log := logging.FromContext(ctx)
1173 var suggestions []Suggestion
1174
1175 switch o.initialBehavior {
1176 case "none":
1177 o.idleAddUpdateSuggestions(nil, "")
1178 return
1179
1180 case "most_visited", "recent", "":
1181 // Run history fetch and favorite URL fetch in parallel
1182 type historyResult struct {
1183 results []*entity.HistoryEntry
1184 err error
1185 }
1186 historyCh := make(chan historyResult, 1)
1187 favCh := make(chan map[string]struct{}, 1)
1188
1189 go func() {
1190 // TODO: Implement GetMostVisited in use case if needed
1191 results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
1192 historyCh <- historyResult{results, err}
1193 }()
1194
1195 go func() {
1196 favCh <- o.getFavoriteURLs(ctx)
1197 }()
1198
1199 // Wait for both results
1200 hr := <-historyCh
1201 favoriteURLs := <-favCh
1202
1203 if hr.err != nil {
1204 log.Error().Err(hr.err).Msg("failed to load recent history")
1205 return
1206 }
1207 if hr.results == nil {
1208 return
1209 }
1210
1211 suggestions = make([]Suggestion, 0, len(hr.results))
1212 for _, r := range hr.results {
1213 _, isFav := favoriteURLs[r.URL]
1214 suggestions = append(suggestions, Suggestion{
1215 URL: r.URL,
1216 Title: r.Title,
1217 IsFavorite: isFav,
1218 })
1219 }
1220 }
1221
1222 o.idleAddUpdateSuggestions(suggestions, "")
1223 }()
1224}
1225
1226// loadFavorites loads favorites, optionally filtered by query.
1227func (o *Omnibox) loadFavorites(query string) {
1228 if o.favoritesUC == nil {
1229 return
1230 }
1231
1232 go func() {
1233 ctx := o.ctx
1234 log := logging.FromContext(ctx)
1235 results, err := o.favoritesUC.GetAll(ctx)
1236 if err != nil {
1237 log.Error().Err(err).Msg("failed to load favorites")
1238 return
1239 }
1240
1241 favorites := make([]Favorite, 0, len(results))
1242 queryLower := strings.ToLower(query)
1243 for i, r := range results {
1244 // Filter by query if provided
1245 if query != "" {
1246 titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
1247 urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
1248 if !titleMatch && !urlMatch {
1249 continue
1250 }
1251 }
1252 favorites = append(favorites, Favorite{
1253 ID: int64(r.ID),
1254 URL: r.URL,
1255 Title: r.Title,
1256 Position: i,
1257 })
1258 }
1259
1260 o.idleAddUpdateFavorites(favorites, query)
1261 }()
1262}
1263
1264func (o *Omnibox) loadBangSuggestions(query string) {
1265 if o.shortcutsUC == nil {
1266 return
1267 }
1268 output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query})
1269 suggestions := make([]BangSuggestion, len(output.Suggestions))
1270 for i, s := range output.Suggestions {
1271 suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
1272 }
1273 o.idleAddUpdateBangSuggestions(suggestions)
1274}
1275
1276func (o *Omnibox) updateBangDetection(query string) {
1277 if o.shortcutsUC == nil {
1278 o.clearDetectedBang()
1279 return
1280 }
1281 output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
1282 if output.Key == "" {
1283 o.clearDetectedBang()
1284 return
1285 }
1286
1287 o.setDetectedBang(output.Key, output.Description)
1288}
1289
1290func (o *Omnibox) clearBangState() {
1291 o.mu.Lock()
1292 o.bangMode = false
1293 o.bangSuggestions = nil
1294 o.mu.Unlock()
1295
1296 o.clearDetectedBang()
1297}
1298
1299func (o *Omnibox) setDetectedBang(key, description string) {
1300 o.mu.Lock()
1301 o.detectedBang = key
1302 o.mu.Unlock()
1303
1304 if o.bangBadge == nil {
1305 return
1306 }
1307 label := description
1308 var cb glib.SourceFunc = func(uintptr) bool {
1309 o.entry.AddCssClass("omnibox-entry-bang-active")
1310 o.bangBadge.SetText(label)
1311 o.bangBadge.SetVisible(true)
1312 return false
1313 }
1314 glib.IdleAdd(&cb, 0)
1315}
1316
1317func (o *Omnibox) clearDetectedBang() {
1318 o.mu.Lock()
1319 o.detectedBang = ""
1320 o.mu.Unlock()
1321
1322 if o.bangBadge == nil {
1323 return
1324 }
1325 var cb glib.SourceFunc = func(uintptr) bool {
1326 o.entry.RemoveCssClass("omnibox-entry-bang-active")
1327 o.bangBadge.SetVisible(false)
1328 return false
1329 }
1330 glib.IdleAdd(&cb, 0)
1331}
1332
1333func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
1334 var cb glib.SourceFunc = func(uintptr) bool {
1335 o.updateBangSuggestions(suggestions)
1336 return false
1337 }
1338 glib.IdleAdd(&cb, 0)
1339}
1340
1341func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1342 o.mu.Lock()
1343 o.bangMode = true
1344 o.bangSuggestions = suggestions
1345 o.selectedIndex = -1
1346 o.mu.Unlock()
1347
1348 o.rebuildList()
1349
1350 rowCount := len(suggestions)
1351 if o.scrolledWin != nil {
1352 o.scrolledWin.SetVisible(rowCount > 0)
1353 }
1354 o.resizeAndCenter(rowCount)
1355
1356 if rowCount > 0 {
1357 o.selectIndex(0)
1358 }
1359}
1360
1361// updateSuggestions updates the list with history suggestions.
1362// The query parameter is the search query that triggered this update.
1363func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1364 o.mu.Lock()
1365 o.bangMode = false
1366 o.bangSuggestions = nil
1367 o.suggestions = suggestions
1368 o.selectedIndex = -1
1369 o.mu.Unlock()
1370
1371 o.rebuildList()
1372
1373 // Hide scrolled window when there are no suggestions
1374 rowCount := len(suggestions)
1375 if o.scrolledWin != nil {
1376 o.scrolledWin.SetVisible(rowCount > 0)
1377 }
1378 o.resizeAndCenter(rowCount)
1379
1380 // Select first item if available and update ghost text
1381 if rowCount > 0 {
1382 o.selectIndex(0)
1383 // Update ghost text from first suggestion
1384 o.updateGhostFromSuggestion(query)
1385 } else {
1386 o.clearGhostText()
1387 }
1388}
1389
1390// updateFavorites updates the list with favorites.
1391// The query parameter is the search query that triggered this update.
1392func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1393 o.mu.Lock()
1394 o.bangMode = false
1395 o.bangSuggestions = nil
1396 o.favorites = favorites
1397 o.selectedIndex = -1
1398 o.mu.Unlock()
1399
1400 o.rebuildList()
1401
1402 // Hide scrolled window when there are no favorites
1403 rowCount := len(favorites)
1404 if o.scrolledWin != nil {
1405 o.scrolledWin.SetVisible(rowCount > 0)
1406 }
1407 o.resizeAndCenter(rowCount)
1408
1409 // Select first item if available and update ghost text
1410 if rowCount > 0 {
1411 o.selectIndex(0)
1412 // Update ghost text from first favorite
1413 o.updateGhostFromSuggestion(query)
1414 } else {
1415 o.clearGhostText()
1416 }
1417}
1418
1419// rebuildList rebuilds the ListBox contents.
1420func (o *Omnibox) rebuildList() {
1421 // Clear existing rows
1422 o.listBox.RemoveAll()
1423
1424 o.mu.RLock()
1425 mode := o.viewMode
1426 suggestions := o.suggestions
1427 favorites := o.favorites
1428 bangMode := o.bangMode
1429 bangSuggestions := o.bangSuggestions
1430 o.mu.RUnlock()
1431
1432 if bangMode {
1433 for i, b := range bangSuggestions {
1434 row := o.createBangRow(b, i)
1435 if row != nil {
1436 o.listBox.Append(&row.Widget)
1437 }
1438 }
1439 } else if mode == ViewModeHistory {
1440 for i, s := range suggestions {
1441 row := o.createSuggestionRow(s, i)
1442 if row != nil {
1443 o.listBox.Append(&row.Widget)
1444 }
1445 }
1446 } else {
1447 for i, f := range favorites {
1448 row := o.createFavoriteRow(f, i)
1449 if row != nil {
1450 o.listBox.Append(&row.Widget)
1451 }
1452 }
1453 }
1454
1455 // Schedule measurement if not yet cached and we have rows
1456 if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
1457 var cb glib.SourceFunc = func(uintptr) bool {
1458 if o.parentOverlay == nil {
1459 return false
1460 }
1461 width := o.parentOverlay.GetAllocatedWidth()
1462 if width <= 0 {
1463 return false // Overlay not allocated yet, skip
1464 }
1465 forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
1466 if o.measureComponentHeights(forWidth) {
1467 // Re-trigger resize with accurate measurements
1468 o.mu.RLock()
1469 var count int
1470 if o.bangMode {
1471 count = len(o.bangSuggestions)
1472 } else if o.viewMode == ViewModeHistory {
1473 count = len(o.suggestions)
1474 } else {
1475 count = len(o.favorites)
1476 }
1477 o.mu.RUnlock()
1478 o.measureAndResize(forWidth, count)
1479 }
1480 return false
1481 }
1482 glib.IdleAdd(&cb, 0)
1483 }
1484}
1485
1486// createFaviconImage creates a favicon image with async loading from cache.
1487func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1488 favicon := gtk.NewImage()
1489 if favicon == nil {
1490 return nil
1491 }
1492 favicon.SetFromIconName(&fallbackIcon)
1493 favicon.SetPixelSize(int(16 * o.uiScale))
1494 favicon.AddCssClass("omnibox-favicon")
1495
1496 // Async load favicon from cache
1497 if o.faviconAdapter != nil && rawURL != "" {
1498 o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
1499 if texture != nil {
1500 var cb glib.SourceFunc = func(data uintptr) bool {
1501 favicon.SetFromPaintable(texture)
1502 return false
1503 }
1504 glib.IdleAdd(&cb, 0)
1505 }
1506 })
1507 }
1508
1509 return favicon
1510}
1511
1512// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1513// Uses rawURL for both favicon fetching and display.
1514func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1515 return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1516}
1517
1518// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1519// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1520func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
1521 row := gtk.NewListBoxRow()
1522 if row == nil {
1523 return nil
1524 }
1525 row.AddCssClass("omnibox-row")
1526
1527 const rowSpacing = 8
1528 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1529 if hbox == nil {
1530 return nil
1531 }
1532 hbox.SetHexpand(true)
1533
1534 // Favicon image (vertically centered)
1535 if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
1536 favicon.SetValign(gtk.AlignCenterValue)
1537 hbox.Append(&favicon.Widget)
1538 }
1539
1540 // Vertical box for title + URL
1541 textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
1542 if textBox == nil {
1543 return nil
1544 }
1545 textBox.SetHexpand(true)
1546 textBox.SetValign(gtk.AlignCenterValue)
1547
1548 // Title label (or URL if no title)
1549 displayTitle := title
1550 if displayTitle == "" {
1551 displayTitle = displayURL
1552 }
1553 titleLabel := gtk.NewLabel(nil)
1554 if titleLabel != nil {
1555 titleLabel.SetText(displayTitle)
1556 titleLabel.AddCssClass("omnibox-suggestion-title")
1557 titleLabel.SetHalign(gtk.AlignStartValue)
1558 titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1559 textBox.Append(&titleLabel.Widget)
1560 }
1561
1562 // URL label (only if title exists and differs from URL)
1563 if title != "" && title != displayURL {
1564 urlLabel := gtk.NewLabel(nil)
1565 if urlLabel != nil {
1566 urlLabel.SetText(displayURL)
1567 urlLabel.AddCssClass("omnibox-suggestion-url")
1568 urlLabel.SetHalign(gtk.AlignStartValue)
1569 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1570 textBox.Append(&urlLabel.Widget)
1571 }
1572 }
1573
1574 hbox.Append(&textBox.Widget)
1575
1576 // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
1577 const maxShortcutIndex = 9
1578 if index <= maxShortcutIndex {
1579 shortcutLabel := gtk.NewLabel(nil)
1580 if shortcutLabel != nil {
1581 if index < maxShortcutIndex {
1582 shortcutLabel.SetText(formatShortcut(index + 1))
1583 } else {
1584 shortcutLabel.SetText("Ctrl+0")
1585 }
1586 shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1587 shortcutLabel.SetValign(gtk.AlignCenterValue)
1588 hbox.Append(&shortcutLabel.Widget)
1589 }
1590 }
1591
1592 row.SetChild(&hbox.Widget)
1593 return row
1594}
1595
1596// createSuggestionRow creates a ListBoxRow for a suggestion.
1597func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1598 row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
1599 if row != nil && s.IsFavorite {
1600 row.AddCssClass("omnibox-row-favorite")
1601 }
1602 return row
1603}
1604
1605// createFavoriteRow creates a ListBoxRow for a favorite.
1606func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
1607 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
1608}
1609
1610func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1611 // Pass description as URL param (displayed as secondary label) and empty
1612 // faviconURL to skip async favicon fetching - bang rows use static icon only
1613 row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
1614 if row != nil {
1615 row.AddCssClass("omnibox-row-bang")
1616 }
1617 return row
1618}
1619
1620// selectIndex selects a row by index.
1621func (o *Omnibox) selectIndex(index int) {
1622 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
1623 // which also acquires the mutex, causing deadlock
1624 row := o.listBox.GetRowAtIndex(index)
1625 if row != nil {
1626 o.listBox.SelectRow(row)
1627 // selectedIndex is updated by rowSelectedCb
1628 }
1629}
1630
1631// selectNext moves selection down.
1632func (o *Omnibox) selectNext() {
1633 o.mu.Lock()
1634 current := o.selectedIndex
1635 mode := o.viewMode
1636 bangMode := o.bangMode
1637 var maxIndex int
1638 if bangMode {
1639 maxIndex = len(o.bangSuggestions) - 1
1640 } else if mode == ViewModeHistory {
1641 maxIndex = len(o.suggestions) - 1
1642 } else {
1643 maxIndex = len(o.favorites) - 1
1644 }
1645 o.hasNavigated = true // User is navigating with arrow keys
1646 o.mu.Unlock()
1647
1648 if maxIndex < 0 {
1649 return
1650 }
1651
1652 newIndex := current + 1
1653 if newIndex > maxIndex {
1654 newIndex = 0 // Wrap around
1655 }
1656 o.selectIndex(newIndex)
1657}
1658
1659// selectPrevious moves selection up.
1660func (o *Omnibox) selectPrevious() {
1661 o.mu.Lock()
1662 current := o.selectedIndex
1663 mode := o.viewMode
1664 bangMode := o.bangMode
1665 var maxIndex int
1666 if bangMode {
1667 maxIndex = len(o.bangSuggestions) - 1
1668 } else if mode == ViewModeHistory {
1669 maxIndex = len(o.suggestions) - 1
1670 } else {
1671 maxIndex = len(o.favorites) - 1
1672 }
1673 o.hasNavigated = true // User is navigating with arrow keys
1674 o.mu.Unlock()
1675
1676 if maxIndex < 0 {
1677 return
1678 }
1679
1680 newIndex := current - 1
1681 if newIndex < 0 {
1682 newIndex = maxIndex // Wrap around
1683 }
1684 o.selectIndex(newIndex)
1685}
1686
1687// selectAndNavigate selects an index and navigates to it.
1688func (o *Omnibox) selectAndNavigate(index int) {
1689 o.selectIndex(index)
1690 o.navigateToSelected()
1691}
1692
1693// navigateToSelected navigates to the currently selected item or typed URL.
1694// If the user typed a URL-like string, prioritize navigating to that directly.
1695func (o *Omnibox) navigateToSelected() {
1696 o.mu.RLock()
1697 mode := o.viewMode
1698 idx := o.selectedIndex
1699 suggestions := o.suggestions
1700 favorites := o.favorites
1701 bangMode := o.bangMode
1702 bangSuggestions := o.bangSuggestions
1703 o.mu.RUnlock()
1704
1705 entryText := o.entry.GetText()
1706
1707 if bangMode {
1708 // If user typed a full bang query, navigate using the bang URL.
1709 if o.shortcutsUC != nil {
1710 navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
1711 if navOutput.Valid {
1712 targetURL := o.buildURL(navOutput.Text)
1713 if targetURL == "" {
1714 return
1715 }
1716 o.Hide(o.ctx)
1717 if o.onNavigate != nil {
1718 o.onNavigate(targetURL)
1719 }
1720 return
1721 }
1722 }
1723
1724 // Otherwise, Enter autocompletes the selected bang.
1725 if idx >= 0 && idx < len(bangSuggestions) {
1726 o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
1727 o.entry.SetPosition(-1)
1728 return
1729 }
1730 }
1731
1732 var targetURL string
1733
1734 // If user has selected a result, navigate to that result
1735 // Otherwise, treat entry text as URL/search
1736 if idx >= 0 {
1737 if mode == ViewModeHistory {
1738 if idx < len(suggestions) {
1739 targetURL = suggestions[idx].URL
1740 }
1741 } else {
1742 if idx < len(favorites) {
1743 targetURL = favorites[idx].URL
1744 }
1745 }
1746 } else {
1747 // No selection - use entry text as URL/search
1748 targetURL = o.buildURL(entryText)
1749 }
1750
1751 if targetURL == "" {
1752 return
1753 }
1754
1755 o.Hide(o.ctx)
1756
1757 if o.onNavigate != nil {
1758 o.onNavigate(targetURL)
1759 }
1760}
1761
1762// toggleFavorite adds or removes the selected item from favorites.
1763// In History mode: adds the selected item to favorites
1764// In Favorites mode: removes the selected item from favorites
1765func (o *Omnibox) toggleFavorite() {
1766 log := logging.FromContext(o.ctx)
1767
1768 o.mu.RLock()
1769 mode := o.viewMode
1770 idx := o.selectedIndex
1771 suggestions := o.suggestions
1772 favorites := o.favorites
1773 o.mu.RUnlock()
1774
1775 if o.favoritesUC == nil {
1776 log.Warn().Msg("toggle favorite: favoritesUC is nil")
1777 return
1778 }
1779
1780 if mode == ViewModeHistory {
1781 // Toggle favorite in history mode
1782 if idx < 0 || idx >= len(suggestions) {
1783 log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection")
1784 return
1785 }
1786
1787 s := suggestions[idx]
1788 if s.URL == "" {
1789 log.Debug().Msg("toggle favorite: empty URL")
1790 return
1791 }
1792
1793 go func() {
1794 ctx := o.ctx
1795 goLog := logging.FromContext(ctx)
1796
1797 result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
1798 if err != nil {
1799 goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite")
1800 if o.onToast != nil {
1801 msg := "Failed to add favorite"
1802 if s.IsFavorite {
1803 msg = "Failed to remove favorite"
1804 }
1805 cb := glib.SourceFunc(func(_ uintptr) bool {
1806 o.onToast(ctx, msg, ToastError)
1807 return false
1808 })
1809 glib.IdleAdd(&cb, 0)
1810 }
1811 return
1812 }
1813
1814 // Update suggestion state
1815 o.mu.Lock()
1816 if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {
1817 o.suggestions[idx].IsFavorite = result.Added
1818 }
1819 o.mu.Unlock()
1820
1821 // Update row CSS and show toast on GTK main thread
1822 cb := glib.SourceFunc(func(_ uintptr) bool {
1823 o.updateRowFavoriteIndicator(idx, result.Added)
1824 if o.onToast != nil {
1825 o.onToast(ctx, result.Message, ToastSuccess)
1826 }
1827 return false
1828 })
1829 glib.IdleAdd(&cb, 0)
1830 }()
1831 } else {
1832 // Remove from favorites
1833 if idx < 0 || idx >= len(favorites) {
1834 log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
1835 return
1836 }
1837
1838 f := favorites[idx]
1839 if f.ID == 0 {
1840 log.Debug().Msg("remove favorite: invalid ID")
1841 return
1842 }
1843
1844 go func() {
1845 ctx := o.ctx
1846 log := logging.FromContext(ctx)
1847
1848 log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")
1849
1850 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
1851 if err != nil {
1852 log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
1853 // Show error toast
1854 if o.onToast != nil {
1855 cb := glib.SourceFunc(func(_ uintptr) bool {
1856 o.onToast(ctx, "Failed to remove favorite", ToastError)
1857 return false
1858 })
1859 glib.IdleAdd(&cb, 0)
1860 }
1861 return
1862 }
1863
1864 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
1865
1866 // Refresh favorites list and show toast
1867 o.loadFavorites(o.entry.GetText())
1868 if o.onToast != nil {
1869 cb := glib.SourceFunc(func(_ uintptr) bool {
1870 o.onToast(ctx, "Favorite removed", ToastSuccess)
1871 return false
1872 })
1873 glib.IdleAdd(&cb, 0)
1874 }
1875 }()
1876 }
1877}
1878
1879// updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
1880// Must be called from GTK main thread (via glib.IdleAdd).
1881func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) {
1882 row := o.listBox.GetRowAtIndex(index)
1883 if row == nil {
1884 return
1885 }
1886 if isFavorite {
1887 row.AddCssClass("omnibox-row-favorite")
1888 } else {
1889 row.RemoveCssClass("omnibox-row-favorite")
1890 }
1891}
1892
1893// yankSelectedURL copies the URL of the selected item to clipboard.
1894func (o *Omnibox) yankSelectedURL() {
1895 log := logging.FromContext(o.ctx)
1896
1897 if o.copyURLUC == nil {
1898 log.Warn().Msg("yank URL: copy URL use case is nil")
1899 return
1900 }
1901
1902 o.mu.RLock()
1903 mode := o.viewMode
1904 idx := o.selectedIndex
1905 suggestions := o.suggestions
1906 favorites := o.favorites
1907 o.mu.RUnlock()
1908
1909 var selectedURL string
1910 if mode == ViewModeHistory {
1911 if idx < 0 || idx >= len(suggestions) {
1912 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1913 return
1914 }
1915 selectedURL = suggestions[idx].URL
1916 } else {
1917 if idx < 0 || idx >= len(favorites) {
1918 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1919 return
1920 }
1921 selectedURL = favorites[idx].URL
1922 }
1923
1924 if selectedURL == "" {
1925 log.Debug().Msg("yank URL: empty URL")
1926 return
1927 }
1928
1929 go func() {
1930 ctx := o.ctx
1931 if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
1932 return // Use case already logs the error
1933 }
1934
1935 // Show toast notification on success (must run on GTK main thread)
1936 if o.onToast != nil {
1937 cb := glib.SourceFunc(func(_ uintptr) bool {
1938 o.onToast(ctx, "URL copied", ToastSuccess)
1939 return false // Don't repeat
1940 })
1941 glib.IdleAdd(&cb, 0)
1942 }
1943 }()
1944}
1945
1946// buildURL constructs a URL from text, handling search shortcuts.
1947func (o *Omnibox) buildURL(text string) string {
1948 var shortcutURLs map[string]string
1949 if o.shortcutsUC != nil {
1950 shortcutURLs = o.shortcutsUC.ShortcutURLs()
1951 }
1952 return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
1953}
1954
1955// toggleViewMode switches between history and favorites.
1956func (o *Omnibox) toggleViewMode() {
1957 o.mu.RLock()
1958 current := o.viewMode
1959 o.mu.RUnlock()
1960
1961 if current == ViewModeHistory {
1962 o.setViewMode(ViewModeFavorites)
1963 } else {
1964 o.setViewMode(ViewModeHistory)
1965 }
1966}
1967
1968// setViewMode changes the view mode and updates UI.
1969func (o *Omnibox) setViewMode(mode ViewMode) {
1970 o.mu.Lock()
1971 o.viewMode = mode
1972 o.mu.Unlock()
1973
1974 // Update header button styling
1975 if mode == ViewModeHistory {
1976 o.historyBtn.AddCssClass("omnibox-header-active")
1977 o.favoritesBtn.RemoveCssClass("omnibox-header-active")
1978 } else {
1979 o.historyBtn.RemoveCssClass("omnibox-header-active")
1980 o.favoritesBtn.AddCssClass("omnibox-header-active")
1981 }
1982
1983 // Reload data
1984 o.performSearch()
1985}
1986
1987// Show opens the omnibox with optional initial query.
1988func (o *Omnibox) Show(ctx context.Context, query string) {
1989 log := logging.FromContext(ctx)
1990 log.Debug().Str("query", query).Msg("showing omnibox")
1991
1992 o.mu.Lock()
1993 if o.visible {
1994 o.mu.Unlock()
1995 return
1996 }
1997 o.visible = true
1998 // Initialize ghost text state
1999 o.realInput = query
2000 o.ghostSuffix = ""
2001 o.ghostFullText = ""
2002 o.hasGhostText = false
2003 o.isAcceptingGhost = false
2004 o.mu.Unlock()
2005
2006 // Clear any stale ghost text display
2007 if o.ghostLabel != nil {
2008 o.ghostLabel.SetVisible(false)
2009 }
2010
2011 // Set initial query
2012 o.entry.SetText(query)
2013
2014 // Determine if we expect content initially
2015 // No content expected if: no query AND initialBehavior is "none"
2016 expectContent := query != "" || o.initialBehavior != "none"
2017
2018 // Hide scrolled window if no content expected
2019 if o.scrolledWin != nil {
2020 o.scrolledWin.SetVisible(expectContent)
2021 // Reset content height constraints - will be updated when results arrive
2022 o.scrolledWin.SetMinContentHeight(-1)
2023 o.scrolledWin.SetMaxContentHeight(0)
2024 }
2025
2026 // Calculate dimensions using shared helper
2027 width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
2028
2029 o.mainBox.SetSizeRequest(width, -1)
2030 o.outerBox.SetMarginTop(marginTop)
2031
2032 // Show the omnibox
2033 o.outerBox.SetVisible(true)
2034
2035 // Trigger initial resize (will be updated when results arrive)
2036 o.resizeAndCenter(0)
2037
2038 // Focus the entry
2039 o.entry.GrabFocus()
2040
2041 // Load initial data (may update size later if results found)
2042 o.performSearch()
2043}
2044
2045// Hide closes the omnibox.
2046func (o *Omnibox) Hide(ctx context.Context) {
2047 log := logging.FromContext(ctx)
2048 log.Debug().Msg("hiding omnibox")
2049
2050 o.mu.Lock()
2051 if !o.visible {
2052 o.mu.Unlock()
2053 return
2054 }
2055 o.visible = false
2056 o.mu.Unlock()
2057
2058 // Cancel any pending search
2059 o.debounceMu.Lock()
2060 if o.debounceTimer != nil {
2061 o.debounceTimer.Stop()
2062 o.debounceTimer = nil
2063 }
2064 o.debounceMu.Unlock()
2065
2066 // Hide omnibox
2067 o.outerBox.SetVisible(false)
2068
2069 // Clear state
2070 o.clearBangState()
2071 o.clearGhostText()
2072 o.entry.SetText("")
2073 o.listBox.RemoveAll()
2074
2075 // Reset realInput
2076 o.mu.Lock()
2077 o.realInput = ""
2078 o.mu.Unlock()
2079
2080 if o.onClose != nil {
2081 o.onClose()
2082 }
2083}
2084
2085// Toggle shows if hidden, hides if visible.
2086func (o *Omnibox) Toggle(ctx context.Context) {
2087 o.mu.RLock()
2088 visible := o.visible
2089 o.mu.RUnlock()
2090
2091 if visible {
2092 o.Hide(ctx)
2093 } else {
2094 o.Show(ctx, "")
2095 }
2096}
2097
2098// IsVisible returns whether the omnibox is currently shown.
2099func (o *Omnibox) IsVisible() bool {
2100 o.mu.RLock()
2101 defer o.mu.RUnlock()
2102 return o.visible
2103}
2104
2105// SetOnNavigate sets the callback for URL navigation.
2106func (o *Omnibox) SetOnNavigate(fn func(url string)) {
2107 o.onNavigate = fn
2108}
2109
2110// SetOnClose sets the callback for omnibox close events.
2111func (o *Omnibox) SetOnClose(fn func()) {
2112 o.onClose = fn
2113}
2114
2115// UpdateZoomIndicator updates the zoom percentage display.
2116// Shows the indicator when zoom != 100%, hides it when at 100%.
2117func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2118 var cb glib.SourceFunc = func(data uintptr) bool {
2119 if o.zoomLabel == nil {
2120 return false
2121 }
2122 if factor == 1.0 {
2123 o.zoomLabel.SetVisible(false)
2124 } else {
2125 percentage := int(factor * 100)
2126 o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
2127 o.zoomLabel.SetVisible(true)
2128 }
2129 return false
2130 }
2131 glib.IdleAdd(&cb, 0)
2132}
2133
2134// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2135// The query parameter is passed through to ensure ghost text uses the correct input.
2136func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
2137 var cb glib.SourceFunc = func(data uintptr) bool {
2138 o.updateSuggestions(suggestions, query)
2139 return false // One-shot callback
2140 }
2141 glib.IdleAdd(&cb, 0)
2142}
2143
2144// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
2145// Returns empty map on error to avoid blocking history display.
2146func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2147 if o.favoritesUC == nil {
2148 return make(map[string]struct{})
2149 }
2150 urls, err := o.favoritesUC.GetAllURLs(ctx)
2151 if err != nil {
2152 log := logging.FromContext(ctx)
2153 log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
2154 return make(map[string]struct{})
2155 }
2156 return urls
2157}
2158
2159// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2160// The query parameter is passed through to ensure ghost text uses the correct input.
2161func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
2162 var cb glib.SourceFunc = func(data uintptr) bool {
2163 o.updateFavorites(favorites, query)
2164 return false // One-shot callback, return false to remove source
2165 }
2166 glib.IdleAdd(&cb, 0)
2167}
2168
2169// Widget returns the omnibox widget for embedding in an overlay.
2170func (o *Omnibox) Widget() *gtk.Widget {
2171 if o.outerBox == nil {
2172 return nil
2173 }
2174 return &o.outerBox.Widget
2175}
2176
2177// Destroy cleans up omnibox resources.
2178func (o *Omnibox) Destroy() {
2179 o.debounceMu.Lock()
2180 if o.debounceTimer != nil {
2181 o.debounceTimer.Stop()
2182 }
2183 o.debounceMu.Unlock()
2184
2185 o.mu.Lock()
2186 o.visible = false
2187 o.mu.Unlock()
2188
2189 o.parentOverlay = nil
2190 o.retainedCallbacks = nil
2191 o.clickOutsideController = nil
2192 o.clickOutsideCb = nil
2193
2194 if o.outerBox != nil {
2195 o.outerBox.Unparent()
2196 o.outerBox = nil
2197 }
2198}
2199
2200// formatShortcut formats a shortcut number.
2201func formatShortcut(n int) string {
2202 return "Ctrl+" + string(rune('0'+n))
2203}
2204
2205// measureWidgetHeight returns the natural height of a widget for the given width.
2206// Returns 0 if widget is nil or measurement fails.
2207func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
2208 if widget == nil {
2209 return 0
2210 }
2211 var minHeight, naturalHeight, minBaseline, naturalBaseline int
2212 widget.Measure(
2213 gtk.OrientationVerticalValue,
2214 forWidth,
2215 &minHeight,
2216 &naturalHeight,
2217 &minBaseline,
2218 &naturalBaseline,
2219 )
2220 return naturalHeight
2221}
2222
2223// measureComponentHeights measures and caches all component heights.
2224// Must be called on GTK main thread after widgets have been laid out.
2225func (o *Omnibox) measureComponentHeights(forWidth int) bool {
2226 if o.headerBox == nil || o.entry == nil || o.listBox == nil {
2227 return false
2228 }
2229
2230 headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
2231 entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
2232
2233 // Measure first row if it exists
2234 var rowH int
2235 if row := o.listBox.GetRowAtIndex(0); row != nil {
2236 rowH = measureWidgetHeight(&row.Widget, forWidth)
2237 }
2238
2239 // Validate measurements (header and entry must be positive)
2240 if headerH <= 0 || entryH <= 0 {
2241 return false
2242 }
2243
2244 o.measuredHeights.header = headerH
2245 o.measuredHeights.entry = entryH
2246 if rowH > 0 {
2247 o.measuredHeights.singleRow = rowH
2248 o.measuredHeights.valid = true
2249 }
2250 return true
2251}
2252
2253// errNilWidget creates an error for nil widget creation.
2254func errNilWidget(name string) error {
2255 return &widgetError{name: name}
2256}
2257
2258type widgetError struct {
2259 name string
2260}
2261
2262func (e *widgetError) Error() string {
2263 return "failed to create widget: " + e.name
2264}