v0.31.0 bf0756b

omnibox.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close