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