v0.29.0 21b3150

omnibox.go RawHistoryFolder

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

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