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