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 []interface{}
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 o.restoreEntryToRealInput()
752 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
753 if row == nil {
754 o.mu.Lock()
755 o.selectedIndex = -1
756 o.mu.Unlock()
757 } else {
758 o.mu.Lock()
759 o.selectedIndex = row.GetIndex()
760 o.mu.Unlock()
761 }
762 o.updateGhostFromSelection()
763 }
764 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
765 o.listBox.ConnectRowSelected(&rowSelectedCb)
766
767 // Handle row activation (click or Enter) - navigate directly to the URL
768 rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
769 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
770 if row == nil {
771 return
772 }
773 idx := row.GetIndex()
774 o.mu.RLock()
775 mode := o.viewMode
776 bangMode := o.bangMode
777 bangSuggestions := o.bangSuggestions
778 suggestions := o.suggestions
779 favorites := o.favorites
780 o.mu.RUnlock()
781
782 // Don't navigate in bang mode - activating a bang should fill it in
783 if bangMode {
784 if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok && o.entry != nil {
785 o.entry.SetText(bangText)
786 o.entry.SetPosition(-1)
787 }
788 return
789 }
790
791 targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
792
793 if targetURL != "" {
794 o.submitNavigation(targetURL)
795 }
796 }
797 o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
798 o.listBox.ConnectRowActivated(&rowActivatedCb)
799
800 o.setupListHoverSelection()
801
802 return nil
803}
804
805func (o *Omnibox) setupListHoverSelection() {
806 motionCtrl := gtk.NewEventControllerMotion()
807 if motionCtrl == nil {
808 logging.FromContext(o.ctx).Error().Msg("failed to create motion event controller")
809 return
810 }
811
812 motionCb := func(_ gtk.EventControllerMotion, _ float64, y float64) {
813 o.handleListHoverSelection(y)
814 }
815 o.retainedCallbacks = append(o.retainedCallbacks, motionCb)
816 motionCtrl.ConnectMotion(&motionCb)
817 o.listBox.AddController(&motionCtrl.EventController)
818}
819
820func (o *Omnibox) handleListHoverSelection(y float64) {
821 o.mu.RLock()
822 realInput := o.realInput
823 hasGhostText := o.ghostSuffix != ""
824 hasNavigated := o.hasNavigated
825 selectedIndex := o.selectedIndex
826 o.mu.RUnlock()
827
828 if !shouldPromoteHoverSelection(realInput, hasGhostText, hasNavigated) {
829 return
830 }
831
832 row := o.listBox.GetRowAtY(int(y))
833 if row == nil {
834 return
835 }
836
837 index := row.GetIndex()
838 if index < 0 || index == selectedIndex {
839 return
840 }
841 o.selectIndex(index)
842}
843
844func (o *Omnibox) assembleWidgets() {
845 o.headerBox.Append(&o.historyBtn.Widget)
846 o.headerBox.Append(&o.favoritesBtn.Widget)
847
848 endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
849 if endBox != nil {
850 endBox.SetHexpand(true)
851 endBox.SetHalign(gtk.AlignEndValue)
852 if o.initialBehaviorBadge != nil {
853 endBox.Append(&o.initialBehaviorBadge.Widget)
854 }
855 if o.bangBadge != nil {
856 endBox.Append(&o.bangBadge.Widget)
857 }
858 if o.zoomLabel != nil {
859 endBox.Append(&o.zoomLabel.Widget)
860 }
861 o.headerBox.Append(&endBox.Widget)
862 }
863
864 o.scrolledWin.SetChild(&o.listBox.Widget)
865 o.mainBox.Append(&o.headerBox.Widget)
866 o.mainBox.Append(&o.entry.Widget)
867 o.mainBox.Append(&o.scrolledWin.Widget)
868 o.outerBox.Append(&o.mainBox.Widget)
869}
870
871// setupEntryChanged wires entry text changes to debounced search.
872func (o *Omnibox) setupEntryChanged() {
873 changedCb := func(_ gtk.SearchEntry) {
874 o.onEntryChanged()
875 }
876 o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
877 o.entry.ConnectSearchChanged(&changedCb)
878}
879
880// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
881func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
882 if onFocusIn == nil && onFocusOut == nil {
883 return
884 }
885
886 focusController := gtk.NewEventControllerFocus()
887 if focusController == nil {
888 return
889 }
890
891 if onFocusIn != nil {
892 entry := o.entry // Capture for closure
893 focusInCb := func(_ gtk.EventControllerFocus) {
894 onFocusIn(entry)
895 }
896 o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
897 focusController.ConnectEnter(&focusInCb)
898 }
899
900 if onFocusOut != nil {
901 focusOutCb := func(_ gtk.EventControllerFocus) {
902 onFocusOut()
903 }
904 o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
905 focusController.ConnectLeave(&focusOutCb)
906 }
907
908 o.entry.AddController(&focusController.EventController)
909}
910
911// hasGhost returns whether ghost text is currently visible.
912func (o *Omnibox) hasGhost() bool {
913 o.mu.RLock()
914 defer o.mu.RUnlock()
915 return o.ghostSuffix != ""
916}
917
918// hasUserNavigated returns whether user has navigated with arrow keys.
919func (o *Omnibox) hasUserNavigated() bool {
920 o.mu.RLock()
921 defer o.mu.RUnlock()
922 return o.hasNavigated
923}
924
925// handleKeyPress processes keyboard events.
926// Returns true if the event was handled.
927func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
928 o.updateInsertCompletionFromKey(keyval)
929
930 ctrl := state&gdk.ControlMaskValue != 0
931
932 switch keyval {
933 case uint(gdk.KEY_r), uint(gdk.KEY_R):
934 if ctrl {
935 return o.toggleInitialBehaviorPreference()
936 }
937
938 case uint(gdk.KEY_Escape):
939 return o.handleEscapeKeyPress()
940
941 case uint(gdk.KEY_l), uint(gdk.KEY_L):
942 // Ctrl+L toggles omnibox (hides when visible)
943 if ctrl {
944 o.Hide(o.ctx)
945 return true
946 }
947
948 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
949 o.navigateToSelected()
950 return true
951
952 case uint(gdk.KEY_Up):
953 o.selectPrevious()
954 return true
955
956 case uint(gdk.KEY_Down):
957 o.selectNext()
958 return true
959
960 case uint(gdk.KEY_Tab):
961 // Tab accepts ghost text if present, otherwise toggles view mode
962 if o.hasGhost() {
963 o.acceptGhostCompletion()
964 return true
965 }
966 o.toggleViewMode()
967 return true
968
969 case uint(gdk.KEY_Right):
970 // Arrow Right accepts ghost text if present
971 if o.hasGhost() {
972 o.acceptGhostCompletion()
973 return true
974 }
975 return false // Let entry handle normal cursor movement
976
977 case uint(gdk.KEY_space):
978 // Space toggles favorite only if user has navigated with arrow keys
979 if o.hasUserNavigated() {
980 o.toggleFavorite()
981 return true
982 }
983 return false // Let entry handle space for typing
984
985 case uint(gdk.KEY_y):
986 // 'y' yanks (copies) the selected URL to clipboard when navigating
987 if o.hasUserNavigated() {
988 o.yankSelectedURL()
989 return true
990 }
991 return false // Let entry handle 'y' for typing
992
993 default:
994 return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
995 }
996
997 return false // Let entry handle the key
998}
999
1000func isDeletionKey(keyval uint) bool {
1001 switch keyval {
1002 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
1003 return true
1004 default:
1005 return false
1006 }
1007}
1008
1009// updateInsertCompletionFromKey sets whether the next text change should trigger
1010// ghost completion. Character insertion enables it; deletion disables it.
1011func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) {
1012 o.mu.Lock()
1013 o.insertCompletion = !isDeletionKey(keyval)
1014 o.mu.Unlock()
1015}
1016
1017// withGhostGuard executes fn while the isSettingGhost flag is set,
1018// so that onEntryChanged skips during programmatic text changes.
1019func (o *Omnibox) withGhostGuard(fn func()) {
1020 o.mu.Lock()
1021 o.isSettingGhost = true
1022 o.mu.Unlock()
1023
1024 fn()
1025
1026 o.mu.Lock()
1027 o.isSettingGhost = false
1028 o.mu.Unlock()
1029}
1030
1031// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
1032// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
1033func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
1034 if !ctrl {
1035 return false
1036 }
1037
1038 const tenthItemIndex = 9
1039
1040 // First try keyval (works for QWERTY layouts)
1041 switch keyval {
1042 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
1043 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
1044 idx := int(keyval - uint(gdk.KEY_1))
1045 o.selectAndNavigate(idx)
1046 return true
1047 case uint(gdk.KEY_0):
1048 o.selectAndNavigate(tenthItemIndex)
1049 return true
1050 }
1051
1052 // Fallback: use hardware keycode for non-QWERTY layouts
1053 // This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
1054 if index, ok := input.KeycodeToDigitIndex(keycode); ok {
1055 log := logging.FromContext(o.ctx)
1056 log.Debug().
1057 Uint("keycode", keycode).
1058 Int("index", index).
1059 Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
1060 o.selectAndNavigate(index)
1061 return true
1062 }
1063
1064 return false
1065}
1066
1067// onEntryChanged handles text input changes with debouncing.
1068// When this fires, GTK has already processed the keystroke — if ghost text was
1069// selected and the user typed, the selection was replaced naturally by GTK.
1070// We must NOT call clearGhostText() here (that would delete real user text).
1071func (o *Omnibox) onEntryChanged() {
1072 o.mu.RLock()
1073 isSettingGhost := o.isSettingGhost
1074 o.mu.RUnlock()
1075 if isSettingGhost {
1076 return
1077 }
1078
1079 entryText := o.entry.GetText()
1080
1081 // Detect debounced echo from our own SetText in setGhostText.
1082 // search-changed is debounced by GtkSearchEntry, so it fires AFTER
1083 // the isSettingGhost guard is already off.
1084 o.mu.RLock()
1085 if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
1086 o.mu.RUnlock()
1087 return
1088 }
1089 o.mu.RUnlock()
1090
1091 log := logging.FromContext(o.ctx)
1092 if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
1093 o.withGhostGuard(func() {
1094 o.entry.SetText(trimmed)
1095 o.entry.SetPosition(-1)
1096 })
1097 entryText = trimmed
1098 }
1099
1100 // Reset ghost and selection state (the buffer is already correct — GTK handled it).
1101 o.mu.Lock()
1102 oldGhost := o.ghostSuffix
1103 o.hasNavigated = false
1104 o.selectedIndex = -1
1105 o.ghostSuffix = ""
1106 o.realInput = entryText
1107 shouldComplete := o.insertCompletion
1108 o.mu.Unlock()
1109
1110 log.Debug().
1111 Str("entryText", entryText).
1112 Str("oldGhost", oldGhost).
1113 Bool("insertCompletion", shouldComplete).
1114 Msg("ghost: onEntryChanged")
1115
1116 // Compute ghost completion synchronously if this was a character insertion.
1117 if shouldComplete {
1118 o.updateGhostFromSelectionWithInput(entryText)
1119 }
1120
1121 // Schedule async search for suggestion list updates.
1122 o.debounceMu.Lock()
1123 if o.debounceTimer != nil {
1124 o.debounceTimer.Stop()
1125 }
1126 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
1127 o.idleCoalescer.Post("omnibox-search", o.performSearch)
1128 })
1129 o.debounceMu.Unlock()
1130}
1131
1132// setGhostText applies inline completion by setting the full text (input + suffix)
1133// and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged.
1134// Skips if the user has typed since this ghost was computed (stale input check).
1135func (o *Omnibox) setGhostText(originalInput, suffix string) {
1136 log := logging.FromContext(o.ctx)
1137 if o.entry == nil || suffix == "" {
1138 o.clearGhostText()
1139 return
1140 }
1141
1142 // Verify the buffer hasn't been modified by user typing since this ghost
1143 // was computed. We check the actual buffer — not realInput, which may be
1144 // stale due to debounced search-changed signal.
1145 currentBuffer := o.entry.GetText()
1146 o.mu.RLock()
1147 existingGhost := o.ghostSuffix
1148 o.mu.RUnlock()
1149 expectedWithGhost := originalInput + existingGhost
1150 if currentBuffer != originalInput && currentBuffer != expectedWithGhost {
1151 log.Debug().
1152 Str("originalInput", originalInput).
1153 Str("currentBuffer", currentBuffer).
1154 Str("suffix", suffix).
1155 Msg("ghost: setGhostText skipped (buffer modified by user)")
1156 return
1157 }
1158
1159 displayText := originalInput + suffix
1160 cursorPos := utf8.RuneCountInString(originalInput)
1161 endPos := utf8.RuneCountInString(displayText)
1162
1163 log.Debug().
1164 Str("input", originalInput).
1165 Str("suffix", suffix).
1166 Int("cursorPos", cursorPos).
1167 Int("endPos", endPos).
1168 Msg("ghost: setGhostText")
1169
1170 o.mu.Lock()
1171 o.realInput = originalInput
1172 o.ghostSuffix = suffix
1173
1174 o.mu.Unlock()
1175
1176 o.withGhostGuard(func() {
1177 o.entry.SetText(displayText)
1178 o.entry.SetPosition(cursorPos)
1179 o.entry.SelectRegion(cursorPos, endPos)
1180 })
1181}
1182
1183// clearGhostText removes any ghost suffix from the entry buffer.
1184// Uses guard flag to prevent re-entrant onEntryChanged.
1185func (o *Omnibox) clearGhostText() {
1186 log := logging.FromContext(o.ctx)
1187 o.mu.Lock()
1188 ghostSuffix := o.ghostSuffix
1189 realInput := o.realInput
1190 o.ghostSuffix = ""
1191
1192 o.mu.Unlock()
1193
1194 if ghostSuffix == "" || o.entry == nil {
1195 return
1196 }
1197
1198 // Only touch the buffer if it still shows the expected ghost text.
1199 expectedText := realInput + ghostSuffix
1200 currentText := o.entry.GetText()
1201 if currentText != expectedText {
1202 log.Debug().
1203 Str("expected", expectedText).
1204 Str("current", currentText).
1205 Msg("ghost: clearGhostText skipped (buffer changed)")
1206 return
1207 }
1208
1209 log.Debug().
1210 Str("realInput", realInput).
1211 Str("ghostSuffix", ghostSuffix).
1212 Msg("ghost: clearGhostText")
1213
1214 o.withGhostGuard(func() {
1215 o.entry.SetText(realInput)
1216 o.entry.SelectRegion(-1, -1)
1217 o.entry.SetPosition(-1)
1218 })
1219}
1220
1221// clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
1222// This prevents stale background results from wiping newer ghost text.
1223func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1224 o.mu.RLock()
1225 currentInput := o.realInput
1226 o.mu.RUnlock()
1227 if currentInput != expectedInput {
1228 return
1229 }
1230 o.clearGhostText()
1231}
1232
1233// acceptGhostCompletion accepts the ghost text — the suffix is already in the
1234// buffer, so we just deselect and update realInput to include it.
1235func (o *Omnibox) acceptGhostCompletion() {
1236 log := logging.FromContext(o.ctx)
1237 o.mu.Lock()
1238 if o.ghostSuffix == "" {
1239 o.mu.Unlock()
1240 log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept")
1241 return
1242 }
1243 suffix := o.ghostSuffix
1244
1245 o.ghostSuffix = ""
1246 o.mu.Unlock()
1247 log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion")
1248
1249 if o.entry != nil {
1250 // The suffix is already in the buffer as selected text.
1251 // Just deselect and move cursor to end.
1252 fullText := o.entry.GetText()
1253 o.mu.Lock()
1254 o.realInput = fullText
1255 o.selectedIndex = -1
1256 o.hasNavigated = false
1257 o.mu.Unlock()
1258
1259 o.withGhostGuard(func() {
1260 o.entry.SelectRegion(-1, -1)
1261 o.entry.SetPosition(-1)
1262 })
1263 }
1264 if o.listBox != nil {
1265 o.listBox.SelectRow(nil)
1266 }
1267}
1268
1269// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
1270// When a row is selected via arrow keys, show the URL as ghost text even if
1271// the user's input isn't a prefix - this shows what Tab would fill in.
1272
1273func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1274 if targetURL == "" {
1275 return false
1276 }
1277
1278 // Strip protocol from URL for cleaner display
1279 displayURL := autocomplete.StripProtocol(targetURL)
1280
1281 // Only show full URL as ghost text when input is empty (initial state)
1282 if userInput == "" {
1283 o.setGhostText("", displayURL)
1284 return true
1285 }
1286
1287 // Try to compute proper suffix if input is a prefix of the URL
1288 if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1289 o.setGhostText(userInput, suffix)
1290 return true
1291 }
1292
1293 return false
1294}
1295
1296// updateGhostFromSelection updates ghost text based on the currently selected row.
1297func (o *Omnibox) updateGhostFromSelection() {
1298 o.mu.RLock()
1299 realInput := o.realInput
1300 hasGhost := o.ghostSuffix != ""
1301 o.mu.RUnlock()
1302
1303 entryText := ""
1304 if o.entry != nil {
1305 entryText = o.entry.GetText()
1306 }
1307
1308 query := effectiveSearchQuery(entryText, realInput, hasGhost)
1309 o.restoreEntryToRealInput()
1310 o.updateGhostFromSelectionWithInput(query)
1311}
1312
1313func (o *Omnibox) restoreEntryToRealInput() {
1314 if o.entry == nil {
1315 return
1316 }
1317
1318 // Clear ghost text first (removes suffix from buffer with guard).
1319 o.clearGhostText()
1320
1321 // If the buffer still doesn't match realInput, force-set it.
1322 o.mu.RLock()
1323 realInput := o.realInput
1324 o.mu.RUnlock()
1325
1326 if o.entry.GetText() != realInput {
1327 o.withGhostGuard(func() {
1328 o.entry.SetText(realInput)
1329 })
1330 }
1331 o.entry.SelectRegion(-1, -1)
1332 o.entry.SetPosition(-1)
1333}
1334
1335// updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1336func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1337 log := logging.FromContext(o.ctx)
1338 maxVisible := o.effectiveMaxRows()
1339 o.mu.RLock()
1340 idx := o.selectedIndex
1341 mode := o.viewMode
1342 bangMode := o.bangMode
1343 hasNavigated := o.hasNavigated
1344 shouldComplete := o.insertCompletion
1345 suggestions := o.suggestions
1346 favorites := o.favorites
1347 o.mu.RUnlock()
1348
1349 // No ghost text after deletion (backspace/delete key).
1350 if !shouldComplete {
1351 log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)")
1352 return
1353 }
1354
1355 // No ghost text in bang mode
1356 if bangMode {
1357 o.clearGhostTextIfInput(entryText)
1358 return
1359 }
1360
1361 if utf8.RuneCountInString(entryText) < minGhostInputLength && !hasNavigated {
1362 o.clearGhostTextIfInput(entryText)
1363 return
1364 }
1365
1366 targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites)
1367
1368 if entryText != "" {
1369 o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites)
1370 return
1371 }
1372
1373 if targetURL == "" {
1374 o.clearGhostTextIfInput(entryText)
1375 return
1376 }
1377
1378 if o.updateGhostFromURL(entryText, targetURL) {
1379 return
1380 }
1381
1382 o.clearGhostTextIfInput(entryText)
1383}
1384
1385// resolveGhostCompletion computes and applies ghost text synchronously.
1386// All computation is pure string matching — no I/O, no goroutines needed.
1387func (o *Omnibox) resolveGhostCompletion(
1388 entryText, selectedURL string,
1389 hasExplicitSelection bool,
1390 mode ViewMode,
1391 maxVisible int,
1392 suggestions []Suggestion,
1393 favorites []Favorite,
1394) {
1395 log := logging.FromContext(o.ctx)
1396 _, completionInput, ok := ghostCompletionInput(entryText)
1397 if !ok {
1398 log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
1399 return
1400 }
1401
1402 fullText, suffix, found := visibleGhostSuggestion(
1403 completionInput,
1404 selectedURL,
1405 hasExplicitSelection,
1406 mode,
1407 maxVisible,
1408 suggestions,
1409 favorites,
1410 )
1411 if !found {
1412 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1413 return
1414 }
1415 fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1416 if fullText == "" || suffix == "" {
1417 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1418 return
1419 }
1420 log.Debug().
1421 Str("input", completionInput).
1422 Str("fullText", fullText).
1423 Str("suffix", suffix).
1424 Msg("ghost: resolveGhost — applying")
1425 o.setGhostText(entryText, suffix)
1426}
1427
1428func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
1429 trimmed := strings.TrimLeft(entryText, " \t")
1430 leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
1431 if trimmed == "" {
1432 return leadingWhitespace, "", false
1433 }
1434 return leadingWhitespace, trimmed, true
1435}
1436
1437// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1438// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1439func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
1440 if queryText == "" || fullText == "" {
1441 return "", ""
1442 }
1443
1444 // If input already contains path/query-ish delimiters, keep original completion behavior.
1445 if strings.ContainsAny(queryText, "/?#=& ") {
1446 return fullText, fallbackSuffix
1447 }
1448
1449 hostOnly := extractHostForCompletion(fullText)
1450 if hostOnly != "" {
1451 for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
1452 if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
1453 return candidate, completionSuffix
1454 }
1455 }
1456 }
1457
1458 return fullText, fallbackSuffix
1459}
1460
1461func extractHostForCompletion(raw string) string {
1462 if raw == "" {
1463 return ""
1464 }
1465 if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
1466 return strings.ToLower(parsed.Hostname())
1467 }
1468 trimmed := autocomplete.StripProtocol(raw)
1469 trimmed = strings.ToLower(trimmed)
1470 for _, sep := range []string{"/", "?", "#"} {
1471 if idx := strings.Index(trimmed, sep); idx >= 0 {
1472 trimmed = trimmed[:idx]
1473 }
1474 }
1475 return strings.TrimSpace(trimmed)
1476}
1477
1478func visibleGhostSuggestion(
1479 query, selectedURL string,
1480 hasExplicitSelection bool,
1481 mode ViewMode,
1482 maxVisible int,
1483 suggestions []Suggestion,
1484 favorites []Favorite,
1485) (fullText, suffix string, ok bool) {
1486 if hasExplicitSelection {
1487 suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL)
1488 return fullText, suffix, ok
1489 }
1490
1491 visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
1492 suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
1493 return fullText, suffix, ok
1494}
1495
1496func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
1497 if mode == ViewModeHistory {
1498 visibleCount := visibleResultCount(len(suggestions), maxVisible)
1499 urls := make([]string, 0, visibleCount)
1500 for _, s := range suggestions[:visibleCount] {
1501 if s.URL != "" {
1502 urls = append(urls, s.URL)
1503 }
1504 }
1505 return urls
1506 }
1507
1508 visibleCount := visibleResultCount(len(favorites), maxVisible)
1509 urls := make([]string, 0, visibleCount)
1510 for _, f := range favorites[:visibleCount] {
1511 if f.URL != "" {
1512 urls = append(urls, f.URL)
1513 }
1514 }
1515 return urls
1516}
1517
1518func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
1519 if idx < 0 {
1520 return "", false
1521 }
1522 return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
1523}
1524
1525func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
1526 if idx < 0 || idx >= len(bangSuggestions) {
1527 return "", false
1528 }
1529 return "!" + bangSuggestions[idx].Key + " ", true
1530}
1531
1532func visibleResultCount(total, maxVisible int) int {
1533 if total <= 0 {
1534 return 0
1535 }
1536 if maxVisible <= 0 || total < maxVisible {
1537 return total
1538 }
1539 return maxVisible
1540}
1541
1542// performSearch executes the search based on current view mode and query.
1543func (o *Omnibox) performSearch() {
1544 o.mu.RLock()
1545 visible := o.visible
1546 mode := o.viewMode
1547 realInput := o.realInput
1548 hasGhost := o.ghostSuffix != ""
1549 o.mu.RUnlock()
1550
1551 // Skip search if omnibox is hidden
1552 if !visible {
1553 return
1554 }
1555
1556 query := effectiveSearchQuery(o.entry.GetText(), realInput, hasGhost)
1557
1558 // Skip duplicate queries
1559 o.debounceMu.Lock()
1560 if query == o.lastQuery && query != "" {
1561 o.debounceMu.Unlock()
1562 return
1563 }
1564 o.lastQuery = query
1565 o.debounceMu.Unlock()
1566
1567 o.mu.Lock()
1568 o.searchToken++
1569 token := o.searchToken
1570 o.mu.Unlock()
1571
1572 if strings.HasPrefix(query, "!") {
1573 o.updateBangDetection(query, token)
1574 o.loadBangSuggestions(query, token)
1575 return
1576 }
1577
1578 o.clearBangState()
1579
1580 if mode == ViewModeFavorites {
1581 o.loadFavorites(query, token)
1582 return
1583 }
1584
1585 // History search
1586 if query == "" {
1587 o.loadInitialHistory(token)
1588 return
1589 }
1590
1591 // Perform fuzzy history search in background
1592 o.searchHistory(query, o.effectiveMaxRows(), token)
1593}
1594
1595func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
1596 if hasGhost && realInput != "" {
1597 return realInput
1598 }
1599 return entryText
1600}
1601
1602// searchHistory runs a fuzzy history search in a background goroutine.
1603// query is the search text; limit caps the number of results.
1604func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1605 go func() {
1606 ctx := o.ctx
1607 log := logging.FromContext(ctx)
1608 if o.historyUC == nil {
1609 return
1610 }
1611
1612 // Run history search and favorite URL fetch in parallel
1613 type searchResult struct {
1614 output *usecase.SearchOutput
1615 err error
1616 }
1617 searchCh := make(chan searchResult, 1)
1618 favCh := make(chan map[string]struct{}, 1)
1619
1620 go func() {
1621 searchInput := usecase.SearchInput{
1622 Query: query,
1623 Limit: limit,
1624 }
1625 output, err := o.historyUC.Search(ctx, searchInput)
1626 searchCh <- searchResult{output, err}
1627 }()
1628
1629 go func() {
1630 favCh <- o.getFavoriteURLs(ctx)
1631 }()
1632
1633 // Wait for both results
1634 sr := <-searchCh
1635 favoriteURLs := <-favCh
1636
1637 if sr.err != nil {
1638 log.Error().Err(sr.err).Msg("history search failed")
1639 return
1640 }
1641 if sr.output == nil {
1642 return
1643 }
1644
1645 suggestions := make([]Suggestion, 0, len(sr.output.Matches))
1646 for _, r := range sr.output.Matches {
1647 _, isFav := favoriteURLs[r.Entry.URL]
1648 suggestions = append(suggestions, Suggestion{
1649 URL: r.Entry.URL,
1650 Title: r.Entry.Title,
1651 IsFavorite: isFav,
1652 })
1653 }
1654
1655 // Marshal back to GTK main thread, passing query for stale-result guarding.
1656 o.idleAddUpdateSuggestions(suggestions, query, token)
1657 }()
1658}
1659
1660// loadInitialHistory loads history based on InitialBehavior config.
1661func (o *Omnibox) loadInitialHistory(token uint64) {
1662 if o.historyUC == nil {
1663 return
1664 }
1665
1666 // Capture effective result limit on the GTK main thread before spawning goroutine
1667 initialLimit := o.effectiveMaxRows()
1668 initialBehavior := o.initialBehavior
1669 mostVisitedDays := o.mostVisitedDays
1670
1671 go func() {
1672 ctx := o.ctx
1673 log := logging.FromContext(ctx)
1674 var suggestions []Suggestion
1675
1676 switch initialBehavior {
1677 case entity.OmniboxInitialBehaviorNone:
1678 o.idleAddUpdateSuggestions(nil, "", token)
1679 return
1680
1681 case entity.OmniboxInitialBehaviorMostVisited, entity.OmniboxInitialBehaviorRecent, "":
1682 // Run history fetch and favorite URL fetch in parallel
1683 type historyResult struct {
1684 results []*entity.HistoryEntry
1685 err error
1686 }
1687 historyCh := make(chan historyResult, 1)
1688 favCh := make(chan map[string]struct{}, 1)
1689
1690 go func() {
1691 var (
1692 results []*entity.HistoryEntry
1693 err error
1694 )
1695 if initialBehavior == entity.OmniboxInitialBehaviorMostVisited {
1696 if mostVisitedDays == 0 {
1697 results, err = o.historyUC.GetMostVisited(ctx, 0)
1698 } else {
1699 results, err = o.historyUC.GetMostVisited(ctx, mostVisitedDays)
1700 }
1701 if err == nil && len(results) > initialLimit {
1702 results = results[:initialLimit]
1703 }
1704 } else {
1705 results, err = o.historyUC.GetRecent(ctx, initialLimit, 0)
1706 }
1707 historyCh <- historyResult{results, err}
1708 }()
1709
1710 go func() {
1711 favCh <- o.getFavoriteURLs(ctx)
1712 }()
1713
1714 // Wait for both results
1715 hr := <-historyCh
1716 favoriteURLs := <-favCh
1717
1718 if hr.err != nil {
1719 log.Error().Err(hr.err).Msg("failed to load recent history")
1720 return
1721 }
1722 if hr.results == nil {
1723 return
1724 }
1725
1726 suggestions = make([]Suggestion, 0, len(hr.results))
1727 for _, r := range hr.results {
1728 _, isFav := favoriteURLs[r.URL]
1729 suggestions = append(suggestions, Suggestion{
1730 URL: r.URL,
1731 Title: r.Title,
1732 IsFavorite: isFav,
1733 })
1734 }
1735 }
1736
1737 o.idleAddUpdateSuggestions(suggestions, "", token)
1738 }()
1739}
1740
1741// loadFavorites loads favorites, optionally filtered by query.
1742func (o *Omnibox) loadFavorites(query string, token uint64) {
1743 if o.favoritesUC == nil {
1744 return
1745 }
1746
1747 go func() {
1748 ctx := o.ctx
1749 log := logging.FromContext(ctx)
1750 results, err := o.favoritesUC.FilterForOmnibox(ctx, query)
1751 if err != nil {
1752 log.Error().Err(err).Msg("failed to load favorites")
1753 return
1754 }
1755
1756 favorites := make([]Favorite, 0, len(results))
1757 for i, r := range results {
1758 favorites = append(favorites, Favorite{
1759 ID: int64(r.ID),
1760 URL: r.URL,
1761 Title: r.Title,
1762 Position: i,
1763 })
1764 }
1765
1766 o.idleAddUpdateFavorites(favorites, query, token)
1767 }()
1768}
1769
1770func (o *Omnibox) loadBangSuggestions(query string, token uint64) {
1771 if o.shortcutsUC == nil {
1772 return
1773 }
1774 output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query})
1775 suggestions := make([]BangSuggestion, len(output.Suggestions))
1776 for i, s := range output.Suggestions {
1777 suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
1778 }
1779 o.idleAddUpdateBangSuggestions(suggestions, query, token)
1780}
1781
1782func (o *Omnibox) updateBangDetection(query string, token uint64) {
1783 if o.shortcutsUC == nil {
1784 o.clearDetectedBang(query, token)
1785 return
1786 }
1787 output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
1788 if output.Key == "" {
1789 o.clearDetectedBang(query, token)
1790 return
1791 }
1792
1793 o.setDetectedBang(output.Key, output.Description, query, token)
1794}
1795
1796func (o *Omnibox) clearBangState() {
1797 o.mu.Lock()
1798 o.bangMode = false
1799 o.bangSuggestions = nil
1800 o.mu.Unlock()
1801
1802 o.mu.RLock()
1803 query := o.realInput
1804 token := o.searchToken
1805 o.mu.RUnlock()
1806 o.clearDetectedBang(query, token)
1807}
1808
1809func (o *Omnibox) setDetectedBang(key, description, query string, token uint64) {
1810 o.mu.Lock()
1811 o.detectedBang = key
1812 o.mu.Unlock()
1813
1814 if o.bangBadge == nil {
1815 return
1816 }
1817 label := description
1818 var cb glib.SourceFunc = func(uintptr) bool {
1819 if !o.isBangUpdateCurrent(query, token) {
1820 return false
1821 }
1822 o.entry.AddCssClass("omnibox-entry-bang-active")
1823 o.bangBadge.SetText(label)
1824 o.bangBadge.SetVisible(true)
1825 return false
1826 }
1827 glib.IdleAdd(&cb, 0)
1828}
1829
1830func (o *Omnibox) clearDetectedBang(query string, token uint64) {
1831 o.mu.Lock()
1832 o.detectedBang = ""
1833 o.mu.Unlock()
1834
1835 if o.bangBadge == nil {
1836 return
1837 }
1838 var cb glib.SourceFunc = func(uintptr) bool {
1839 if !o.isBangUpdateCurrent(query, token) {
1840 return false
1841 }
1842 o.entry.RemoveCssClass("omnibox-entry-bang-active")
1843 o.bangBadge.SetVisible(false)
1844 return false
1845 }
1846 glib.IdleAdd(&cb, 0)
1847}
1848
1849func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion, query string, token uint64) {
1850 var cb glib.SourceFunc = func(uintptr) bool {
1851 if !o.isBangUpdateCurrent(query, token) {
1852 return false
1853 }
1854 o.updateBangSuggestions(suggestions)
1855 return false
1856 }
1857 glib.IdleAdd(&cb, 0)
1858}
1859
1860func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1861 o.mu.Lock()
1862 o.bangMode = true
1863 o.bangSuggestions = suggestions
1864 o.selectedIndex = -1
1865 o.hasNavigated = false
1866 o.mu.Unlock()
1867
1868 o.rebuildList()
1869
1870 rowCount := len(suggestions)
1871 o.setResultsContainerState(rowCount)
1872 o.resizeAndCenter(rowCount)
1873}
1874
1875// updateSuggestions updates the list with history suggestions.
1876// The query parameter is the search query that triggered this update.
1877func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1878 o.mu.Lock()
1879 o.bangMode = false
1880 o.bangSuggestions = nil
1881 o.suggestions = suggestions
1882 o.selectedIndex = -1
1883 o.mu.Unlock()
1884
1885 o.rebuildList()
1886
1887 // Hide scrolled window when there are no suggestions
1888 rowCount := len(suggestions)
1889 o.setResultsContainerState(rowCount)
1890 o.resizeAndCenter(rowCount)
1891
1892 if rowCount > 0 {
1893 o.updateGhostFromSelectionWithInput(query)
1894 } else {
1895 o.clearGhostTextIfInput(query)
1896 }
1897}
1898
1899// updateFavorites updates the list with favorites.
1900// The query parameter is the search query that triggered this update.
1901func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1902 o.mu.Lock()
1903 o.bangMode = false
1904 o.bangSuggestions = nil
1905 o.favorites = favorites
1906 o.selectedIndex = -1
1907 o.mu.Unlock()
1908
1909 o.rebuildList()
1910
1911 // Hide scrolled window when there are no favorites
1912 rowCount := len(favorites)
1913 o.setResultsContainerState(rowCount)
1914 o.resizeAndCenter(rowCount)
1915
1916 if rowCount > 0 {
1917 o.updateGhostFromSelectionWithInput(query)
1918 } else {
1919 o.clearGhostTextIfInput(query)
1920 }
1921}
1922
1923// rebuildList rebuilds the ListBox contents.
1924func (o *Omnibox) rebuildList() {
1925 // Clear existing rows
1926 o.listBox.RemoveAll()
1927
1928 o.mu.RLock()
1929 mode := o.viewMode
1930 suggestions := o.suggestions
1931 favorites := o.favorites
1932 bangMode := o.bangMode
1933 bangSuggestions := o.bangSuggestions
1934 o.mu.RUnlock()
1935
1936 if bangMode {
1937 for i, b := range bangSuggestions {
1938 row := o.createBangRow(b, i)
1939 if row != nil {
1940 o.listBox.Append(&row.Widget)
1941 }
1942 }
1943 } else if mode == ViewModeHistory {
1944 for i, s := range suggestions {
1945 row := o.createSuggestionRow(s, i)
1946 if row != nil {
1947 o.listBox.Append(&row.Widget)
1948 }
1949 }
1950 } else {
1951 for i, f := range favorites {
1952 row := o.createFavoriteRow(f, i)
1953 if row != nil {
1954 o.listBox.Append(&row.Widget)
1955 }
1956 }
1957 }
1958
1959 // Schedule measurement if not yet cached and we have rows
1960 if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
1961 var cb glib.SourceFunc = func(uintptr) bool {
1962 if o.parentOverlay == nil {
1963 return false
1964 }
1965 width := o.parentOverlay.GetAllocatedWidth()
1966 if width <= 0 {
1967 return false // Overlay not allocated yet, skip
1968 }
1969 forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
1970 if o.measureComponentHeights(forWidth) {
1971 // Re-trigger resize with accurate measurements
1972 o.mu.RLock()
1973 var count int
1974 if o.bangMode {
1975 count = len(o.bangSuggestions)
1976 } else if o.viewMode == ViewModeHistory {
1977 count = len(o.suggestions)
1978 } else {
1979 count = len(o.favorites)
1980 }
1981 o.mu.RUnlock()
1982 o.measureAndResize(forWidth, count)
1983 }
1984 return false
1985 }
1986 glib.IdleAdd(&cb, 0)
1987 }
1988}
1989
1990// createFaviconImage creates a favicon image with async loading from cache.
1991func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1992 favicon := gtk.NewImage()
1993 if favicon == nil {
1994 return nil
1995 }
1996 favicon.SetFromIconName(&fallbackIcon)
1997 favicon.SetPixelSize(int(16 * o.uiScale))
1998 favicon.AddCssClass("omnibox-favicon")
1999
2000 // Async load favicon from cache
2001 if o.faviconAdapter != nil && rawURL != "" {
2002 o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
2003 if texture != nil {
2004 var cb glib.SourceFunc = func(data uintptr) bool {
2005 favicon.SetFromPaintable(texture)
2006 return false
2007 }
2008 glib.IdleAdd(&cb, 0)
2009 }
2010 })
2011 }
2012
2013 return favicon
2014}
2015
2016const favoriteStarBaseSize = 18
2017const favoriteStarSlotClass = "omnibox-favorite-star-slot"
2018
2019func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) }
2020
2021func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite }
2022
2023func clearBoxChildren(box *gtk.Box) {
2024 if box == nil {
2025 return
2026 }
2027 for child := box.GetFirstChild(); child != nil; {
2028 next := child.GetNextSibling()
2029 box.Remove(child)
2030 child = next
2031 }
2032}
2033
2034func (o *Omnibox) syncFavoriteStarSlot(slot *gtk.Box, showFavoriteStar bool) {
2035 if slot == nil {
2036 return
2037 }
2038 clearBoxChildren(slot)
2039 if showFavoriteStar {
2040 if star := o.createFavoriteStarIcon(); star != nil {
2041 slot.Append(&star.Widget)
2042 }
2043 }
2044 slot.SetVisible(showFavoriteStar)
2045}
2046
2047func favoriteStarSlotForRow(row *gtk.ListBoxRow) *gtk.Box {
2048 if row == nil {
2049 return nil
2050 }
2051 hbox := row.GetChild()
2052 if hbox == nil {
2053 return nil
2054 }
2055 for child := hbox.GetFirstChild(); child != nil; child = child.GetNextSibling() {
2056 if child.HasCssClass(favoriteStarSlotClass) {
2057 return gtk.BoxNewFromInternalPtr(child.GoPointer())
2058 }
2059 }
2060 return nil
2061}
2062
2063func (o *Omnibox) createFavoriteStarIcon() *gtk.Image {
2064 star := gtk.NewImage()
2065 if star == nil {
2066 return nil
2067 }
2068 iconName := "starred-symbolic"
2069 star.SetFromIconName(&iconName)
2070 star.SetPixelSize(favoriteStarSize(o.uiScale))
2071 star.AddCssClass("omnibox-favorite-star")
2072 star.SetValign(gtk.AlignCenterValue)
2073 return star
2074}
2075
2076func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) {
2077 displayTitle := title
2078 if displayTitle == "" {
2079 displayTitle = displayURL
2080 }
2081
2082 titleLabel := gtk.NewLabel(nil)
2083 if titleLabel != nil {
2084 titleLabel.SetText(displayTitle)
2085 titleLabel.AddCssClass("omnibox-suggestion-title")
2086 titleLabel.SetHalign(gtk.AlignStartValue)
2087 titleLabel.SetEllipsize(pango.EllipsizeEndValue)
2088 textBox.Append(&titleLabel.Widget)
2089 }
2090
2091 if title != "" && title != displayURL {
2092 urlLabel := gtk.NewLabel(nil)
2093 if urlLabel != nil {
2094 urlLabel.SetText(displayURL)
2095 urlLabel.AddCssClass("omnibox-suggestion-url")
2096 urlLabel.SetHalign(gtk.AlignStartValue)
2097 urlLabel.SetEllipsize(pango.EllipsizeEndValue)
2098 textBox.Append(&urlLabel.Widget)
2099 }
2100 }
2101}
2102
2103func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) {
2104 starSlot := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
2105 if starSlot != nil {
2106 starSlot.AddCssClass(favoriteStarSlotClass)
2107 starSlot.SetValign(gtk.AlignCenterValue)
2108 o.syncFavoriteStarSlot(starSlot, showFavoriteStar)
2109 hbox.Append(&starSlot.Widget)
2110 }
2111
2112 const maxShortcutIndex = 9
2113 if index > maxShortcutIndex {
2114 return
2115 }
2116
2117 shortcutLabel := gtk.NewLabel(nil)
2118 if shortcutLabel == nil {
2119 return
2120 }
2121 if index < maxShortcutIndex {
2122 shortcutLabel.SetText(formatShortcut(index + 1))
2123 } else {
2124 shortcutLabel.SetText("Ctrl+0")
2125 }
2126 shortcutLabel.AddCssClass("omnibox-shortcut-badge")
2127 shortcutLabel.SetValign(gtk.AlignCenterValue)
2128 hbox.Append(&shortcutLabel.Widget)
2129}
2130
2131// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
2132// Uses rawURL for both favicon fetching and display.
2133func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
2134 return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index)
2135}
2136
2137// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
2138// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
2139func (o *Omnibox) createRowWithFaviconURL(
2140 displayURL, title, faviconURL, fallbackIcon string,
2141 showFavoriteStar bool,
2142 index int,
2143) *gtk.ListBoxRow {
2144 row := gtk.NewListBoxRow()
2145 if row == nil {
2146 return nil
2147 }
2148 row.AddCssClass("omnibox-row")
2149
2150 const rowSpacing = 8
2151 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
2152 if hbox == nil {
2153 return nil
2154 }
2155 hbox.SetHexpand(true)
2156
2157 // Favicon image (vertically centered)
2158 if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
2159 favicon.SetValign(gtk.AlignCenterValue)
2160 hbox.Append(&favicon.Widget)
2161 }
2162
2163 // Vertical box for title + URL
2164 textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
2165 if textBox == nil {
2166 return nil
2167 }
2168 textBox.SetHexpand(true)
2169 textBox.SetValign(gtk.AlignCenterValue)
2170
2171 o.appendSuggestionTitleAndURL(textBox, title, displayURL)
2172
2173 hbox.Append(&textBox.Widget)
2174 o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index)
2175
2176 row.SetChild(&hbox.Widget)
2177 return row
2178}
2179
2180func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bool) bool {
2181 return realInput == "" && !hasGhostText && !hasNavigated
2182}
2183
2184// createSuggestionRow creates a ListBoxRow for a suggestion.
2185func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
2186 row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index)
2187 if row != nil && s.IsFavorite {
2188 row.AddCssClass("omnibox-row-favorite")
2189 }
2190 return row
2191}
2192
2193// createFavoriteRow creates a ListBoxRow for a favorite.
2194func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
2195 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
2196}
2197
2198func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
2199 // Pass description as URL param (displayed as secondary label) and empty
2200 // faviconURL to skip async favicon fetching - bang rows use static icon only
2201 row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index)
2202 if row != nil {
2203 row.AddCssClass("omnibox-row-bang")
2204 }
2205 return row
2206}
2207
2208// selectIndex selects a row by index.
2209func (o *Omnibox) selectIndex(index int) {
2210 o.mu.Lock()
2211 o.selectedIndex = index
2212 o.mu.Unlock()
2213
2214 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
2215 // which also acquires the mutex, causing deadlock
2216 row := o.listBox.GetRowAtIndex(index)
2217 if row != nil {
2218 o.listBox.SelectRow(row)
2219 return
2220 }
2221
2222 o.updateGhostFromSelection()
2223}
2224
2225// selectNext moves selection down.
2226func (o *Omnibox) selectNext() {
2227 o.mu.Lock()
2228 current := o.selectedIndex
2229 mode := o.viewMode
2230 bangMode := o.bangMode
2231 maxVisible := o.effectiveMaxRows()
2232 var maxIndex int
2233 if bangMode {
2234 maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2235 } else if mode == ViewModeHistory {
2236 maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2237 } else {
2238 maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2239 }
2240 o.hasNavigated = true // User is navigating with arrow keys
2241 o.mu.Unlock()
2242
2243 if maxIndex < 0 {
2244 return
2245 }
2246
2247 newIndex := current + 1
2248 if newIndex > maxIndex {
2249 newIndex = 0 // Wrap around
2250 }
2251 o.selectIndex(newIndex)
2252}
2253
2254// selectPrevious moves selection up.
2255func (o *Omnibox) selectPrevious() {
2256 o.mu.Lock()
2257 current := o.selectedIndex
2258 mode := o.viewMode
2259 bangMode := o.bangMode
2260 maxVisible := o.effectiveMaxRows()
2261 var maxIndex int
2262 if bangMode {
2263 maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2264 } else if mode == ViewModeHistory {
2265 maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2266 } else {
2267 maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2268 }
2269 o.hasNavigated = true // User is navigating with arrow keys
2270 o.mu.Unlock()
2271
2272 if maxIndex < 0 {
2273 return
2274 }
2275
2276 newIndex := current - 1
2277 if newIndex < 0 {
2278 newIndex = maxIndex // Wrap around
2279 }
2280 o.selectIndex(newIndex)
2281}
2282
2283// selectAndNavigate selects an index and navigates to it.
2284func (o *Omnibox) selectAndNavigate(index int) {
2285 o.selectIndex(index)
2286
2287 o.mu.RLock()
2288 mode := o.viewMode
2289 bangMode := o.bangMode
2290 suggestions := o.suggestions
2291 favorites := o.favorites
2292 o.mu.RUnlock()
2293 maxVisible := o.effectiveMaxRows()
2294
2295 if bangMode {
2296 o.navigateToSelected()
2297 return
2298 }
2299
2300 targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites)
2301 if targetURL == "" {
2302 return
2303 }
2304
2305 o.submitNavigation(targetURL)
2306}
2307
2308func (o *Omnibox) submitNavigation(targetURL string) {
2309 if targetURL == "" {
2310 return
2311 }
2312 if o.onNavigate == nil {
2313 logging.FromContext(o.ctx).Warn().Msg("omnibox navigate handler is nil")
2314 return
2315 }
2316 if err := o.onNavigate(o.ctx, targetURL); err != nil {
2317 logging.FromContext(o.ctx).Error().Err(err).Msg("omnibox navigation failed")
2318 if o.onToast != nil {
2319 o.onToast(o.ctx, "Unable to open URL", ToastError)
2320 }
2321 return
2322 }
2323 o.Hide(o.ctx)
2324}
2325
2326// navigateToSelected navigates to the currently selected item or typed URL.
2327// If the user typed a URL-like string, prioritize navigating to that directly.
2328func (o *Omnibox) navigateToSelected() {
2329 o.mu.RLock()
2330 mode := o.viewMode
2331 idx := o.selectedIndex
2332 suggestions := o.suggestions
2333 favorites := o.favorites
2334 bangMode := o.bangMode
2335 bangSuggestions := o.bangSuggestions
2336 o.mu.RUnlock()
2337
2338 entryText := o.entry.GetText()
2339
2340 if bangMode {
2341 // If user typed a full bang query, navigate using the bang URL.
2342 if o.shortcutsUC != nil {
2343 navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
2344 if navOutput.Valid {
2345 targetURL := o.buildURL(navOutput.Text)
2346 if targetURL == "" {
2347 return
2348 }
2349 o.submitNavigation(targetURL)
2350 return
2351 }
2352 }
2353
2354 // Otherwise, Enter autocompletes the selected bang.
2355 if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok {
2356 o.entry.SetText(bangText)
2357 o.entry.SetPosition(-1)
2358 return
2359 }
2360 return
2361 }
2362
2363 var targetURL string
2364 if idx >= 0 {
2365 // If user has selected a result, navigate to that result.
2366 targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
2367 } else {
2368 targetURL = o.buildURL(entryText)
2369 }
2370
2371 if targetURL == "" {
2372 return
2373 }
2374
2375 o.submitNavigation(targetURL)
2376}
2377
2378func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
2379 if mode == ViewModeHistory {
2380 visibleCount := visibleResultCount(len(suggestions), maxVisible)
2381 if idx >= 0 && idx < visibleCount {
2382 return suggestions[idx].URL
2383 }
2384 return ""
2385 }
2386 visibleCount := visibleResultCount(len(favorites), maxVisible)
2387 if idx >= 0 && idx < visibleCount {
2388 return favorites[idx].URL
2389 }
2390 return ""
2391}
2392
2393func shouldPreferTypedURLNavigation(entryText string) bool {
2394 entryText = strings.TrimSpace(entryText)
2395 if entryText == "" {
2396 return false
2397 }
2398 return url.LooksLikeURL(entryText)
2399}
2400
2401func resolveFavoriteToggleTarget(
2402 mode ViewMode,
2403 idx int,
2404 suggestions []Suggestion,
2405 favorites []Favorite,
2406) (favoriteToggleTarget, bool) {
2407 if mode == ViewModeHistory {
2408 if idx < 0 || idx >= len(suggestions) {
2409 return favoriteToggleTarget{}, false
2410 }
2411 s := suggestions[idx]
2412 if s.URL == "" {
2413 return favoriteToggleTarget{}, false
2414 }
2415 return favoriteToggleTarget{
2416 url: s.URL,
2417 title: s.Title,
2418 wasFavorite: s.IsFavorite,
2419 }, true
2420 }
2421
2422 if idx < 0 || idx >= len(favorites) {
2423 return favoriteToggleTarget{}, false
2424 }
2425 f := favorites[idx]
2426 if f.ID == 0 {
2427 return favoriteToggleTarget{}, false
2428 }
2429 return favoriteToggleTarget{
2430 url: f.URL,
2431 title: f.Title,
2432 favoriteID: f.ID,
2433 }, true
2434}
2435
2436func resolveFavoriteToggleResultUpdate(
2437 suggestions []Suggestion,
2438 index int,
2439 expectedURL string,
2440 isFavorite bool,
2441) favoriteToggleResultUpdate {
2442 if index < 0 || index >= len(suggestions) {
2443 return favoriteToggleResultUpdate{}
2444 }
2445 if suggestions[index].URL != expectedURL {
2446 return favoriteToggleResultUpdate{}
2447 }
2448 return favoriteToggleResultUpdate{Apply: true, Index: index, IsFavorite: isFavorite}
2449}
2450
2451func favoriteToggleErrorMessage(wasFavorite bool) string {
2452 if wasFavorite {
2453 return "Failed to remove favorite"
2454 }
2455 return "Failed to add favorite"
2456}
2457
2458// toggleFavorite adds or removes the selected item from favorites.
2459// In History mode: adds the selected item to favorites
2460// In Favorites mode: removes the selected item from favorites
2461func (o *Omnibox) toggleFavorite() {
2462 log := logging.FromContext(o.ctx)
2463
2464 o.mu.RLock()
2465 mode := o.viewMode
2466 idx := o.selectedIndex
2467 suggestions := o.suggestions
2468 favorites := o.favorites
2469 o.mu.RUnlock()
2470
2471 if o.favoritesUC == nil {
2472 log.Warn().Msg("toggle favorite: favoritesUC is nil")
2473 return
2474 }
2475
2476 if mode == ViewModeHistory {
2477 // Toggle favorite in history mode
2478 if idx < 0 || idx >= len(suggestions) {
2479 log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection")
2480 return
2481 }
2482
2483 s := suggestions[idx]
2484 if s.URL == "" {
2485 log.Debug().Msg("toggle favorite: empty URL")
2486 return
2487 }
2488 target, ok := resolveFavoriteToggleTarget(mode, idx, suggestions, favorites)
2489 if !ok {
2490 return
2491 }
2492
2493 go func() {
2494 ctx := o.ctx
2495 goLog := logging.FromContext(ctx)
2496
2497 result, err := o.favoritesUC.Toggle(ctx, target.url, target.title)
2498 if err != nil {
2499 goLog.Error().Err(err).Str("url", target.url).Msg("failed to toggle favorite")
2500 if o.onToast != nil {
2501 msg := favoriteToggleErrorMessage(target.wasFavorite)
2502 cb := glib.SourceFunc(func(_ uintptr) bool {
2503 o.onToast(ctx, msg, ToastError)
2504 return false
2505 })
2506 glib.IdleAdd(&cb, 0)
2507 }
2508 return
2509 }
2510
2511 // Update suggestion state
2512 o.mu.Lock()
2513 update := resolveFavoriteToggleResultUpdate(o.suggestions, idx, target.url, result.Added)
2514 if update.Apply {
2515 o.suggestions[update.Index].IsFavorite = update.IsFavorite
2516 }
2517 o.mu.Unlock()
2518
2519 // Update row CSS and show toast on GTK main thread
2520 cb := glib.SourceFunc(func(_ uintptr) bool {
2521 o.updateRowFavoriteIndicator(idx, target.url, result.Added)
2522 if o.onToast != nil {
2523 o.onToast(ctx, result.Message, ToastSuccess)
2524 }
2525 return false
2526 })
2527 glib.IdleAdd(&cb, 0)
2528 }()
2529 } else {
2530 // Remove from favorites
2531 if idx < 0 || idx >= len(favorites) {
2532 log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
2533 return
2534 }
2535
2536 f := favorites[idx]
2537 if f.ID == 0 {
2538 log.Debug().Msg("remove favorite: invalid ID")
2539 return
2540 }
2541 target, ok := resolveFavoriteToggleTarget(mode, idx, suggestions, favorites)
2542 if !ok {
2543 return
2544 }
2545 entryText := o.entry.GetText()
2546
2547 go func() {
2548 ctx := o.ctx
2549 log := logging.FromContext(ctx)
2550
2551 log.Debug().Int64("id", target.favoriteID).Str("url", target.url).Msg("removing from favorites")
2552
2553 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(target.favoriteID))
2554 if err != nil {
2555 log.Error().Err(err).Int64("id", target.favoriteID).Msg("failed to remove favorite")
2556 // Show error toast
2557 if o.onToast != nil {
2558 cb := glib.SourceFunc(func(_ uintptr) bool {
2559 o.onToast(ctx, "Failed to remove favorite", ToastError)
2560 return false
2561 })
2562 glib.IdleAdd(&cb, 0)
2563 }
2564 return
2565 }
2566
2567 log.Info().Int64("id", target.favoriteID).Str("url", target.url).Msg("favorite removed from omnibox")
2568
2569 // Refresh favorites list and show toast
2570 o.mu.RLock()
2571 currentToken := o.searchToken
2572 o.mu.RUnlock()
2573 o.loadFavorites(entryText, currentToken)
2574 if o.onToast != nil {
2575 cb := glib.SourceFunc(func(_ uintptr) bool {
2576 o.onToast(ctx, "Favorite removed", ToastSuccess)
2577 return false
2578 })
2579 glib.IdleAdd(&cb, 0)
2580 }
2581 }()
2582 }
2583}
2584
2585// updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
2586// Must be called from GTK main thread (via glib.IdleAdd).
2587func (o *Omnibox) updateRowFavoriteIndicator(index int, expectedURL string, isFavorite bool) {
2588 row := o.listBox.GetRowAtIndex(index)
2589 if row == nil {
2590 return
2591 }
2592
2593 o.mu.RLock()
2594 mode := o.viewMode
2595 suggestions := append([]Suggestion(nil), o.suggestions...)
2596 o.mu.RUnlock()
2597
2598 update := resolveFavoriteRowIndicatorUpdate(mode, index, expectedURL, suggestions, isFavorite)
2599 if !update.Apply {
2600 return
2601 }
2602
2603 if isFavorite {
2604 row.AddCssClass("omnibox-row-favorite")
2605 } else {
2606 row.RemoveCssClass("omnibox-row-favorite")
2607 }
2608 if slot := favoriteStarSlotForRow(row); slot != nil {
2609 o.syncFavoriteStarSlot(slot, isFavorite)
2610 slot.QueueDraw()
2611 if update.ShowStarSlot {
2612 slot.Show()
2613 }
2614 }
2615 if child := row.GetChild(); child != nil {
2616 child.QueueDraw()
2617 child.Show()
2618 }
2619 row.QueueDraw()
2620 row.Show()
2621}
2622
2623func resolveFavoriteRowIndicatorUpdate(
2624 mode ViewMode,
2625 index int,
2626 expectedURL string,
2627 suggestions []Suggestion,
2628 isFavorite bool,
2629) favoriteRowIndicatorUpdate {
2630 if mode != ViewModeHistory {
2631 return favoriteRowIndicatorUpdate{}
2632 }
2633 if index < 0 || index >= len(suggestions) {
2634 return favoriteRowIndicatorUpdate{}
2635 }
2636 if suggestions[index].URL != expectedURL {
2637 return favoriteRowIndicatorUpdate{}
2638 }
2639 return favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: isFavorite}
2640}
2641
2642// yankSelectedURL copies the URL of the selected item to clipboard.
2643func (o *Omnibox) yankSelectedURL() {
2644 log := logging.FromContext(o.ctx)
2645
2646 if o.copyURLUC == nil {
2647 log.Warn().Msg("yank URL: copy URL use case is nil")
2648 return
2649 }
2650
2651 o.mu.RLock()
2652 mode := o.viewMode
2653 idx := o.selectedIndex
2654 suggestions := o.suggestions
2655 favorites := o.favorites
2656 o.mu.RUnlock()
2657
2658 var selectedURL string
2659 if mode == ViewModeHistory {
2660 if idx < 0 || idx >= len(suggestions) {
2661 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
2662 return
2663 }
2664 selectedURL = suggestions[idx].URL
2665 } else {
2666 if idx < 0 || idx >= len(favorites) {
2667 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
2668 return
2669 }
2670 selectedURL = favorites[idx].URL
2671 }
2672
2673 if selectedURL == "" {
2674 log.Debug().Msg("yank URL: empty URL")
2675 return
2676 }
2677
2678 go func() {
2679 ctx := o.ctx
2680 if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
2681 return // Use case already logs the error
2682 }
2683
2684 // Show toast notification on success (must run on GTK main thread)
2685 if o.onToast != nil {
2686 cb := glib.SourceFunc(func(_ uintptr) bool {
2687 o.onToast(ctx, "URL copied", ToastSuccess)
2688 return false // Don't repeat
2689 })
2690 glib.IdleAdd(&cb, 0)
2691 }
2692 }()
2693}
2694
2695// buildURL constructs a URL from text, handling search shortcuts.
2696func (o *Omnibox) buildURL(text string) string {
2697 var shortcutURLs map[string]string
2698 if o.shortcutsUC != nil {
2699 shortcutURLs = o.shortcutsUC.ShortcutURLs()
2700 }
2701 return usecase.BuildNavigationURL(o.ctx, text, o.normalizeNavigationURL, shortcutURLs, o.defaultSearch)
2702}
2703
2704// toggleViewMode switches between history and favorites.
2705func (o *Omnibox) toggleViewMode() {
2706 o.mu.RLock()
2707 current := o.viewMode
2708 o.mu.RUnlock()
2709
2710 if current == ViewModeHistory {
2711 o.setViewMode(ViewModeFavorites)
2712 } else {
2713 o.setViewMode(ViewModeHistory)
2714 }
2715}
2716
2717// setViewMode changes the view mode and updates UI.
2718func (o *Omnibox) setViewMode(mode ViewMode) {
2719 o.mu.Lock()
2720 o.viewMode = mode
2721 o.mu.Unlock()
2722
2723 // Update header button styling
2724 if mode == ViewModeHistory {
2725 o.historyBtn.AddCssClass("omnibox-header-active")
2726 o.favoritesBtn.RemoveCssClass("omnibox-header-active")
2727 } else {
2728 o.historyBtn.RemoveCssClass("omnibox-header-active")
2729 o.favoritesBtn.AddCssClass("omnibox-header-active")
2730 }
2731
2732 // Reload data
2733 o.performSearch()
2734}
2735
2736// Show opens the omnibox with optional initial query.
2737func (o *Omnibox) Show(ctx context.Context, query string) {
2738 log := logging.FromContext(ctx)
2739 log.Debug().Str("query", query).Msg("showing omnibox")
2740
2741 o.mu.Lock()
2742 if o.visible {
2743 o.mu.Unlock()
2744 return
2745 }
2746 o.visible = true
2747 // Initialize ghost text state
2748 o.realInput = query
2749 o.ghostSuffix = ""
2750
2751 o.insertCompletion = false
2752 o.mu.Unlock()
2753
2754 // Set initial query
2755 o.entry.SetText(query)
2756 o.entry.SelectRegion(-1, -1)
2757
2758 // Keep the results container hidden until real rows arrive.
2759 // Pre-showing an empty, expanding scroller renders a dead area in standalone mode.
2760 if o.scrolledWin != nil {
2761 o.setResultsContainerState(0)
2762 // Reset content height constraints - will be updated when results arrive
2763 o.scrolledWin.SetMinContentHeight(-1)
2764 o.scrolledWin.SetMaxContentHeight(0)
2765 }
2766
2767 // Calculate dimensions using shared helper
2768 parentWidth, parentHeight := 0, 0
2769 if o.parentOverlay != nil {
2770 parentWidth = o.parentOverlay.GetAllocatedWidth()
2771 parentHeight = o.parentOverlay.GetAllocatedHeight()
2772 }
2773 width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2774 log.Debug().
2775 Int("parentWidth", parentWidth).
2776 Int("parentHeight", parentHeight).
2777 Int("width", width).
2778 Int("marginTop", marginTop).
2779 Int("fixedWidth", o.sizeCfg.FixedWidth).
2780 Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin).
2781 Int("fixedTopMargin", o.sizeCfg.FixedTopMargin).
2782 Float64("widthPct", o.sizeCfg.WidthPct).
2783 Float64("topMarginPct", o.sizeCfg.TopMarginPct).
2784 Msg("omnibox geometry decision")
2785
2786 o.mainBox.SetSizeRequest(width, -1)
2787 o.outerBox.SetMarginTop(marginTop)
2788
2789 // Show the omnibox
2790 o.outerBox.SetVisible(true)
2791
2792 // Trigger initial resize (will be updated when results arrive)
2793 o.resizeAndCenter(0)
2794
2795 // Focus the entry
2796 o.entry.GrabFocus()
2797 o.resetSearchSessionState()
2798
2799 // Load initial data (may update size later if results found)
2800 o.performSearch()
2801}
2802
2803// Hide closes the omnibox.
2804func (o *Omnibox) Hide(ctx context.Context) {
2805 log := logging.FromContext(ctx)
2806 log.Debug().Msg("hiding omnibox")
2807
2808 o.mu.Lock()
2809 if !o.visible {
2810 o.mu.Unlock()
2811 return
2812 }
2813 o.visible = false
2814 o.mu.Unlock()
2815
2816 // Cancel any pending search
2817 o.debounceMu.Lock()
2818 if o.debounceTimer != nil {
2819 o.debounceTimer.Stop()
2820 o.debounceTimer = nil
2821 }
2822 o.debounceMu.Unlock()
2823
2824 // Hide omnibox
2825 o.outerBox.SetVisible(false)
2826
2827 // Clear state
2828 o.clearBangState()
2829 o.clearGhostText()
2830 o.entry.SetText("")
2831 o.listBox.RemoveAll()
2832
2833 // Reset state
2834 o.mu.Lock()
2835 o.realInput = ""
2836 o.insertCompletion = false
2837 o.mu.Unlock()
2838 o.resetSearchSessionState()
2839
2840 if o.onClose != nil {
2841 o.onClose()
2842 }
2843}
2844
2845func (o *Omnibox) resetSearchSessionState() {
2846 o.debounceMu.Lock()
2847 o.lastQuery = ""
2848 o.debounceMu.Unlock()
2849}
2850
2851// Toggle shows if hidden, hides if visible.
2852func (o *Omnibox) Toggle(ctx context.Context) {
2853 o.mu.RLock()
2854 visible := o.visible
2855 o.mu.RUnlock()
2856
2857 if visible {
2858 o.Hide(ctx)
2859 } else {
2860 o.Show(ctx, "")
2861 }
2862}
2863
2864// IsVisible returns whether the omnibox is currently shown.
2865func (o *Omnibox) IsVisible() bool {
2866 o.mu.RLock()
2867 defer o.mu.RUnlock()
2868 return o.visible
2869}
2870
2871// SetOnNavigate sets the callback for URL navigation.
2872func (o *Omnibox) SetOnNavigate(fn func(ctx context.Context, url string) error) {
2873 o.onNavigate = fn
2874}
2875
2876// SetOnClose sets the callback for omnibox close events.
2877func (o *Omnibox) SetOnClose(fn func()) {
2878 o.onClose = fn
2879}
2880
2881// UpdateZoomIndicator updates the zoom percentage display.
2882// Shows the indicator when zoom != 100%, hides it when at 100%.
2883func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2884 var cb glib.SourceFunc = func(data uintptr) bool {
2885 if o.zoomLabel == nil {
2886 return false
2887 }
2888 if factor == 1.0 {
2889 o.zoomLabel.SetVisible(false)
2890 } else {
2891 percentage := int(factor * 100)
2892 o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
2893 o.zoomLabel.SetVisible(true)
2894 }
2895 return false
2896 }
2897 glib.IdleAdd(&cb, 0)
2898}
2899
2900// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2901func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) {
2902 var cb glib.SourceFunc = func(data uintptr) bool {
2903 if !o.isSearchTokenCurrent(token) {
2904 return false
2905 }
2906 o.updateSuggestions(suggestions, query)
2907 return false // One-shot callback
2908 }
2909 glib.IdleAdd(&cb, 0)
2910}
2911
2912// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
2913// Returns empty map on error to avoid blocking history display.
2914func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2915 if o.favoritesUC == nil {
2916 return make(map[string]struct{})
2917 }
2918 urls, err := o.favoritesUC.GetAllURLs(ctx)
2919 if err != nil {
2920 log := logging.FromContext(ctx)
2921 log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
2922 return make(map[string]struct{})
2923 }
2924 return urls
2925}
2926
2927// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2928func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) {
2929 var cb glib.SourceFunc = func(data uintptr) bool {
2930 if !o.isSearchTokenCurrent(token) {
2931 return false
2932 }
2933 o.updateFavorites(favorites, query)
2934 return false // One-shot callback, return false to remove source
2935 }
2936 glib.IdleAdd(&cb, 0)
2937}
2938
2939func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2940 o.mu.RLock()
2941 defer o.mu.RUnlock()
2942 return o.visible && o.searchToken == token
2943}
2944
2945func (o *Omnibox) isBangUpdateCurrent(query string, token uint64) bool {
2946 o.mu.RLock()
2947 defer o.mu.RUnlock()
2948 return o.visible && o.searchToken == token && o.realInput == query
2949}
2950
2951// Widget returns the omnibox widget for embedding in an overlay.
2952func (o *Omnibox) Widget() *gtk.Widget {
2953 if o.outerBox == nil {
2954 return nil
2955 }
2956 return &o.outerBox.Widget
2957}
2958
2959// Destroy cleans up omnibox resources.
2960func (o *Omnibox) Destroy() {
2961 o.debounceMu.Lock()
2962 if o.debounceTimer != nil {
2963 o.debounceTimer.Stop()
2964 }
2965 o.debounceMu.Unlock()
2966
2967 o.mu.Lock()
2968 o.visible = false
2969 o.mu.Unlock()
2970
2971 o.parentOverlay = nil
2972 o.retainedCallbacks = nil
2973 o.clickOutsideController = nil
2974 o.clickOutsideCb = nil
2975 if o.idleCoalescer != nil {
2976 o.idleCoalescer.Destroy()
2977 }
2978
2979 if o.outerBox != nil {
2980 o.outerBox.Unparent()
2981 o.outerBox = nil
2982 }
2983}
2984
2985// formatShortcut formats a shortcut number.
2986func formatShortcut(n int) string {
2987 return "Ctrl+" + string(rune('0'+n))
2988}
2989
2990// measureWidgetHeight returns the natural height of a widget for the given width.
2991// Returns 0 if widget is nil or measurement fails.
2992func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
2993 if widget == nil {
2994 return 0
2995 }
2996 var minHeight, naturalHeight, minBaseline, naturalBaseline int
2997 widget.Measure(
2998 gtk.OrientationVerticalValue,
2999 forWidth,
3000 &minHeight,
3001 &naturalHeight,
3002 &minBaseline,
3003 &naturalBaseline,
3004 )
3005 return naturalHeight
3006}
3007
3008// measureComponentHeights measures and caches all component heights.
3009// Must be called on GTK main thread after widgets have been laid out.
3010func (o *Omnibox) measureComponentHeights(forWidth int) bool {
3011 if o.headerBox == nil || o.entry == nil || o.listBox == nil {
3012 return false
3013 }
3014
3015 headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
3016 entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
3017
3018 // Measure first row if it exists
3019 var rowH int
3020 if row := o.listBox.GetRowAtIndex(0); row != nil {
3021 rowH = measureWidgetHeight(&row.Widget, forWidth)
3022 }
3023
3024 // Validate measurements (header and entry must be positive)
3025 if headerH <= 0 || entryH <= 0 {
3026 return false
3027 }
3028
3029 o.measuredHeights.header = headerH
3030 o.measuredHeights.entry = entryH
3031 if rowH > 0 {
3032 o.measuredHeights.singleRow = rowH
3033 o.measuredHeights.valid = true
3034 }
3035 return true
3036}
3037
3038// errNilWidget creates an error for nil widget creation.
3039func errNilWidget(name string) error {
3040 return &widgetError{name: name}
3041}
3042
3043type widgetError struct {
3044 name string
3045}
3046
3047func (e *widgetError) Error() string {
3048 return "failed to create widget: " + e.name
3049}