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