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