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/puregotk/v4/gdk"
13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/graphene"
15 "github.com/bnema/puregotk/v4/gtk"
16 "github.com/bnema/puregotk/v4/pango"
17
18 "github.com/bnema/dumber/internal/application/usecase"
19 "github.com/bnema/dumber/internal/domain/autocomplete"
20 "github.com/bnema/dumber/internal/domain/entity"
21 "github.com/bnema/dumber/internal/domain/url"
22 "github.com/bnema/dumber/internal/logging"
23 "github.com/bnema/dumber/internal/ui/adapter"
24 "github.com/bnema/dumber/internal/ui/input"
25 "github.com/bnema/dumber/internal/ui/layout"
26 "github.com/bnema/dumber/internal/ui/mainloop"
27)
28
29const (
30 debounceDelayMs = 50
31 endBoxSpacing = 6
32 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
33 minGhostInputLength = 1
34 initialBehaviorBadgeTooltip = "Toggle default history order (Ctrl+R)"
35)
36
37type favoriteRowIndicatorUpdate struct {
38 Apply bool
39 ShowStarSlot bool
40}
41
42// ViewMode distinguishes history search from favorites display.
43type ViewMode string
44
45const (
46 ViewModeHistory ViewMode = "history"
47 ViewModeFavorites ViewMode = "favorites"
48)
49
50// Suggestion represents a search result from history.
51type Suggestion struct {
52 URL string
53 Title string
54 FaviconURL string
55 IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
56}
57
58// BangSuggestion represents a configured bang shortcut.
59// A bang is invoked by typing "!<key>".
60type BangSuggestion struct {
61 Key string
62 Description string
63}
64
65// Favorite represents a bookmarked URL.
66type Favorite struct {
67 ID int64
68 URL string
69 Title string
70 FaviconURL string
71 Position int
72}
73
74// Omnibox is the native GTK4 address bar / command palette.
75type Omnibox struct {
76 // GTK widgets
77 outerBox *gtk.Box // Outer container for positioning
78 mainBox *gtk.Box // Main content box
79 headerBox *gtk.Box
80 initialBehaviorBadge *gtk.Button
81 historyBtn *gtk.Button
82 favoritesBtn *gtk.Button
83 zoomLabel *gtk.Label
84 bangBadge *gtk.Label
85 entry *gtk.SearchEntry
86 scrolledWin *gtk.ScrolledWindow
87 listBox *gtk.ListBox
88
89 // Parent overlay reference for sizing (set via SetParentOverlay)
90 parentOverlay layout.OverlayWidget
91
92 // State
93 mu sync.RWMutex
94 visible bool
95 viewMode ViewMode
96 selectedIndex int
97 suggestions []Suggestion
98 favorites []Favorite
99 bangSuggestions []BangSuggestion
100 bangMode bool
101 detectedBang string
102 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
103
104 // Ghost text state
105 realInput string // What user actually typed (without ghost suffix)
106 ghostSuffix string // Completion suffix currently displayed
107 insertCompletion bool // True on character insert, false on delete — gates ghost completion
108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
109
110 // Dependencies
111 historyUC *usecase.SearchHistoryUseCase
112 favoritesUC *usecase.ManageFavoritesUseCase
113 faviconAdapter *adapter.FaviconAdapter
114 copyURLUC *usecase.CopyURLUseCase
115 shortcutsUC *usecase.SearchShortcutsUseCase
116 defaultSearch string
117 initialBehavior entity.OmniboxInitialBehavior
118 mostVisitedDays int
119 saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
120 ctx context.Context
121
122 // Callbacks
123 onNavigate func(ctx context.Context, url string) error
124 onClose func()
125 onToast func(ctx context.Context, message string, level ToastLevel)
126 onAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
127 onAccentKeyRelease func(keyval uint)
128
129 // Debouncing
130 debounceTimer *time.Timer
131 debounceMu sync.Mutex
132 lastQuery string // Prevent duplicate searches
133 searchToken uint64
134
135 // Scaling
136 uiScale float64
137 sizeCfg ModalSizeConfig
138
139 // Cached measurements (populated after first layout)
140 measuredHeights struct {
141 header int // headerBox natural height
142 entry int // entry natural height
143 singleRow int // single ListBoxRow natural height
144 valid bool // whether cache is valid
145 }
146
147 // Callback retention: keep GTK signal callbacks reachable by Go GC.
148 retainedCallbacks []interface{}
149
150 // Click outside handler
151 clickOutsideController *gtk.GestureClick
152 clickOutsideCb func(gtk.GestureClick, int, float64, float64)
153 idleCoalescer *mainloop.Coalescer
154}
155
156// OmniboxConfig holds configuration for creating an Omnibox.
157type OmniboxConfig struct {
158 HistoryUC *usecase.SearchHistoryUseCase
159 FavoritesUC *usecase.ManageFavoritesUseCase
160 FaviconAdapter *adapter.FaviconAdapter
161 CopyURLUC *usecase.CopyURLUseCase
162 ShortcutsUC *usecase.SearchShortcutsUseCase
163 DefaultSearch string
164 InitialBehavior entity.OmniboxInitialBehavior
165 MostVisitedDays int
166 SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
167 UIScale float64 // UI scale for favicon sizing
168 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
169 OnNavigate func(ctx context.Context, url string) error
170 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
171 OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
172 OnFocusOut func() // Callback when entry loses focus
173 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection
174 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel
175 SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing
176}
177
178// NewOmnibox creates a new native GTK4 omnibox widget.
179// Call SetParentOverlay() before Show() to set the parent for sizing.
180func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
181 log := logging.FromContext(ctx)
182
183 uiScale := cfg.UIScale
184 if uiScale <= 0 {
185 uiScale = 1.0
186 }
187
188 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
189
190 o := &Omnibox{
191 viewMode: ViewModeHistory,
192 selectedIndex: -1,
193 historyUC: cfg.HistoryUC,
194 favoritesUC: cfg.FavoritesUC,
195 faviconAdapter: cfg.FaviconAdapter,
196 copyURLUC: cfg.CopyURLUC,
197 shortcutsUC: cfg.ShortcutsUC,
198 defaultSearch: cfg.DefaultSearch,
199 initialBehavior: cfg.InitialBehavior,
200 mostVisitedDays: cfg.MostVisitedDays,
201 saveInitialBehaviorFn: cfg.SaveInitialBehavior,
202 onToast: cfg.OnToast,
203 onAccentKeyPress: cfg.OnAccentKeyPress,
204 onAccentKeyRelease: cfg.OnAccentKeyRelease,
205 ctx: ctx,
206 uiScale: uiScale,
207 sizeCfg: sizeCfg,
208 }
209 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
210 var cb glib.SourceFunc = func(uintptr) bool {
211 fn()
212 return false
213 }
214 glib.IdleAdd(&cb, 0)
215 })
216
217 if err := o.createWidgets(); err != nil {
218 log.Error().Err(err).Msg("failed to create omnibox widgets")
219 return nil
220 }
221
222 o.setupKeyboardHandling()
223 o.setupEntryChanged()
224 o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
225
226 // Set navigation callback if provided
227 if cfg.OnNavigate != nil {
228 o.onNavigate = cfg.OnNavigate
229 }
230
231 log.Debug().Msg("omnibox created")
232 return o
233}
234
235// SetParentOverlay sets the overlay widget used for sizing calculations.
236// Must be called before Show().
237func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
238 o.mu.Lock()
239 defer o.mu.Unlock()
240 o.parentOverlay = overlay
241 o.setupClickOutsideHandler()
242}
243
244// setupClickOutsideHandler attaches a click gesture to the parent overlay
245// that hides the omnibox when clicking outside its bounds.
246func (o *Omnibox) setupClickOutsideHandler() {
247 if o.parentOverlay == nil {
248 return
249 }
250
251 log := logging.FromContext(o.ctx)
252
253 // Create gesture click controller
254 o.clickOutsideController = gtk.NewGestureClick()
255 if o.clickOutsideController == nil {
256 log.Error().Msg("failed to create click outside controller")
257 return
258 }
259
260 // Listen to primary button only
261 o.clickOutsideController.SetButton(1) // GDK_BUTTON_PRIMARY
262
263 // Set capture phase so we see clicks before they reach the omnibox
264 o.clickOutsideController.SetPropagationPhase(gtk.PhaseCaptureValue)
265
266 // Connect pressed handler
267 o.clickOutsideCb = func(_ gtk.GestureClick, _ int, x, y float64) {
268 o.handleClickOutside(x, y)
269 }
270 o.clickOutsideController.ConnectPressed(&o.clickOutsideCb)
271
272 // Add controller to parent overlay
273 gtkWidget := o.parentOverlay.GtkWidget()
274 if gtkWidget != nil {
275 gtkWidget.AddController(&o.clickOutsideController.EventController)
276 }
277
278 log.Debug().Msg("click outside handler attached to parent overlay")
279}
280
281// handleClickOutside checks if a click is outside the omnibox bounds and hides if so.
282func (o *Omnibox) handleClickOutside(clickX, clickY float64) {
283 o.mu.RLock()
284 visible := o.visible
285 o.mu.RUnlock()
286
287 if !visible || o.outerBox == nil || o.parentOverlay == nil {
288 return
289 }
290
291 // Get omnibox position relative to parent overlay using graphene points
292 srcPoint := &graphene.Point{X: 0, Y: 0}
293 outPoint := &graphene.Point{}
294 ok := o.outerBox.ComputePoint(o.parentOverlay.GtkWidget(), srcPoint, outPoint)
295 if !ok {
296 return
297 }
298
299 boxX := float64(outPoint.X)
300 boxY := float64(outPoint.Y)
301
302 // Get omnibox dimensions
303 boxWidth := float64(o.outerBox.GetAllocatedWidth())
304 boxHeight := float64(o.outerBox.GetAllocatedHeight())
305
306 // Check if click is inside omnibox bounds
307 isInside := clickX >= boxX && clickX <= boxX+boxWidth &&
308 clickY >= boxY && clickY <= boxY+boxHeight
309
310 if !isInside {
311 logging.FromContext(o.ctx).Debug().
312 Float64("clickX", clickX).
313 Float64("clickY", clickY).
314 Float64("boxX", boxX).
315 Float64("boxY", boxY).
316 Float64("boxWidth", boxWidth).
317 Float64("boxHeight", boxHeight).
318 Msg("click outside omnibox, hiding")
319 o.Hide(o.ctx)
320 }
321}
322
323// WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
324// This is useful for adding the omnibox to a PaneView overlay.
325func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
326 if o.outerBox == nil {
327 return nil
328 }
329 return factory.WrapWidget(&o.outerBox.Widget)
330}
331
332// estimateRowHeight returns the current best estimate for a single row height.
333func (o *Omnibox) estimateRowHeight() int {
334 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
335 return o.measuredHeights.singleRow
336 }
337 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)
338}
339
340func resultsContainerState(rowCount int) (visible, expand, listVisible bool) {
341 return rowCount > 0, rowCount > 0, rowCount > 0
342}
343
344func shouldApplyEmptyResultsState(rowCount int, visible bool) bool {
345 return !visible || rowCount <= 0
346}
347
348type initialBehaviorBadgeStateInfo struct {
349 visible bool
350 label string
351 tooltip string
352 nextBehavior entity.OmniboxInitialBehavior
353}
354
355func initialBehaviorBadgeState(initialBehavior entity.OmniboxInitialBehavior) initialBehaviorBadgeStateInfo {
356 switch initialBehavior {
357 case entity.OmniboxInitialBehaviorRecent:
358 return initialBehaviorBadgeStateInfo{
359 visible: true,
360 label: "Recent",
361 tooltip: initialBehaviorBadgeTooltip,
362 nextBehavior: entity.OmniboxInitialBehaviorMostVisited,
363 }
364 case entity.OmniboxInitialBehaviorMostVisited:
365 return initialBehaviorBadgeStateInfo{
366 visible: true,
367 label: "Most used",
368 tooltip: initialBehaviorBadgeTooltip,
369 nextBehavior: entity.OmniboxInitialBehaviorRecent,
370 }
371 default:
372 return initialBehaviorBadgeStateInfo{}
373 }
374}
375
376var loadInitialHistoryFn = func(o *Omnibox, token uint64) {
377 o.loadInitialHistory(token)
378}
379
380func shouldRefreshInitialHistoryAfterToggle(viewMode ViewMode, query string, initialBehavior entity.OmniboxInitialBehavior) bool {
381 if viewMode != ViewModeHistory {
382 return false
383 }
384 if strings.TrimSpace(query) != "" {
385 return false
386 }
387 switch initialBehavior {
388 case entity.OmniboxInitialBehaviorRecent, entity.OmniboxInitialBehaviorMostVisited:
389 return true
390 default:
391 return false
392 }
393}
394
395func (o *Omnibox) refreshInitialBehaviorBadge() {
396 if o.initialBehaviorBadge == nil {
397 return
398 }
399
400 state := initialBehaviorBadgeState(o.initialBehavior)
401 o.initialBehaviorBadge.SetVisible(state.visible)
402 if !state.visible {
403 return
404 }
405 o.initialBehaviorBadge.SetLabel(state.label)
406 tooltip := state.tooltip
407 o.initialBehaviorBadge.SetTooltipText(&tooltip)
408}
409
410func (o *Omnibox) toggleInitialBehaviorPreference() bool {
411 state := initialBehaviorBadgeState(o.initialBehavior)
412 if state.nextBehavior == "" || o.saveInitialBehaviorFn == nil {
413 return false
414 }
415
416 nextBehavior := state.nextBehavior
417 if err := o.saveInitialBehaviorFn(o.ctx, nextBehavior); err != nil {
418 if o.onToast != nil {
419 o.onToast(o.ctx, "Failed to save default history order", ToastError)
420 }
421 return false
422 }
423
424 o.initialBehavior = nextBehavior
425 o.refreshInitialBehaviorBadge()
426
427 var query string
428 if o.entry != nil {
429 query = o.entry.GetText()
430 }
431 if shouldRefreshInitialHistoryAfterToggle(o.viewMode, query, o.initialBehavior) && loadInitialHistoryFn != nil {
432 loadInitialHistoryFn(o, o.searchToken)
433 }
434
435 return true
436}
437
438func (o *Omnibox) handleEscapeKeyPress() bool {
439 if o.entry == nil {
440 return false
441 }
442 text := o.entry.GetText()
443 if text != "" {
444 o.entry.SetText("")
445 return true
446 }
447 o.Hide(o.ctx)
448 return true
449}
450
451func (o *Omnibox) setResultsContainerState(rowCount int) {
452 if o.scrolledWin == nil {
453 return
454 }
455 visible, expand, listVisible := resultsContainerState(rowCount)
456 o.scrolledWin.SetVisible(visible)
457 o.scrolledWin.SetVexpand(expand)
458 if o.listBox != nil {
459 o.listBox.SetVisible(listVisible)
460 }
461 if o.mainBox != nil {
462 if shouldApplyEmptyResultsState(rowCount, visible) {
463 o.mainBox.AddCssClass("omnibox-empty")
464 } else {
465 o.mainBox.RemoveCssClass("omnibox-empty")
466 }
467 }
468}
469
470// effectiveMaxRows returns the max visible rows adapted to the current parent pane height.
471// Must be called on the GTK main thread.
472func (o *Omnibox) effectiveMaxRows() int {
473 if o.parentOverlay == nil {
474 return OmniboxListDefaults.MaxVisibleRows
475 }
476 return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults)
477}
478
479// resizeAndCenter adjusts the omnibox size based on content and centers it.
480// rowCount is the number of result rows to display (0 = no content, adapts to parent height).
481func (o *Omnibox) resizeAndCenter(rowCount int) {
482 if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil {
483 return
484 }
485
486 // Cap at effective max rows (adapts to parent pane height)
487 if maxRows := o.effectiveMaxRows(); rowCount > maxRows {
488 rowCount = maxRows
489 }
490
491 // Schedule measurement after GTK has laid out widgets
492 var cb glib.SourceFunc = func(uintptr) bool {
493 width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
494 o.measureAndResize(width, rowCount)
495 return false
496 }
497 glib.IdleAdd(&cb, 0)
498}
499
500// measureAndResize calculates and sets the omnibox size based on content.
501// Uses measured widget heights when available, falls back to estimates.
502func (o *Omnibox) measureAndResize(width, rowCount int) {
503 if o.outerBox == nil || o.mainBox == nil {
504 return
505 }
506
507 log := logging.FromContext(o.ctx)
508
509 // Apply width to mainBox - this allows the omnibox to adapt when parent resizes
510 o.mainBox.SetSizeRequest(width, -1)
511
512 var rowHeight int
513
514 if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
515 // Use measured values from GTK4 Measure API
516 rowHeight = o.measuredHeights.singleRow
517 } else {
518 // Fallback to scaled estimate (before first measurement)
519 rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
520 }
521
522 // Calculate content height for the scrolled window
523 // This is the key to dynamic sizing - limit the ScrolledWindow's max height
524 contentHeight := 0
525 if rowCount > 0 {
526 contentHeight = rowCount * rowHeight
527 }
528
529 // Cap at effective max rows (adapts to parent pane height)
530 maxContentHeight := o.effectiveMaxRows() * rowHeight
531 if contentHeight > maxContentHeight {
532 contentHeight = maxContentHeight
533 }
534
535 // Set size constraints on ScrolledWindow using shared helper
536 SetScrolledWindowHeight(o.scrolledWin, contentHeight)
537
538 // Force layout recalculation
539 o.outerBox.QueueResize()
540 if parent := o.parentOverlay; parent != nil {
541 if widget := parent.GtkWidget(); widget != nil {
542 widget.QueueResize()
543 }
544 }
545 if o.scrolledWin != nil {
546 o.scrolledWin.QueueResize()
547 }
548 o.mainBox.QueueResize()
549
550 log.Debug().
551 Int("width", width).
552 Int("contentHeight", contentHeight).
553 Int("rowHeight", rowHeight).
554 Int("rows", rowCount).
555 Bool("measured", o.measuredHeights.valid).
556 Msg("omnibox resized")
557}
558
559// createWidgets builds the GTK widget hierarchy.
560func (o *Omnibox) createWidgets() error {
561 if err := o.initOuterBox(); err != nil {
562 return err
563 }
564 if err := o.initMainBox(); err != nil {
565 return err
566 }
567 if err := o.initHeader(); err != nil {
568 return err
569 }
570 if err := o.initEntry(); err != nil {
571 return err
572 }
573 if err := o.initList(); err != nil {
574 return err
575 }
576 o.assembleWidgets()
577 return nil
578}
579
580// setupKeyboardHandling adds keyboard event handling.
581func (o *Omnibox) setupKeyboardHandling() {
582 log := logging.FromContext(o.ctx)
583 controller := gtk.NewEventControllerKey()
584 if controller == nil {
585 log.Error().Msg("failed to create event controller key")
586 return
587 }
588
589 // Set capture phase to intercept before entry
590 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
591
592 keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
593 if o.handleKeyPress(keyval, keycode, state) {
594 return true
595 }
596 // Try long-press accent detection for text keys
597 if o.onAccentKeyPress != nil {
598 return o.onAccentKeyPress(keyval, state)
599 }
600 return false
601 }
602 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
603 controller.ConnectKeyPressed(&keyPressedCb)
604
605 keyReleasedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
606 if o.onAccentKeyRelease != nil {
607 o.onAccentKeyRelease(keyval)
608 }
609 }
610 o.retainedCallbacks = append(o.retainedCallbacks, keyReleasedCb)
611 controller.ConnectKeyReleased(&keyReleasedCb)
612
613 o.outerBox.AddController(&controller.EventController)
614}
615
616func (o *Omnibox) initOuterBox() error {
617 o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
618 if o.outerBox == nil {
619 return errNilWidget("outerBox")
620 }
621 o.outerBox.AddCssClass("omnibox-outer")
622 o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
623 o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
624 o.outerBox.SetVisible(false) // Hidden by default
625 return nil
626}
627
628func (o *Omnibox) initMainBox() error {
629 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
630 if o.mainBox == nil {
631 return errNilWidget("mainBox")
632 }
633 o.mainBox.AddCssClass("omnibox-container")
634 return nil
635}
636
637func (o *Omnibox) initHeader() error {
638 o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
639 if o.headerBox == nil {
640 return errNilWidget("headerBox")
641 }
642 o.headerBox.AddCssClass("omnibox-header")
643 o.headerBox.SetHexpand(true)
644
645 o.historyBtn = gtk.NewButtonWithLabel("History")
646 if o.historyBtn == nil {
647 return errNilWidget("historyBtn")
648 }
649 o.historyBtn.AddCssClass("omnibox-header-btn")
650 o.historyBtn.AddCssClass("omnibox-header-active")
651 o.historyBtn.SetCanFocus(false)
652
653 o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
654 if o.favoritesBtn == nil {
655 return errNilWidget("favoritesBtn")
656 }
657 o.favoritesBtn.AddCssClass("omnibox-header-btn")
658 o.favoritesBtn.SetCanFocus(false)
659
660 o.initialBehaviorBadge = gtk.NewButtonWithLabel("")
661 if o.initialBehaviorBadge == nil {
662 return errNilWidget("initialBehaviorBadge")
663 }
664 o.initialBehaviorBadge.AddCssClass("omnibox-header-badge")
665 o.initialBehaviorBadge.SetCanFocus(false)
666 o.initialBehaviorBadge.SetFocusOnClick(false)
667
668 initialBehaviorClickCb := func(_ gtk.Button) {
669 o.toggleInitialBehaviorPreference()
670 }
671 o.retainedCallbacks = append(o.retainedCallbacks, initialBehaviorClickCb)
672 o.initialBehaviorBadge.ConnectClicked(&initialBehaviorClickCb)
673 o.refreshInitialBehaviorBadge()
674
675 historyClickCb := func(_ gtk.Button) {
676 o.setViewMode(ViewModeHistory)
677 }
678 o.retainedCallbacks = append(o.retainedCallbacks, historyClickCb)
679 o.historyBtn.ConnectClicked(&historyClickCb)
680
681 favoritesClickCb := func(_ gtk.Button) {
682 o.setViewMode(ViewModeFavorites)
683 }
684 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
685 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
686
687 o.bangBadge = gtk.NewLabel(nil)
688 if o.bangBadge != nil {
689 o.bangBadge.AddCssClass("omnibox-bang-badge")
690 o.bangBadge.SetVisible(false)
691 }
692
693 o.zoomLabel = gtk.NewLabel(nil)
694 if o.zoomLabel != nil {
695 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
696 o.zoomLabel.SetVisible(false)
697 }
698 return nil
699}
700
701func (o *Omnibox) initEntry() error {
702 o.entry = gtk.NewSearchEntry()
703 if o.entry == nil {
704 return errNilWidget("entry")
705 }
706 o.entry.AddCssClass("omnibox-entry")
707 o.entry.SetHexpand(true)
708
709 placeholder := defaultOmniboxPlaceholder
710 o.entry.SetPlaceholderText(&placeholder)
711
712 return nil
713}
714
715func (o *Omnibox) initList() error {
716 o.scrolledWin = gtk.NewScrolledWindow()
717 if o.scrolledWin == nil {
718 return errNilWidget("scrolledWin")
719 }
720 o.scrolledWin.AddCssClass("omnibox-scrolled")
721 o.scrolledWin.SetVexpand(false)
722 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
723 o.scrolledWin.SetPropagateNaturalHeight(true)
724
725 o.listBox = gtk.NewListBox()
726 if o.listBox == nil {
727 return errNilWidget("listBox")
728 }
729 o.listBox.AddCssClass("omnibox-listbox")
730 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
731
732 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
733 o.restoreEntryToRealInput()
734 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
735 if row == nil {
736 o.mu.Lock()
737 o.selectedIndex = -1
738 o.mu.Unlock()
739 } else {
740 o.mu.Lock()
741 o.selectedIndex = row.GetIndex()
742 o.mu.Unlock()
743 }
744 o.updateGhostFromSelection()
745 }
746 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
747 o.listBox.ConnectRowSelected(&rowSelectedCb)
748
749 // Handle row activation (click or Enter) - navigate directly to the URL
750 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
751 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
752 if row == nil {
753 return
754 }
755 idx := row.GetIndex()
756 o.mu.RLock()
757 mode := o.viewMode
758 bangMode := o.bangMode
759 bangSuggestions := o.bangSuggestions
760 suggestions := o.suggestions
761 favorites := o.favorites
762 o.mu.RUnlock()
763
764 // Don't navigate in bang mode - activating a bang should fill it in
765 if bangMode {
766 if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok && o.entry != nil {
767 o.entry.SetText(bangText)
768 o.entry.SetPosition(-1)
769 }
770 return
771 }
772
773 targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
774
775 if targetURL != "" {
776 o.submitNavigation(targetURL)
777 }
778 }
779 o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
780 o.listBox.ConnectRowActivated(&rowActivatedCb)
781
782 o.setupListHoverSelection()
783
784 return nil
785}
786
787func (o *Omnibox) setupListHoverSelection() {
788 motionCtrl := gtk.NewEventControllerMotion()
789 if motionCtrl == nil {
790 logging.FromContext(o.ctx).Error().Msg("failed to create motion event controller")
791 return
792 }
793
794 motionCb := func(_ gtk.EventControllerMotion, _ float64, y float64) {
795 o.handleListHoverSelection(y)
796 }
797 o.retainedCallbacks = append(o.retainedCallbacks, motionCb)
798 motionCtrl.ConnectMotion(&motionCb)
799 o.listBox.AddController(&motionCtrl.EventController)
800}
801
802func (o *Omnibox) handleListHoverSelection(y float64) {
803 o.mu.RLock()
804 realInput := o.realInput
805 hasGhostText := o.ghostSuffix != ""
806 hasNavigated := o.hasNavigated
807 selectedIndex := o.selectedIndex
808 o.mu.RUnlock()
809
810 if !shouldPromoteHoverSelection(realInput, hasGhostText, hasNavigated) {
811 return
812 }
813
814 row := o.listBox.GetRowAtY(int(y))
815 if row == nil {
816 return
817 }
818
819 index := row.GetIndex()
820 if index < 0 || index == selectedIndex {
821 return
822 }
823 o.selectIndex(index)
824}
825
826func (o *Omnibox) assembleWidgets() {
827 o.headerBox.Append(&o.historyBtn.Widget)
828 o.headerBox.Append(&o.favoritesBtn.Widget)
829
830 endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
831 if endBox != nil {
832 endBox.SetHexpand(true)
833 endBox.SetHalign(gtk.AlignEndValue)
834 if o.initialBehaviorBadge != nil {
835 endBox.Append(&o.initialBehaviorBadge.Widget)
836 }
837 if o.bangBadge != nil {
838 endBox.Append(&o.bangBadge.Widget)
839 }
840 if o.zoomLabel != nil {
841 endBox.Append(&o.zoomLabel.Widget)
842 }
843 o.headerBox.Append(&endBox.Widget)
844 }
845
846 o.scrolledWin.SetChild(&o.listBox.Widget)
847 o.mainBox.Append(&o.headerBox.Widget)
848 o.mainBox.Append(&o.entry.Widget)
849 o.mainBox.Append(&o.scrolledWin.Widget)
850 o.outerBox.Append(&o.mainBox.Widget)
851}
852
853// setupEntryChanged wires entry text changes to debounced search.
854func (o *Omnibox) setupEntryChanged() {
855 changedCb := func(_ gtk.SearchEntry) {
856 o.onEntryChanged()
857 }
858 o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
859 o.entry.ConnectSearchChanged(&changedCb)
860}
861
862// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
863func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
864 if onFocusIn == nil && onFocusOut == nil {
865 return
866 }
867
868 focusController := gtk.NewEventControllerFocus()
869 if focusController == nil {
870 return
871 }
872
873 if onFocusIn != nil {
874 entry := o.entry // Capture for closure
875 focusInCb := func(_ gtk.EventControllerFocus) {
876 onFocusIn(entry)
877 }
878 o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
879 focusController.ConnectEnter(&focusInCb)
880 }
881
882 if onFocusOut != nil {
883 focusOutCb := func(_ gtk.EventControllerFocus) {
884 onFocusOut()
885 }
886 o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
887 focusController.ConnectLeave(&focusOutCb)
888 }
889
890 o.entry.AddController(&focusController.EventController)
891}
892
893// hasGhost returns whether ghost text is currently visible.
894func (o *Omnibox) hasGhost() bool {
895 o.mu.RLock()
896 defer o.mu.RUnlock()
897 return o.ghostSuffix != ""
898}
899
900// hasUserNavigated returns whether user has navigated with arrow keys.
901func (o *Omnibox) hasUserNavigated() bool {
902 o.mu.RLock()
903 defer o.mu.RUnlock()
904 return o.hasNavigated
905}
906
907// handleKeyPress processes keyboard events.
908// Returns true if the event was handled.
909func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
910 o.updateInsertCompletionFromKey(keyval)
911
912 ctrl := state&gdk.ControlMaskValue != 0
913
914 switch keyval {
915 case uint(gdk.KEY_r), uint(gdk.KEY_R):
916 if ctrl {
917 return o.toggleInitialBehaviorPreference()
918 }
919
920 case uint(gdk.KEY_Escape):
921 return o.handleEscapeKeyPress()
922
923 case uint(gdk.KEY_l), uint(gdk.KEY_L):
924 // Ctrl+L toggles omnibox (hides when visible)
925 if ctrl {
926 o.Hide(o.ctx)
927 return true
928 }
929
930 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
931 o.navigateToSelected()
932 return true
933
934 case uint(gdk.KEY_Up):
935 o.selectPrevious()
936 return true
937
938 case uint(gdk.KEY_Down):
939 o.selectNext()
940 return true
941
942 case uint(gdk.KEY_Tab):
943 // Tab accepts ghost text if present, otherwise toggles view mode
944 if o.hasGhost() {
945 o.acceptGhostCompletion()
946 return true
947 }
948 o.toggleViewMode()
949 return true
950
951 case uint(gdk.KEY_Right):
952 // Arrow Right accepts ghost text if present
953 if o.hasGhost() {
954 o.acceptGhostCompletion()
955 return true
956 }
957 return false // Let entry handle normal cursor movement
958
959 case uint(gdk.KEY_space):
960 // Space toggles favorite only if user has navigated with arrow keys
961 if o.hasUserNavigated() {
962 o.toggleFavorite()
963 return true
964 }
965 return false // Let entry handle space for typing
966
967 case uint(gdk.KEY_y):
968 // 'y' yanks (copies) the selected URL to clipboard when navigating
969 if o.hasUserNavigated() {
970 o.yankSelectedURL()
971 return true
972 }
973 return false // Let entry handle 'y' for typing
974
975 default:
976 return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
977 }
978
979 return false // Let entry handle the key
980}
981
982func isDeletionKey(keyval uint) bool {
983 switch keyval {
984 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
985 return true
986 default:
987 return false
988 }
989}
990
991// updateInsertCompletionFromKey sets whether the next text change should trigger
992// ghost completion. Character insertion enables it; deletion disables it.
993func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) {
994 o.mu.Lock()
995 o.insertCompletion = !isDeletionKey(keyval)
996 o.mu.Unlock()
997}
998
999// withGhostGuard executes fn while the isSettingGhost flag is set,
1000// so that onEntryChanged skips during programmatic text changes.
1001func (o *Omnibox) withGhostGuard(fn func()) {
1002 o.mu.Lock()
1003 o.isSettingGhost = true
1004 o.mu.Unlock()
1005
1006 fn()
1007
1008 o.mu.Lock()
1009 o.isSettingGhost = false
1010 o.mu.Unlock()
1011}
1012
1013// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
1014// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
1015func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
1016 if !ctrl {
1017 return false
1018 }
1019
1020 const tenthItemIndex = 9
1021
1022 // First try keyval (works for QWERTY layouts)
1023 switch keyval {
1024 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
1025 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
1026 idx := int(keyval - uint(gdk.KEY_1))
1027 o.selectAndNavigate(idx)
1028 return true
1029 case uint(gdk.KEY_0):
1030 o.selectAndNavigate(tenthItemIndex)
1031 return true
1032 }
1033
1034 // Fallback: use hardware keycode for non-QWERTY layouts
1035 // This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
1036 if index, ok := input.KeycodeToDigitIndex(keycode); ok {
1037 log := logging.FromContext(o.ctx)
1038 log.Debug().
1039 Uint("keycode", keycode).
1040 Int("index", index).
1041 Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
1042 o.selectAndNavigate(index)
1043 return true
1044 }
1045
1046 return false
1047}
1048
1049// onEntryChanged handles text input changes with debouncing.
1050// When this fires, GTK has already processed the keystroke — if ghost text was
1051// selected and the user typed, the selection was replaced naturally by GTK.
1052// We must NOT call clearGhostText() here (that would delete real user text).
1053func (o *Omnibox) onEntryChanged() {
1054 o.mu.RLock()
1055 isSettingGhost := o.isSettingGhost
1056 o.mu.RUnlock()
1057 if isSettingGhost {
1058 return
1059 }
1060
1061 entryText := o.entry.GetText()
1062
1063 // Detect debounced echo from our own SetText in setGhostText.
1064 // search-changed is debounced by GtkSearchEntry, so it fires AFTER
1065 // the isSettingGhost guard is already off.
1066 o.mu.RLock()
1067 if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
1068 o.mu.RUnlock()
1069 return
1070 }
1071 o.mu.RUnlock()
1072
1073 log := logging.FromContext(o.ctx)
1074 if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
1075 o.withGhostGuard(func() {
1076 o.entry.SetText(trimmed)
1077 o.entry.SetPosition(-1)
1078 })
1079 entryText = trimmed
1080 }
1081
1082 // Reset ghost and selection state (the buffer is already correct — GTK handled it).
1083 o.mu.Lock()
1084 oldGhost := o.ghostSuffix
1085 o.hasNavigated = false
1086 o.selectedIndex = -1
1087 o.ghostSuffix = ""
1088 o.realInput = entryText
1089 shouldComplete := o.insertCompletion
1090 o.mu.Unlock()
1091
1092 log.Debug().
1093 Str("entryText", entryText).
1094 Str("oldGhost", oldGhost).
1095 Bool("insertCompletion", shouldComplete).
1096 Msg("ghost: onEntryChanged")
1097
1098 // Compute ghost completion synchronously if this was a character insertion.
1099 if shouldComplete {
1100 o.updateGhostFromSelectionWithInput(entryText)
1101 }
1102
1103 // Schedule async search for suggestion list updates.
1104 o.debounceMu.Lock()
1105 if o.debounceTimer != nil {
1106 o.debounceTimer.Stop()
1107 }
1108 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
1109 o.idleCoalescer.Post("omnibox-search", o.performSearch)
1110 })
1111 o.debounceMu.Unlock()
1112}
1113
1114// setGhostText applies inline completion by setting the full text (input + suffix)
1115// and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged.
1116// Skips if the user has typed since this ghost was computed (stale input check).
1117func (o *Omnibox) setGhostText(originalInput, suffix string) {
1118 log := logging.FromContext(o.ctx)
1119 if o.entry == nil || suffix == "" {
1120 o.clearGhostText()
1121 return
1122 }
1123
1124 // Verify the buffer hasn't been modified by user typing since this ghost
1125 // was computed. We check the actual buffer — not realInput, which may be
1126 // stale due to debounced search-changed signal.
1127 currentBuffer := o.entry.GetText()
1128 o.mu.RLock()
1129 existingGhost := o.ghostSuffix
1130 o.mu.RUnlock()
1131 expectedWithGhost := originalInput + existingGhost
1132 if currentBuffer != originalInput && currentBuffer != expectedWithGhost {
1133 log.Debug().
1134 Str("originalInput", originalInput).
1135 Str("currentBuffer", currentBuffer).
1136 Str("suffix", suffix).
1137 Msg("ghost: setGhostText skipped (buffer modified by user)")
1138 return
1139 }
1140
1141 displayText := originalInput + suffix
1142 cursorPos := utf8.RuneCountInString(originalInput)
1143 endPos := utf8.RuneCountInString(displayText)
1144
1145 log.Debug().
1146 Str("input", originalInput).
1147 Str("suffix", suffix).
1148 Int("cursorPos", cursorPos).
1149 Int("endPos", endPos).
1150 Msg("ghost: setGhostText")
1151
1152 o.mu.Lock()
1153 o.realInput = originalInput
1154 o.ghostSuffix = suffix
1155
1156 o.mu.Unlock()
1157
1158 o.withGhostGuard(func() {
1159 o.entry.SetText(displayText)
1160 o.entry.SetPosition(cursorPos)
1161 o.entry.SelectRegion(cursorPos, endPos)
1162 })
1163}
1164
1165// clearGhostText removes any ghost suffix from the entry buffer.
1166// Uses guard flag to prevent re-entrant onEntryChanged.
1167func (o *Omnibox) clearGhostText() {
1168 log := logging.FromContext(o.ctx)
1169 o.mu.Lock()
1170 ghostSuffix := o.ghostSuffix
1171 realInput := o.realInput
1172 o.ghostSuffix = ""
1173
1174 o.mu.Unlock()
1175
1176 if ghostSuffix == "" || o.entry == nil {
1177 return
1178 }
1179
1180 // Only touch the buffer if it still shows the expected ghost text.
1181 expectedText := realInput + ghostSuffix
1182 currentText := o.entry.GetText()
1183 if currentText != expectedText {
1184 log.Debug().
1185 Str("expected", expectedText).
1186 Str("current", currentText).
1187 Msg("ghost: clearGhostText skipped (buffer changed)")
1188 return
1189 }
1190
1191 log.Debug().
1192 Str("realInput", realInput).
1193 Str("ghostSuffix", ghostSuffix).
1194 Msg("ghost: clearGhostText")
1195
1196 o.withGhostGuard(func() {
1197 o.entry.SetText(realInput)
1198 o.entry.SelectRegion(-1, -1)
1199 o.entry.SetPosition(-1)
1200 })
1201}
1202
1203// clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
1204// This prevents stale background results from wiping newer ghost text.
1205func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1206 o.mu.RLock()
1207 currentInput := o.realInput
1208 o.mu.RUnlock()
1209 if currentInput != expectedInput {
1210 return
1211 }
1212 o.clearGhostText()
1213}
1214
1215// acceptGhostCompletion accepts the ghost text — the suffix is already in the
1216// buffer, so we just deselect and update realInput to include it.
1217func (o *Omnibox) acceptGhostCompletion() {
1218 log := logging.FromContext(o.ctx)
1219 o.mu.Lock()
1220 if o.ghostSuffix == "" {
1221 o.mu.Unlock()
1222 log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept")
1223 return
1224 }
1225 suffix := o.ghostSuffix
1226
1227 o.ghostSuffix = ""
1228 o.mu.Unlock()
1229 log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion")
1230
1231 if o.entry != nil {
1232 // The suffix is already in the buffer as selected text.
1233 // Just deselect and move cursor to end.
1234 fullText := o.entry.GetText()
1235 o.mu.Lock()
1236 o.realInput = fullText
1237 o.selectedIndex = -1
1238 o.hasNavigated = false
1239 o.mu.Unlock()
1240
1241 o.withGhostGuard(func() {
1242 o.entry.SelectRegion(-1, -1)
1243 o.entry.SetPosition(-1)
1244 })
1245 }
1246 if o.listBox != nil {
1247 o.listBox.SelectRow(nil)
1248 }
1249}
1250
1251// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
1252// When a row is selected via arrow keys, show the URL as ghost text even if
1253// the user's input isn't a prefix - this shows what Tab would fill in.
1254
1255func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1256 if targetURL == "" {
1257 return false
1258 }
1259
1260 // Strip protocol from URL for cleaner display
1261 displayURL := autocomplete.StripProtocol(targetURL)
1262
1263 // Only show full URL as ghost text when input is empty (initial state)
1264 if userInput == "" {
1265 o.setGhostText("", displayURL)
1266 return true
1267 }
1268
1269 // Try to compute proper suffix if input is a prefix of the URL
1270 if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1271 o.setGhostText(userInput, suffix)
1272 return true
1273 }
1274
1275 return false
1276}
1277
1278// updateGhostFromSelection updates ghost text based on the currently selected row.
1279func (o *Omnibox) updateGhostFromSelection() {
1280 o.mu.RLock()
1281 realInput := o.realInput
1282 hasGhost := o.ghostSuffix != ""
1283 o.mu.RUnlock()
1284
1285 entryText := ""
1286 if o.entry != nil {
1287 entryText = o.entry.GetText()
1288 }
1289
1290 query := effectiveSearchQuery(entryText, realInput, hasGhost)
1291 o.restoreEntryToRealInput()
1292 o.updateGhostFromSelectionWithInput(query)
1293}
1294
1295func (o *Omnibox) restoreEntryToRealInput() {
1296 if o.entry == nil {
1297 return
1298 }
1299
1300 // Clear ghost text first (removes suffix from buffer with guard).
1301 o.clearGhostText()
1302
1303 // If the buffer still doesn't match realInput, force-set it.
1304 o.mu.RLock()
1305 realInput := o.realInput
1306 o.mu.RUnlock()
1307
1308 if o.entry.GetText() != realInput {
1309 o.withGhostGuard(func() {
1310 o.entry.SetText(realInput)
1311 })
1312 }
1313 o.entry.SelectRegion(-1, -1)
1314 o.entry.SetPosition(-1)
1315}
1316
1317// updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1318func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1319 log := logging.FromContext(o.ctx)
1320 maxVisible := o.effectiveMaxRows()
1321 o.mu.RLock()
1322 idx := o.selectedIndex
1323 mode := o.viewMode
1324 bangMode := o.bangMode
1325 hasNavigated := o.hasNavigated
1326 shouldComplete := o.insertCompletion
1327 suggestions := o.suggestions
1328 favorites := o.favorites
1329 o.mu.RUnlock()
1330
1331 // No ghost text after deletion (backspace/delete key).
1332 if !shouldComplete {
1333 log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)")
1334 return
1335 }
1336
1337 // No ghost text in bang mode
1338 if bangMode {
1339 o.clearGhostTextIfInput(entryText)
1340 return
1341 }
1342
1343 if utf8.RuneCountInString(entryText) < minGhostInputLength && !hasNavigated {
1344 o.clearGhostTextIfInput(entryText)
1345 return
1346 }
1347
1348 targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites)
1349
1350 if entryText != "" {
1351 o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites)
1352 return
1353 }
1354
1355 if targetURL == "" {
1356 o.clearGhostTextIfInput(entryText)
1357 return
1358 }
1359
1360 if o.updateGhostFromURL(entryText, targetURL) {
1361 return
1362 }
1363
1364 o.clearGhostTextIfInput(entryText)
1365}
1366
1367// resolveGhostCompletion computes and applies ghost text synchronously.
1368// All computation is pure string matching — no I/O, no goroutines needed.
1369func (o *Omnibox) resolveGhostCompletion(
1370 entryText, selectedURL string,
1371 hasExplicitSelection bool,
1372 mode ViewMode,
1373 maxVisible int,
1374 suggestions []Suggestion,
1375 favorites []Favorite,
1376) {
1377 log := logging.FromContext(o.ctx)
1378 _, completionInput, ok := ghostCompletionInput(entryText)
1379 if !ok {
1380 log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
1381 return
1382 }
1383
1384 fullText, suffix, found := visibleGhostSuggestion(
1385 completionInput,
1386 selectedURL,
1387 hasExplicitSelection,
1388 mode,
1389 maxVisible,
1390 suggestions,
1391 favorites,
1392 )
1393 if !found {
1394 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1395 return
1396 }
1397 fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1398 if fullText == "" || suffix == "" {
1399 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1400 return
1401 }
1402 log.Debug().
1403 Str("input", completionInput).
1404 Str("fullText", fullText).
1405 Str("suffix", suffix).
1406 Msg("ghost: resolveGhost — applying")
1407 o.setGhostText(entryText, suffix)
1408}
1409
1410func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1411 trimmed := strings.TrimLeft(entryText, " \t")
1412 leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1413 if trimmed == "" {
1414 return leadingWhitespace, "", false
1415 }
1416 return leadingWhitespace, trimmed, true
1417}
1418
1419// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1420// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1421func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
1422 if queryText == "" || fullText == "" {
1423 return "", ""
1424 }
1425
1426 // If input already contains path/query-ish delimiters, keep original completion behavior.
1427 if strings.ContainsAny(queryText, "/?#=& ") {
1428 return fullText, fallbackSuffix
1429 }
1430
1431 hostOnly := extractHostForCompletion(fullText)
1432 if hostOnly != "" {
1433 for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
1434 if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
1435 return candidate, completionSuffix
1436 }
1437 }
1438 }
1439
1440 return fullText, fallbackSuffix
1441}
1442
1443func extractHostForCompletion(raw string) string {
1444 if raw == "" {
1445 return ""
1446 }
1447 if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
1448 return strings.ToLower(parsed.Hostname())
1449 }
1450 trimmed := autocomplete.StripProtocol(raw)
1451 trimmed = strings.ToLower(trimmed)
1452 for _, sep := range []string{"/", "?", "#"} {
1453 if idx := strings.Index(trimmed, sep); idx >= 0 {
1454 trimmed = trimmed[:idx]
1455 }
1456 }
1457 return strings.TrimSpace(trimmed)
1458}
1459
1460func visibleGhostSuggestion(
1461 query, selectedURL string,
1462 hasExplicitSelection bool,
1463 mode ViewMode,
1464 maxVisible int,
1465 suggestions []Suggestion,
1466 favorites []Favorite,
1467) (fullText, suffix string, ok bool) {
1468 if hasExplicitSelection {
1469 suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL)
1470 return fullText, suffix, ok
1471 }
1472
1473 visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
1474 suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
1475 return fullText, suffix, ok
1476}
1477
1478func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
1479 if mode == ViewModeHistory {
1480 visibleCount := visibleResultCount(len(suggestions), maxVisible)
1481 urls := make([]string, 0, visibleCount)
1482 for _, s := range suggestions[:visibleCount] {
1483 if s.URL != "" {
1484 urls = append(urls, s.URL)
1485 }
1486 }
1487 return urls
1488 }
1489
1490 visibleCount := visibleResultCount(len(favorites), maxVisible)
1491 urls := make([]string, 0, visibleCount)
1492 for _, f := range favorites[:visibleCount] {
1493 if f.URL != "" {
1494 urls = append(urls, f.URL)
1495 }
1496 }
1497 return urls
1498}
1499
1500func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
1501 if idx < 0 {
1502 return "", false
1503 }
1504 return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
1505}
1506
1507func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
1508 if idx < 0 || idx >= len(bangSuggestions) {
1509 return "", false
1510 }
1511 return "!" + bangSuggestions[idx].Key + " ", true
1512}
1513
1514func visibleResultCount(total, maxVisible int) int {
1515 if total <= 0 {
1516 return 0
1517 }
1518 if maxVisible <= 0 || total < maxVisible {
1519 return total
1520 }
1521 return maxVisible
1522}
1523
1524// performSearch executes the search based on current view mode and query.
1525func (o *Omnibox) performSearch() {
1526 o.mu.RLock()
1527 visible := o.visible
1528 mode := o.viewMode
1529 realInput := o.realInput
1530 hasGhost := o.ghostSuffix != ""
1531 o.mu.RUnlock()
1532
1533 // Skip search if omnibox is hidden
1534 if !visible {
1535 return
1536 }
1537
1538 query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost)
1539
1540 // Skip duplicate queries
1541 o.debounceMu.Lock()
1542 if query == o.lastQuery && query != "" {
1543 o.debounceMu.Unlock()
1544 return
1545 }
1546 o.lastQuery = query
1547 o.debounceMu.Unlock()
1548
1549 o.mu.Lock()
1550 o.searchToken++
1551 token := o.searchToken
1552 o.mu.Unlock()
1553
1554 if strings.HasPrefix(query, "!") {
1555 o.updateBangDetection(query, token)
1556 o.loadBangSuggestions(query, token)
1557 return
1558 }
1559
1560 o.clearBangState()
1561
1562 if mode == ViewModeFavorites {
1563 o.loadFavorites(query, token)
1564 return
1565 }
1566
1567 // History search
1568 if query == "" {
1569 o.loadInitialHistory(token)
1570 return
1571 }
1572
1573 // Perform fuzzy history search in background
1574 o.searchHistory(query, o.effectiveMaxRows(), token)
1575}
1576
1577func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1578 if hasGhost && realInput != "" {
1579 return realInput
1580 }
1581 return entryText
1582}
1583
1584// searchHistory runs a fuzzy history search in a background goroutine.
1585// query is the search text; limit caps the number of results.
1586func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1587 go func() {
1588 ctx := o.ctx
1589 log := logging.FromContext(ctx)
1590 if o.historyUC == nil {
1591 return
1592 }
1593
1594 // Run history search and favorite URL fetch in parallel
1595 type searchResult struct {
1596 output *usecase.SearchOutput
1597 err error
1598 }
1599 searchCh := make(chan searchResult, 1)
1600 favCh := make(chan map[string]struct{}, 1)
1601
1602 go func() {
1603 searchInput := usecase.SearchInput{
1604 Query: query,
1605 Limit: limit,
1606 }
1607 output, err := o.historyUC.Search(ctx, searchInput)
1608 searchCh <- searchResult{output, err}
1609 }()
1610
1611 go func() {
1612 favCh <- o.getFavoriteURLs(ctx)
1613 }()
1614
1615 // Wait for both results
1616 sr := <-searchCh
1617 favoriteURLs := <-favCh
1618
1619 if sr.err != nil {
1620 log.Error().Err(sr.err).Msg("history search failed")
1621 return
1622 }
1623 if sr.output == nil {
1624 return
1625 }
1626
1627 suggestions := make([]Suggestion, 0, len(sr.output.Matches))
1628 for _, r := range sr.output.Matches {
1629 _, isFav := favoriteURLs[r.Entry.URL]
1630 suggestions = append(suggestions, Suggestion{
1631 URL: r.Entry.URL,
1632 Title: r.Entry.Title,
1633 IsFavorite: isFav,
1634 })
1635 }
1636
1637 // Marshal back to GTK main thread, passing query for stale-result guarding.
1638 o.idleAddUpdateSuggestions(suggestions, query, token)
1639 }()
1640}
1641
1642// loadInitialHistory loads history based on InitialBehavior config.
1643func (o *Omnibox) loadInitialHistory(token uint64) {
1644 if o.historyUC == nil {
1645 return
1646 }
1647
1648 // Capture effective result limit on the GTK main thread before spawning goroutine
1649 initialLimit := o.effectiveMaxRows()
1650 initialBehavior := o.initialBehavior
1651 mostVisitedDays := o.mostVisitedDays
1652
1653 go func() {
1654 ctx := o.ctx
1655 log := logging.FromContext(ctx)
1656 var suggestions []Suggestion
1657
1658 switch initialBehavior {
1659 case entity.OmniboxInitialBehaviorNone:
1660 o.idleAddUpdateSuggestions(nil, "", token)
1661 return
1662
1663 case entity.OmniboxInitialBehaviorMostVisited, entity.OmniboxInitialBehaviorRecent, "":
1664 // Run history fetch and favorite URL fetch in parallel
1665 type historyResult struct {
1666 results []*entity.HistoryEntry
1667 err error
1668 }
1669 historyCh := make(chan historyResult, 1)
1670 favCh := make(chan map[string]struct{}, 1)
1671
1672 go func() {
1673 var (
1674 results []*entity.HistoryEntry
1675 err error
1676 )
1677 if initialBehavior == entity.OmniboxInitialBehaviorMostVisited {
1678 if mostVisitedDays == 0 {
1679 results, err = o.historyUC.GetMostVisited(ctx, 0)
1680 } else {
1681 results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays)
1682 }
1683 if err == nil && len(results) > initialLimit {
1684 results = results[:initialLimit]
1685 }
1686 } else {
1687 results, err = o.historyUC.GetRecent(ctx, initialLimit, 0)
1688 }
1689 historyCh <- historyResult{results, err}
1690 }()
1691
1692 go func() {
1693 favCh <- o.getFavoriteURLs(ctx)
1694 }()
1695
1696 // Wait for both results
1697 hr := <-historyCh
1698 favoriteURLs := <-favCh
1699
1700 if hr.err != nil {
1701 log.Error().Err(hr.err).Msg("failed to load recent history")
1702 return
1703 }
1704 if hr.results == nil {
1705 return
1706 }
1707
1708 suggestions = make([]Suggestion, 0, len(hr.results))
1709 for _, r := range hr.results {
1710 _, isFav := favoriteURLs[r.URL]
1711 suggestions = append(suggestions, Suggestion{
1712 URL: r.URL,
1713 Title: r.Title,
1714 IsFavorite: isFav,
1715 })
1716 }
1717 }
1718
1719 o.idleAddUpdateSuggestions(suggestions, "", token)
1720 }()
1721}
1722
1723// loadFavorites loads favorites, optionally filtered by query.
1724func (o *Omnibox) loadFavorites(query string, token uint64) {
1725 if o.favoritesUC == nil {
1726 return
1727 }
1728
1729 go func() {
1730 ctx := o.ctx
1731 log := logging.FromContext(ctx)
1732 results, err := o.favoritesUC.FilterForOmnibox(ctx, query)
1733 if err != nil {
1734 log.Error().Err(err).Msg("failed to load favorites")
1735 return
1736 }
1737
1738 favorites := make([]Favorite, 0, len(results))
1739 for i, r := range results {
1740 favorites = append(favorites, Favorite{
1741 ID: int64(r.ID),
1742 URL: r.URL,
1743 Title: r.Title,
1744 Position: i,
1745 })
1746 }
1747
1748 o.idleAddUpdateFavorites(favorites, query, token)
1749 }()
1750}
1751
1752func (o *Omnibox) loadBangSuggestions(query string, token uint64) {
1753 if o.shortcutsUC == nil {
1754 return
1755 }
1756 output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query})
1757 suggestions := make([]BangSuggestion, len(output.Suggestions))
1758 for i, s := range output.Suggestions {
1759 suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
1760 }
1761 o.idleAddUpdateBangSuggestions(suggestions, query, token)
1762}
1763
1764func (o *Omnibox) updateBangDetection(query string, token uint64) {
1765 if o.shortcutsUC == nil {
1766 o.clearDetectedBang(query, token)
1767 return
1768 }
1769 output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
1770 if output.Key == "" {
1771 o.clearDetectedBang(query, token)
1772 return
1773 }
1774
1775 o.setDetectedBang(output.Key, output.Description, query, token)
1776}
1777
1778func (o *Omnibox) clearBangState() {
1779 o.mu.Lock()
1780 o.bangMode = false
1781 o.bangSuggestions = nil
1782 o.mu.Unlock()
1783
1784 o.mu.RLock()
1785 query := o.realInput
1786 token := o.searchToken
1787 o.mu.RUnlock()
1788 o.clearDetectedBang(query, token)
1789}
1790
1791func (o *Omnibox) setDetectedBang(key, description, query string, token uint64) {
1792 o.mu.Lock()
1793 o.detectedBang = key
1794 o.mu.Unlock()
1795
1796 if o.bangBadge == nil {
1797 return
1798 }
1799 label := description
1800 var cb glib.SourceFunc = func(uintptr) bool {
1801 if !o.isBangUpdateCurrent(query, token) {
1802 return false
1803 }
1804 o.entry.AddCssClass("omnibox-entry-bang-active")
1805 o.bangBadge.SetText(label)
1806 o.bangBadge.SetVisible(true)
1807 return false
1808 }
1809 glib.IdleAdd(&cb, 0)
1810}
1811
1812func (o *Omnibox) clearDetectedBang(query string, token uint64) {
1813 o.mu.Lock()
1814 o.detectedBang = ""
1815 o.mu.Unlock()
1816
1817 if o.bangBadge == nil {
1818 return
1819 }
1820 var cb glib.SourceFunc = func(uintptr) bool {
1821 if !o.isBangUpdateCurrent(query, token) {
1822 return false
1823 }
1824 o.entry.RemoveCssClass("omnibox-entry-bang-active")
1825 o.bangBadge.SetVisible(false)
1826 return false
1827 }
1828 glib.IdleAdd(&cb, 0)
1829}
1830
1831func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion, query string, token uint64) {
1832 var cb glib.SourceFunc = func(uintptr) bool {
1833 if !o.isBangUpdateCurrent(query, token) {
1834 return false
1835 }
1836 o.updateBangSuggestions(suggestions)
1837 return false
1838 }
1839 glib.IdleAdd(&cb, 0)
1840}
1841
1842func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1843 o.mu.Lock()
1844 o.bangMode = true
1845 o.bangSuggestions = suggestions
1846 o.selectedIndex = -1
1847 o.hasNavigated = false
1848 o.mu.Unlock()
1849
1850 o.rebuildList()
1851
1852 rowCount := len(suggestions)
1853 o.setResultsContainerState(rowCount)
1854 o.resizeAndCenter(rowCount)
1855}
1856
1857// updateSuggestions updates the list with history suggestions.
1858// The query parameter is the search query that triggered this update.
1859func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1860 o.mu.Lock()
1861 o.bangMode = false
1862 o.bangSuggestions = nil
1863 o.suggestions = suggestions
1864 o.selectedIndex = -1
1865 o.mu.Unlock()
1866
1867 o.rebuildList()
1868
1869 // Hide scrolled window when there are no suggestions
1870 rowCount := len(suggestions)
1871 o.setResultsContainerState(rowCount)
1872 o.resizeAndCenter(rowCount)
1873
1874 if rowCount > 0 {
1875 o.updateGhostFromSelectionWithInput(query)
1876 } else {
1877 o.clearGhostTextIfInput(query)
1878 }
1879}
1880
1881// updateFavorites updates the list with favorites.
1882// The query parameter is the search query that triggered this update.
1883func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1884 o.mu.Lock()
1885 o.bangMode = false
1886 o.bangSuggestions = nil
1887 o.favorites = favorites
1888 o.selectedIndex = -1
1889 o.mu.Unlock()
1890
1891 o.rebuildList()
1892
1893 // Hide scrolled window when there are no favorites
1894 rowCount := len(favorites)
1895 o.setResultsContainerState(rowCount)
1896 o.resizeAndCenter(rowCount)
1897
1898 if rowCount > 0 {
1899 o.updateGhostFromSelectionWithInput(query)
1900 } else {
1901 o.clearGhostTextIfInput(query)
1902 }
1903}
1904
1905// rebuildList rebuilds the ListBox contents.
1906func (o *Omnibox) rebuildList() {
1907 // Clear existing rows
1908 o.listBox.RemoveAll()
1909
1910 o.mu.RLock()
1911 mode := o.viewMode
1912 suggestions := o.suggestions
1913 favorites := o.favorites
1914 bangMode := o.bangMode
1915 bangSuggestions := o.bangSuggestions
1916 o.mu.RUnlock()
1917
1918 if bangMode {
1919 for i, b := range bangSuggestions {
1920 row := o.createBangRow(b, i)
1921 if row != nil {
1922 o.listBox.Append(&row.Widget)
1923 }
1924 }
1925 } else if mode == ViewModeHistory {
1926 for i, s := range suggestions {
1927 row := o.createSuggestionRow(s, i)
1928 if row != nil {
1929 o.listBox.Append(&row.Widget)
1930 }
1931 }
1932 } else {
1933 for i, f := range favorites {
1934 row := o.createFavoriteRow(f, i)
1935 if row != nil {
1936 o.listBox.Append(&row.Widget)
1937 }
1938 }
1939 }
1940
1941 // Schedule measurement if not yet cached and we have rows
1942 if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
1943 var cb glib.SourceFunc = func(uintptr) bool {
1944 if o.parentOverlay == nil {
1945 return false
1946 }
1947 width := o.parentOverlay.GetAllocatedWidth()
1948 if width <= 0 {
1949 return false // Overlay not allocated yet, skip
1950 }
1951 forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
1952 if o.measureComponentHeights(forWidth) {
1953 // Re-trigger resize with accurate measurements
1954 o.mu.RLock()
1955 var count int
1956 if o.bangMode {
1957 count = len(o.bangSuggestions)
1958 } else if o.viewMode == ViewModeHistory {
1959 count = len(o.suggestions)
1960 } else {
1961 count = len(o.favorites)
1962 }
1963 o.mu.RUnlock()
1964 o.measureAndResize(forWidth, count)
1965 }
1966 return false
1967 }
1968 glib.IdleAdd(&cb, 0)
1969 }
1970}
1971
1972// createFaviconImage creates a favicon image with async loading from cache.
1973func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1974 favicon := gtk.NewImage()
1975 if favicon == nil {
1976 return nil
1977 }
1978 favicon.SetFromIconName(&fallbackIcon)
1979 favicon.SetPixelSize(int(16 * o.uiScale))
1980 favicon.AddCssClass("omnibox-favicon")
1981
1982 // Async load favicon from cache
1983 if o.faviconAdapter != nil && rawURL != "" {
1984 o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
1985 if texture != nil {
1986 var cb glib.SourceFunc = func(data uintptr) bool {
1987 favicon.SetFromPaintable(texture)
1988 return false
1989 }
1990 glib.IdleAdd(&cb, 0)
1991 }
1992 })
1993 }
1994
1995 return favicon
1996}
1997
1998const favoriteStarBaseSize = 18
1999const favoriteStarSlotClass = "omnibox-favorite-star-slot"
2000
2001func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) }
2002
2003func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite }
2004
2005func clearBoxChildren(box *gtk.Box) {
2006 if box == nil {
2007 return
2008 }
2009 for child := box.GetFirstChild(); child != nil; {
2010 next := child.GetNextSibling()
2011 box.Remove(child)
2012 child = next
2013 }
2014}
2015
2016func (o *Omnibox) syncFavoriteStarSlot(slot *gtk.Box, showFavoriteStar bool) {
2017 if slot == nil {
2018 return
2019 }
2020 clearBoxChildren(slot)
2021 if showFavoriteStar {
2022 if star := o.createFavoriteStarIcon(); star != nil {
2023 slot.Append(&star.Widget)
2024 }
2025 }
2026 slot.SetVisible(showFavoriteStar)
2027}
2028
2029func favoriteStarSlotForRow(row *gtk.ListBoxRow) *gtk.Box {
2030 if row == nil {
2031 return nil
2032 }
2033 hbox := row.GetChild()
2034 if hbox == nil {
2035 return nil
2036 }
2037 for child := hbox.GetFirstChild(); child != nil; child = child.GetNextSibling() {
2038 if child.HasCssClass(favoriteStarSlotClass) {
2039 return gtk.BoxNewFromInternalPtr(child.GoPointer())
2040 }
2041 }
2042 return nil
2043}
2044
2045func (o *Omnibox) createFavoriteStarIcon() *gtk.Image {
2046 star := gtk.NewImage()
2047 if star == nil {
2048 return nil
2049 }
2050 iconName := "starred-symbolic"
2051 star.SetFromIconName(&iconName)
2052 star.SetPixelSize(favoriteStarSize(o.uiScale))
2053 star.AddCssClass("omnibox-favorite-star")
2054 star.SetValign(gtk.AlignCenterValue)
2055 return star
2056}
2057
2058func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) {
2059 displayTitle := title
2060 if displayTitle == "" {
2061 displayTitle = displayURL
2062 }
2063
2064 titleLabel := gtk.NewLabel(nil)
2065 if titleLabel != nil {
2066 titleLabel.SetText(displayTitle)
2067 titleLabel.AddCssClass("omnibox-suggestion-title")
2068 titleLabel.SetHalign(gtk.AlignStartValue)
2069 titleLabel.SetEllipsize(pango.EllipsizeEndValue)
2070 textBox.Append(&titleLabel.Widget)
2071 }
2072
2073 if title != "" && title != displayURL {
2074 urlLabel := gtk.NewLabel(nil)
2075 if urlLabel != nil {
2076 urlLabel.SetText(displayURL)
2077 urlLabel.AddCssClass("omnibox-suggestion-url")
2078 urlLabel.SetHalign(gtk.AlignStartValue)
2079 urlLabel.SetEllipsize(pango.EllipsizeEndValue)
2080 textBox.Append(&urlLabel.Widget)
2081 }
2082 }
2083}
2084
2085func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) {
2086 starSlot := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
2087 if starSlot != nil {
2088 starSlot.AddCssClass(favoriteStarSlotClass)
2089 starSlot.SetValign(gtk.AlignCenterValue)
2090 o.syncFavoriteStarSlot(starSlot, showFavoriteStar)
2091 hbox.Append(&starSlot.Widget)
2092 }
2093
2094 const maxShortcutIndex = 9
2095 if index > maxShortcutIndex {
2096 return
2097 }
2098
2099 shortcutLabel := gtk.NewLabel(nil)
2100 if shortcutLabel == nil {
2101 return
2102 }
2103 if index < maxShortcutIndex {
2104 shortcutLabel.SetText(formatShortcut(index + 1))
2105 } else {
2106 shortcutLabel.SetText("Ctrl+0")
2107 }
2108 shortcutLabel.AddCssClass("omnibox-shortcut-badge")
2109 shortcutLabel.SetValign(gtk.AlignCenterValue)
2110 hbox.Append(&shortcutLabel.Widget)
2111}
2112
2113// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
2114// Uses rawURL for both favicon fetching and display.
2115func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
2116 return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index)
2117}
2118
2119// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
2120// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
2121func (o *Omnibox) createRowWithFaviconURL(
2122 displayURL, title, faviconURL, fallbackIcon string,
2123 showFavoriteStar bool,
2124 index int,
2125) *gtk.ListBoxRow {
2126 row := gtk.NewListBoxRow()
2127 if row == nil {
2128 return nil
2129 }
2130 row.AddCssClass("omnibox-row")
2131
2132 const rowSpacing = 8
2133 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
2134 if hbox == nil {
2135 return nil
2136 }
2137 hbox.SetHexpand(true)
2138
2139 // Favicon image (vertically centered)
2140 if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
2141 favicon.SetValign(gtk.AlignCenterValue)
2142 hbox.Append(&favicon.Widget)
2143 }
2144
2145 // Vertical box for title + URL
2146 textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
2147 if textBox == nil {
2148 return nil
2149 }
2150 textBox.SetHexpand(true)
2151 textBox.SetValign(gtk.AlignCenterValue)
2152
2153 o.appendSuggestionTitleAndURL(textBox, title, displayURL)
2154
2155 hbox.Append(&textBox.Widget)
2156 o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index)
2157
2158 row.SetChild(&hbox.Widget)
2159 return row
2160}
2161
2162func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bool) bool {
2163 return realInput == "" && !hasGhostText && !hasNavigated
2164}
2165
2166// createSuggestionRow creates a ListBoxRow for a suggestion.
2167func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
2168 row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index)
2169 if row != nil && s.IsFavorite {
2170 row.AddCssClass("omnibox-row-favorite")
2171 }
2172 return row
2173}
2174
2175// createFavoriteRow creates a ListBoxRow for a favorite.
2176func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
2177 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
2178}
2179
2180func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
2181 // Pass description as URL param (displayed as secondary label) and empty
2182 // faviconURL to skip async favicon fetching - bang rows use static icon only
2183 row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index)
2184 if row != nil {
2185 row.AddCssClass("omnibox-row-bang")
2186 }
2187 return row
2188}
2189
2190// selectIndex selects a row by index.
2191func (o *Omnibox) selectIndex(index int) {
2192 o.mu.Lock()
2193 o.selectedIndex = index
2194 o.mu.Unlock()
2195
2196 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
2197 // which also acquires the mutex, causing deadlock
2198 row := o.listBox.GetRowAtIndex(index)
2199 if row != nil {
2200 o.listBox.SelectRow(row)
2201 return
2202 }
2203
2204 o.updateGhostFromSelection()
2205}
2206
2207// selectNext moves selection down.
2208func (o *Omnibox) selectNext() {
2209 o.mu.Lock()
2210 current := o.selectedIndex
2211 mode := o.viewMode
2212 bangMode := o.bangMode
2213 maxVisible := o.effectiveMaxRows()
2214 var maxIndex int
2215 if bangMode {
2216 maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2217 } else if mode == ViewModeHistory {
2218 maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2219 } else {
2220 maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2221 }
2222 o.hasNavigated = true // User is navigating with arrow keys
2223 o.mu.Unlock()
2224
2225 if maxIndex < 0 {
2226 return
2227 }
2228
2229 newIndex := current + 1
2230 if newIndex > maxIndex {
2231 newIndex = 0 // Wrap around
2232 }
2233 o.selectIndex(newIndex)
2234}
2235
2236// selectPrevious moves selection up.
2237func (o *Omnibox) selectPrevious() {
2238 o.mu.Lock()
2239 current := o.selectedIndex
2240 mode := o.viewMode
2241 bangMode := o.bangMode
2242 maxVisible := o.effectiveMaxRows()
2243 var maxIndex int
2244 if bangMode {
2245 maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2246 } else if mode == ViewModeHistory {
2247 maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2248 } else {
2249 maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2250 }
2251 o.hasNavigated = true // User is navigating with arrow keys
2252 o.mu.Unlock()
2253
2254 if maxIndex < 0 {
2255 return
2256 }
2257
2258 newIndex := current - 1
2259 if newIndex < 0 {
2260 newIndex = maxIndex // Wrap around
2261 }
2262 o.selectIndex(newIndex)
2263}
2264
2265// selectAndNavigate selects an index and navigates to it.
2266func (o *Omnibox) selectAndNavigate(index int) {
2267 o.selectIndex(index)
2268
2269 o.mu.RLock()
2270 mode := o.viewMode
2271 bangMode := o.bangMode
2272 suggestions := o.suggestions
2273 favorites := o.favorites
2274 o.mu.RUnlock()
2275 maxVisible := o.effectiveMaxRows()
2276
2277 if bangMode {
2278 o.navigateToSelected()
2279 return
2280 }
2281
2282 targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites)
2283 if targetURL == "" {
2284 return
2285 }
2286
2287 o.submitNavigation(targetURL)
2288}
2289
2290func (o *Omnibox) submitNavigation(targetURL string) {
2291 if targetURL == "" {
2292 return
2293 }
2294 if o.onNavigate == nil {
2295 logging.FromContext(o.ctx).Warn().Msg("omnibox navigate handler is nil")
2296 return
2297 }
2298 if err := o.onNavigate(o.ctx, targetURL); err != nil {
2299 logging.FromContext(o.ctx).Error().Err(err).Msg("omnibox navigation failed")
2300 if o.onToast != nil {
2301 o.onToast(o.ctx, "Unable to open URL", ToastError)
2302 }
2303 return
2304 }
2305 o.Hide(o.ctx)
2306}
2307
2308// navigateToSelected navigates to the currently selected item or typed URL.
2309// If the user typed a URL-like string, prioritize navigating to that directly.
2310func (o *Omnibox) navigateToSelected() {
2311 o.mu.RLock()
2312 mode := o.viewMode
2313 idx := o.selectedIndex
2314 suggestions := o.suggestions
2315 favorites := o.favorites
2316 bangMode := o.bangMode
2317 bangSuggestions := o.bangSuggestions
2318 o.mu.RUnlock()
2319
2320 entryText := o.entry.GetText()
2321
2322 if bangMode {
2323 // If user typed a full bang query, navigate using the bang URL.
2324 if o.shortcutsUC != nil {
2325 navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
2326 if navOutput.Valid {
2327 targetURL := o.buildURL(navOutput.Text)
2328 if targetURL == "" {
2329 return
2330 }
2331 o.submitNavigation(targetURL)
2332 return
2333 }
2334 }
2335
2336 // Otherwise, Enter autocompletes the selected bang.
2337 if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok {
2338 o.entry.SetText(bangText)
2339 o.entry.SetPosition(-1)
2340 return
2341 }
2342 return
2343 }
2344
2345 var targetURL string
2346 if idx >= 0 {
2347 // If user has selected a result, navigate to that result.
2348 targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
2349 } else {
2350 targetURL = o.buildURL(entryText)
2351 }
2352
2353 if targetURL == "" {
2354 return
2355 }
2356
2357 o.submitNavigation(targetURL)
2358}
2359
2360func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
2361 if mode == ViewModeHistory {
2362 visibleCount := visibleResultCount(len(suggestions), maxVisible)
2363 if idx >= 0 && idx < visibleCount {
2364 return suggestions[idx].URL
2365 }
2366 return ""
2367 }
2368 visibleCount := visibleResultCount(len(favorites), maxVisible)
2369 if idx >= 0 && idx < visibleCount {
2370 return favorites[idx].URL
2371 }
2372 return ""
2373}
2374
2375func shouldPreferTypedURLNavigation(entryText string) bool {
2376 entryText = strings.TrimSpace(entryText)
2377 if entryText == "" {
2378 return false
2379 }
2380 return url.LooksLikeURL(entryText)
2381}
2382
2383// toggleFavorite adds or removes the selected item from favorites.
2384// In History mode: adds the selected item to favorites
2385// In Favorites mode: removes the selected item from favorites
2386func (o *Omnibox) toggleFavorite() {
2387 log := logging.FromContext(o.ctx)
2388
2389 o.mu.RLock()
2390 mode := o.viewMode
2391 idx := o.selectedIndex
2392 suggestions := o.suggestions
2393 favorites := o.favorites
2394 o.mu.RUnlock()
2395
2396 if o.favoritesUC == nil {
2397 log.Warn().Msg("toggle favorite: favoritesUC is nil")
2398 return
2399 }
2400
2401 if mode == ViewModeHistory {
2402 // Toggle favorite in history mode
2403 if idx < 0 || idx >= len(suggestions) {
2404 log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection")
2405 return
2406 }
2407
2408 s := suggestions[idx]
2409 if s.URL == "" {
2410 log.Debug().Msg("toggle favorite: empty URL")
2411 return
2412 }
2413
2414 go func() {
2415 ctx := o.ctx
2416 goLog := logging.FromContext(ctx)
2417
2418 result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
2419 if err != nil {
2420 goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite")
2421 if o.onToast != nil {
2422 msg := "Failed to add favorite"
2423 if s.IsFavorite {
2424 msg = "Failed to remove favorite"
2425 }
2426 cb := glib.SourceFunc(func(_ uintptr) bool {
2427 o.onToast(ctx, msg, ToastError)
2428 return false
2429 })
2430 glib.IdleAdd(&cb, 0)
2431 }
2432 return
2433 }
2434
2435 // Update suggestion state
2436 o.mu.Lock()
2437 if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {
2438 o.suggestions[idx].IsFavorite = result.Added
2439 }
2440 o.mu.Unlock()
2441
2442 // Update row CSS and show toast on GTK main thread
2443 cb := glib.SourceFunc(func(_ uintptr) bool {
2444 o.updateRowFavoriteIndicator(idx, s.URL, result.Added)
2445 if o.onToast != nil {
2446 o.onToast(ctx, result.Message, ToastSuccess)
2447 }
2448 return false
2449 })
2450 glib.IdleAdd(&cb, 0)
2451 }()
2452 } else {
2453 // Remove from favorites
2454 if idx < 0 || idx >= len(favorites) {
2455 log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
2456 return
2457 }
2458
2459 f := favorites[idx]
2460 if f.ID == 0 {
2461 log.Debug().Msg("remove favorite: invalid ID")
2462 return
2463 }
2464 entryText := o.entry.GetText()
2465
2466 go func() {
2467 ctx := o.ctx
2468 log := logging.FromContext(ctx)
2469
2470 log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")
2471
2472 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
2473 if err != nil {
2474 log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
2475 // Show error toast
2476 if o.onToast != nil {
2477 cb := glib.SourceFunc(func(_ uintptr) bool {
2478 o.onToast(ctx, "Failed to remove favorite", ToastError)
2479 return false
2480 })
2481 glib.IdleAdd(&cb, 0)
2482 }
2483 return
2484 }
2485
2486 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
2487
2488 // Refresh favorites list and show toast
2489 o.mu.RLock()
2490 currentToken := o.searchToken
2491 o.mu.RUnlock()
2492 o.loadFavorites(entryText, currentToken)
2493 if o.onToast != nil {
2494 cb := glib.SourceFunc(func(_ uintptr) bool {
2495 o.onToast(ctx, "Favorite removed", ToastSuccess)
2496 return false
2497 })
2498 glib.IdleAdd(&cb, 0)
2499 }
2500 }()
2501 }
2502}
2503
2504// updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
2505// Must be called from GTK main thread (via glib.IdleAdd).
2506func (o *Omnibox) updateRowFavoriteIndicator(index int, expectedURL string, isFavorite bool) {
2507 row := o.listBox.GetRowAtIndex(index)
2508 if row == nil {
2509 return
2510 }
2511
2512 o.mu.RLock()
2513 mode := o.viewMode
2514 suggestions := append([]Suggestion(nil), o.suggestions...)
2515 o.mu.RUnlock()
2516
2517 update := resolveFavoriteRowIndicatorUpdate(mode, index, expectedURL, suggestions, isFavorite)
2518 if !update.Apply {
2519 return
2520 }
2521
2522 if isFavorite {
2523 row.AddCssClass("omnibox-row-favorite")
2524 } else {
2525 row.RemoveCssClass("omnibox-row-favorite")
2526 }
2527 if slot := favoriteStarSlotForRow(row); slot != nil {
2528 o.syncFavoriteStarSlot(slot, isFavorite)
2529 slot.QueueDraw()
2530 if update.ShowStarSlot {
2531 slot.Show()
2532 }
2533 }
2534 if child := row.GetChild(); child != nil {
2535 child.QueueDraw()
2536 child.Show()
2537 }
2538 row.QueueDraw()
2539 row.Show()
2540}
2541
2542func resolveFavoriteRowIndicatorUpdate(
2543 mode ViewMode,
2544 index int,
2545 expectedURL string,
2546 suggestions []Suggestion,
2547 isFavorite bool,
2548) favoriteRowIndicatorUpdate {
2549 if mode != ViewModeHistory {
2550 return favoriteRowIndicatorUpdate{}
2551 }
2552 if index < 0 || index >= len(suggestions) {
2553 return favoriteRowIndicatorUpdate{}
2554 }
2555 if suggestions[index].URL != expectedURL {
2556 return favoriteRowIndicatorUpdate{}
2557 }
2558 return favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: isFavorite}
2559}
2560
2561// yankSelectedURL copies the URL of the selected item to clipboard.
2562func (o *Omnibox) yankSelectedURL() {
2563 log := logging.FromContext(o.ctx)
2564
2565 if o.copyURLUC == nil {
2566 log.Warn().Msg("yank URL: copy URL use case is nil")
2567 return
2568 }
2569
2570 o.mu.RLock()
2571 mode := o.viewMode
2572 idx := o.selectedIndex
2573 suggestions := o.suggestions
2574 favorites := o.favorites
2575 o.mu.RUnlock()
2576
2577 var selectedURL string
2578 if mode == ViewModeHistory {
2579 if idx < 0 || idx >= len(suggestions) {
2580 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
2581 return
2582 }
2583 selectedURL = suggestions[idx].URL
2584 } else {
2585 if idx < 0 || idx >= len(favorites) {
2586 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
2587 return
2588 }
2589 selectedURL = favorites[idx].URL
2590 }
2591
2592 if selectedURL == "" {
2593 log.Debug().Msg("yank URL: empty URL")
2594 return
2595 }
2596
2597 go func() {
2598 ctx := o.ctx
2599 if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
2600 return // Use case already logs the error
2601 }
2602
2603 // Show toast notification on success (must run on GTK main thread)
2604 if o.onToast != nil {
2605 cb := glib.SourceFunc(func(_ uintptr) bool {
2606 o.onToast(ctx, "URL copied", ToastSuccess)
2607 return false // Don't repeat
2608 })
2609 glib.IdleAdd(&cb, 0)
2610 }
2611 }()
2612}
2613
2614// buildURL constructs a URL from text, handling search shortcuts.
2615func (o *Omnibox) buildURL(text string) string {
2616 var shortcutURLs map[string]string
2617 if o.shortcutsUC != nil {
2618 shortcutURLs = o.shortcutsUC.ShortcutURLs()
2619 }
2620 return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
2621}
2622
2623// toggleViewMode switches between history and favorites.
2624func (o *Omnibox) toggleViewMode() {
2625 o.mu.RLock()
2626 current := o.viewMode
2627 o.mu.RUnlock()
2628
2629 if current == ViewModeHistory {
2630 o.setViewMode(ViewModeFavorites)
2631 } else {
2632 o.setViewMode(ViewModeHistory)
2633 }
2634}
2635
2636// setViewMode changes the view mode and updates UI.
2637func (o *Omnibox) setViewMode(mode ViewMode) {
2638 o.mu.Lock()
2639 o.viewMode = mode
2640 o.mu.Unlock()
2641
2642 // Update header button styling
2643 if mode == ViewModeHistory {
2644 o.historyBtn.AddCssClass("omnibox-header-active")
2645 o.favoritesBtn.RemoveCssClass("omnibox-header-active")
2646 } else {
2647 o.historyBtn.RemoveCssClass("omnibox-header-active")
2648 o.favoritesBtn.AddCssClass("omnibox-header-active")
2649 }
2650
2651 // Reload data
2652 o.performSearch()
2653}
2654
2655// Show opens the omnibox with optional initial query.
2656func (o *Omnibox) Show(ctx context.Context, query string) {
2657 log := logging.FromContext(ctx)
2658 log.Debug().Str("query", query).Msg("showing omnibox")
2659
2660 o.mu.Lock()
2661 if o.visible {
2662 o.mu.Unlock()
2663 return
2664 }
2665 o.visible = true
2666 // Initialize ghost text state
2667 o.realInput = query
2668 o.ghostSuffix = ""
2669
2670 o.insertCompletion = false
2671 o.mu.Unlock()
2672
2673 // Set initial query
2674 o.entry.SetText(query)
2675 o.entry.SelectRegion(-1, -1)
2676
2677 // Keep the results container hidden until real rows arrive.
2678 // Pre-showing an empty, expanding scroller renders a dead area in standalone mode.
2679 if o.scrolledWin != nil {
2680 o.setResultsContainerState(0)
2681 // Reset content height constraints - will be updated when results arrive
2682 o.scrolledWin.SetMinContentHeight(-1)
2683 o.scrolledWin.SetMaxContentHeight(0)
2684 }
2685
2686 // Calculate dimensions using shared helper
2687 parentWidth, parentHeight := 0, 0
2688 if o.parentOverlay != nil {
2689 parentWidth = o.parentOverlay.GetAllocatedWidth()
2690 parentHeight = o.parentOverlay.GetAllocatedHeight()
2691 }
2692 width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2693 log.Debug().
2694 Int("parentWidth", parentWidth).
2695 Int("parentHeight", parentHeight).
2696 Int("width", width).
2697 Int("marginTop", marginTop).
2698 Int("fixedWidth", o.sizeCfg.FixedWidth).
2699 Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin).
2700 Int("fixedTopMargin", o.sizeCfg.FixedTopMargin).
2701 Float64("widthPct", o.sizeCfg.WidthPct).
2702 Float64("topMarginPct", o.sizeCfg.TopMarginPct).
2703 Msg("omnibox geometry decision")
2704
2705 o.mainBox.SetSizeRequest(width, -1)
2706 o.outerBox.SetMarginTop(marginTop)
2707
2708 // Show the omnibox
2709 o.outerBox.SetVisible(true)
2710
2711 // Trigger initial resize (will be updated when results arrive)
2712 o.resizeAndCenter(0)
2713
2714 // Focus the entry
2715 o.entry.GrabFocus()
2716 o.resetSearchSessionState()
2717
2718 // Load initial data (may update size later if results found)
2719 o.performSearch()
2720}
2721
2722// Hide closes the omnibox.
2723func (o *Omnibox) Hide(ctx context.Context) {
2724 log := logging.FromContext(ctx)
2725 log.Debug().Msg("hiding omnibox")
2726
2727 o.mu.Lock()
2728 if !o.visible {
2729 o.mu.Unlock()
2730 return
2731 }
2732 o.visible = false
2733 o.mu.Unlock()
2734
2735 // Cancel any pending search
2736 o.debounceMu.Lock()
2737 if o.debounceTimer != nil {
2738 o.debounceTimer.Stop()
2739 o.debounceTimer = nil
2740 }
2741 o.debounceMu.Unlock()
2742
2743 // Hide omnibox
2744 o.outerBox.SetVisible(false)
2745
2746 // Clear state
2747 o.clearBangState()
2748 o.clearGhostText()
2749 o.entry.SetText("")
2750 o.listBox.RemoveAll()
2751
2752 // Reset state
2753 o.mu.Lock()
2754 o.realInput = ""
2755 o.insertCompletion = false
2756 o.mu.Unlock()
2757 o.resetSearchSessionState()
2758
2759 if o.onClose != nil {
2760 o.onClose()
2761 }
2762}
2763
2764func (o *Omnibox) resetSearchSessionState() {
2765 o.debounceMu.Lock()
2766 o.lastQuery = ""
2767 o.debounceMu.Unlock()
2768}
2769
2770// Toggle shows if hidden, hides if visible.
2771func (o *Omnibox) Toggle(ctx context.Context) {
2772 o.mu.RLock()
2773 visible := o.visible
2774 o.mu.RUnlock()
2775
2776 if visible {
2777 o.Hide(ctx)
2778 } else {
2779 o.Show(ctx, "")
2780 }
2781}
2782
2783// IsVisible returns whether the omnibox is currently shown.
2784func (o *Omnibox) IsVisible() bool {
2785 o.mu.RLock()
2786 defer o.mu.RUnlock()
2787 return o.visible
2788}
2789
2790// SetOnNavigate sets the callback for URL navigation.
2791func (o *Omnibox) SetOnNavigate(fn func(ctx context.Context, url string) error) {
2792 o.onNavigate = fn
2793}
2794
2795// SetOnClose sets the callback for omnibox close events.
2796func (o *Omnibox) SetOnClose(fn func()) {
2797 o.onClose = fn
2798}
2799
2800// UpdateZoomIndicator updates the zoom percentage display.
2801// Shows the indicator when zoom != 100%, hides it when at 100%.
2802func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2803 var cb glib.SourceFunc = func(data uintptr) bool {
2804 if o.zoomLabel == nil {
2805 return false
2806 }
2807 if factor == 1.0 {
2808 o.zoomLabel.SetVisible(false)
2809 } else {
2810 percentage := int(factor * 100)
2811 o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
2812 o.zoomLabel.SetVisible(true)
2813 }
2814 return false
2815 }
2816 glib.IdleAdd(&cb, 0)
2817}
2818
2819// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2820func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) {
2821 var cb glib.SourceFunc = func(data uintptr) bool {
2822 if !o.isSearchTokenCurrent(token) {
2823 return false
2824 }
2825 o.updateSuggestions(suggestions, query)
2826 return false // One-shot callback
2827 }
2828 glib.IdleAdd(&cb, 0)
2829}
2830
2831// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
2832// Returns empty map on error to avoid blocking history display.
2833func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2834 if o.favoritesUC == nil {
2835 return make(map[string]struct{})
2836 }
2837 urls, err := o.favoritesUC.GetAllURLs(ctx)
2838 if err != nil {
2839 log := logging.FromContext(ctx)
2840 log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
2841 return make(map[string]struct{})
2842 }
2843 return urls
2844}
2845
2846// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2847func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) {
2848 var cb glib.SourceFunc = func(data uintptr) bool {
2849 if !o.isSearchTokenCurrent(token) {
2850 return false
2851 }
2852 o.updateFavorites(favorites, query)
2853 return false // One-shot callback, return false to remove source
2854 }
2855 glib.IdleAdd(&cb, 0)
2856}
2857
2858func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2859 o.mu.RLock()
2860 defer o.mu.RUnlock()
2861 return o.visible && o.searchToken == token
2862}
2863
2864func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool {
2865 o.mu.RLock()
2866 defer o.mu.RUnlock()
2867 return o.visible && o.searchToken == token && o.realInput == query
2868}
2869
2870// Widget returns the omnibox widget for embedding in an overlay.
2871func (o *Omnibox) Widget() *gtk.Widget {
2872 if o.outerBox == nil {
2873 return nil
2874 }
2875 return &o.outerBox.Widget
2876}
2877
2878// Destroy cleans up omnibox resources.
2879func (o *Omnibox) Destroy() {
2880 o.debounceMu.Lock()
2881 if o.debounceTimer != nil {
2882 o.debounceTimer.Stop()
2883 }
2884 o.debounceMu.Unlock()
2885
2886 o.mu.Lock()
2887 o.visible = false
2888 o.mu.Unlock()
2889
2890 o.parentOverlay = nil
2891 o.retainedCallbacks = nil
2892 o.clickOutsideController = nil
2893 o.clickOutsideCb = nil
2894 if o.idleCoalescer != nil {
2895 o.idleCoalescer.Destroy()
2896 }
2897
2898 if o.outerBox != nil {
2899 o.outerBox.Unparent()
2900 o.outerBox = nil
2901 }
2902}
2903
2904// formatShortcut formats a shortcut number.
2905func formatShortcut(n int) string {
2906 return "Ctrl+" + string(rune('0'+n))
2907}
2908
2909// measureWidgetHeight returns the natural height of a widget for the given width.
2910// Returns 0 if widget is nil or measurement fails.
2911func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
2912 if widget == nil {
2913 return 0
2914 }
2915 var minHeight, naturalHeight, minBaseline, naturalBaseline int
2916 widget.Measure(
2917 gtk.OrientationVerticalValue,
2918 forWidth,
2919 &minHeight,
2920 &naturalHeight,
2921 &minBaseline,
2922 &naturalBaseline,
2923 )
2924 return naturalHeight
2925}
2926
2927// measureComponentHeights measures and caches all component heights.
2928// Must be called on GTK main thread after widgets have been laid out.
2929func (o *Omnibox) measureComponentHeights(forWidth int) bool {
2930 if o.headerBox == nil || o.entry == nil || o.listBox == nil {
2931 return false
2932 }
2933
2934 headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
2935 entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
2936
2937 // Measure first row if it exists
2938 var rowH int
2939 if row := o.listBox.GetRowAtIndex(0); row != nil {
2940 rowH = measureWidgetHeight(&row.Widget, forWidth)
2941 }
2942
2943 // Validate measurements (header and entry must be positive)
2944 if headerH <= 0 || entryH <= 0 {
2945 return false
2946 }
2947
2948 o.measuredHeights.header = headerH
2949 o.measuredHeights.entry = entryH
2950 if rowH > 0 {
2951 o.measuredHeights.singleRow = rowH
2952 o.measuredHeights.valid = true
2953 }
2954 return true
2955}
2956
2957// errNilWidget creates an error for nil widget creation.
2958func errNilWidget(name string) error {
2959 return &widgetError{name: name}
2960}
2961
2962type widgetError struct {
2963 name string
2964}
2965
2966func (e *widgetError) Error() string {
2967 return "failed to create widget: " + e.name
2968}