v0.30.0 eb3068f

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

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