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