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