v0.32.0 0617278

omnibox.go RawHistoryFolder

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

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