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