v0.33.2 57f21d6

omnibox.go RawHistoryFolder

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

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