omnibox.go RawHistoryFolder

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

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