v0.26.0 ae3a77a

omnibox.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"fmt"
6	"math"
7	"strings"
8	"sync"
9	"time"
10	"unicode/utf8"
11
12	"github.com/bnema/dumber/internal/application/usecase"
13	"github.com/bnema/dumber/internal/domain/autocomplete"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/domain/url"
16	"github.com/bnema/dumber/internal/logging"
17	"github.com/bnema/dumber/internal/ui/adapter"
18	"github.com/bnema/dumber/internal/ui/input"
19	"github.com/bnema/dumber/internal/ui/layout"
20	"github.com/jwijenbergh/puregotk/v4/gdk"
21	"github.com/jwijenbergh/puregotk/v4/glib"
22	"github.com/jwijenbergh/puregotk/v4/graphene"
23	"github.com/jwijenbergh/puregotk/v4/gtk"
24	"github.com/jwijenbergh/puregotk/v4/pango"
25)
26
27const (
28	debounceDelayMs = 50
29	endBoxSpacing   = 6
30)
31
32// ViewMode distinguishes history search from favorites display.
33type ViewMode string
34
35const (
36	ViewModeHistory   ViewMode = "history"
37	ViewModeFavorites ViewMode = "favorites"
38)
39
40// Suggestion represents a search result from history.
41type Suggestion struct {
42	URL        string
43	Title      string
44	FaviconURL string
45	IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
46}
47
48// BangSuggestion represents a configured bang shortcut.
49// A bang is invoked by typing "!<key>".
50type BangSuggestion struct {
51	Key         string
52	Description string
53}
54
55// Favorite represents a bookmarked URL.
56type Favorite struct {
57	ID         int64
58	URL        string
59	Title      string
60	FaviconURL string
61	Position   int
62}
63
64// Omnibox is the native GTK4 address bar / command palette.
65type Omnibox struct {
66	// GTK widgets
67	outerBox     *gtk.Box // Outer container for positioning
68	mainBox      *gtk.Box // Main content box
69	headerBox    *gtk.Box
70	historyBtn   *gtk.Button
71	favoritesBtn *gtk.Button
72	zoomLabel    *gtk.Label
73	bangBadge    *gtk.Label
74	entry        *gtk.SearchEntry
75	entryOverlay *gtk.Overlay // Overlay for ghost text
76	ghostLabel   *gtk.Label   // Ghost text completion label
77	scrolledWin  *gtk.ScrolledWindow
78	listBox      *gtk.ListBox
79
80	// Parent overlay reference for sizing (set via SetParentOverlay)
81	parentOverlay layout.OverlayWidget
82
83	// State
84	mu              sync.RWMutex
85	visible         bool
86	viewMode        ViewMode
87	selectedIndex   int
88	suggestions     []Suggestion
89	favorites       []Favorite
90	bangSuggestions []BangSuggestion
91	bangMode        bool
92	detectedBang    string
93	hasNavigated    bool // true if user navigated with arrow keys (enables space to toggle favorite)
94
95	// Ghost text state
96	realInput        string // What user actually typed (without ghost suffix)
97	ghostSuffix      string // Completion suffix to display
98	ghostFullText    string // Full text that ghost completes to
99	hasGhostText     bool   // Quick check for Tab behavior
100	isAcceptingGhost bool   // Prevent re-trigger during acceptance
101
102	// Dependencies
103	historyUC       *usecase.SearchHistoryUseCase
104	favoritesUC     *usecase.ManageFavoritesUseCase
105	faviconAdapter  *adapter.FaviconAdapter
106	copyURLUC       *usecase.CopyURLUseCase
107	shortcutsUC     *usecase.SearchShortcutsUseCase
108	autocompleteUC  *usecase.AutocompleteUseCase
109	defaultSearch   string
110	initialBehavior string
111	ctx             context.Context
112
113	// Callbacks
114	onNavigate func(url string)
115	onClose    func()
116	onToast    func(ctx context.Context, message string, level ToastLevel)
117
118	// Debouncing
119	debounceTimer *time.Timer
120	debounceMu    sync.Mutex
121	lastQuery     string // Prevent duplicate searches
122
123	// Scaling
124	uiScale float64
125
126	// Cached measurements (populated after first layout)
127	measuredHeights struct {
128		header    int  // headerBox natural height
129		entry     int  // entry natural height
130		singleRow int  // single ListBoxRow natural height
131		valid     bool // whether cache is valid
132	}
133
134	// Callback retention: keep GTK signal callbacks reachable by Go GC.
135	retainedCallbacks []interface{}
136
137	// Click outside handler
138	clickOutsideController *gtk.GestureClick
139	clickOutsideCb         func(gtk.GestureClick, int, float64, float64)
140}
141
142// OmniboxConfig holds configuration for creating an Omnibox.
143type OmniboxConfig struct {
144	HistoryUC       *usecase.SearchHistoryUseCase
145	FavoritesUC     *usecase.ManageFavoritesUseCase
146	FaviconAdapter  *adapter.FaviconAdapter
147	CopyURLUC       *usecase.CopyURLUseCase
148	ShortcutsUC     *usecase.SearchShortcutsUseCase
149	AutocompleteUC  *usecase.AutocompleteUseCase
150	DefaultSearch   string
151	InitialBehavior string
152	UIScale         float64                                                     // UI scale for favicon sizing
153	OnNavigate      func(url string)                                            // Callback when user navigates via omnibox
154	OnToast         func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
155	OnFocusIn       func(entry *gtk.SearchEntry)                                // Callback when entry gains focus (for accent picker)
156	OnFocusOut      func()                                                      // Callback when entry loses focus
157}
158
159// NewOmnibox creates a new native GTK4 omnibox widget.
160// Call SetParentOverlay() before Show() to set the parent for sizing.
161func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
162	log := logging.FromContext(ctx)
163
164	uiScale := cfg.UIScale
165	if uiScale <= 0 {
166		uiScale = 1.0
167	}
168
169	o := &Omnibox{
170		viewMode:        ViewModeHistory,
171		selectedIndex:   -1,
172		historyUC:       cfg.HistoryUC,
173		favoritesUC:     cfg.FavoritesUC,
174		faviconAdapter:  cfg.FaviconAdapter,
175		copyURLUC:       cfg.CopyURLUC,
176		shortcutsUC:     cfg.ShortcutsUC,
177		autocompleteUC:  cfg.AutocompleteUC,
178		defaultSearch:   cfg.DefaultSearch,
179		initialBehavior: cfg.InitialBehavior,
180		onToast:         cfg.OnToast,
181		ctx:             ctx,
182		uiScale:         uiScale,
183	}
184
185	if err := o.createWidgets(); err != nil {
186		log.Error().Err(err).Msg("failed to create omnibox widgets")
187		return nil
188	}
189
190	o.setupKeyboardHandling()
191	o.setupEntryChanged()
192	o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
193
194	// Set navigation callback if provided
195	if cfg.OnNavigate != nil {
196		o.onNavigate = cfg.OnNavigate
197	}
198
199	log.Debug().Msg("omnibox created")
200	return o
201}
202
203// SetParentOverlay sets the overlay widget used for sizing calculations.
204// Must be called before Show().
205func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
206	o.mu.Lock()
207	defer o.mu.Unlock()
208	o.parentOverlay = overlay
209	o.setupClickOutsideHandler()
210}
211
212// setupClickOutsideHandler attaches a click gesture to the parent overlay
213// that hides the omnibox when clicking outside its bounds.
214func (o *Omnibox) setupClickOutsideHandler() {
215	if o.parentOverlay == nil {
216		return
217	}
218
219	log := logging.FromContext(o.ctx)
220
221	// Create gesture click controller
222	o.clickOutsideController = gtk.NewGestureClick()
223	if o.clickOutsideController == nil {
224		log.Error().Msg("failed to create click outside controller")
225		return
226	}
227
228	// Listen to primary button only
229	o.clickOutsideController.SetButton(1) // GDK_BUTTON_PRIMARY
230
231	// Set capture phase so we see clicks before they reach the omnibox
232	o.clickOutsideController.SetPropagationPhase(gtk.PhaseCaptureValue)
233
234	// Connect pressed handler
235	o.clickOutsideCb = func(_ gtk.GestureClick, _ int, x, y float64) {
236		o.handleClickOutside(x, y)
237	}
238	o.clickOutsideController.ConnectPressed(&o.clickOutsideCb)
239
240	// Add controller to parent overlay
241	gtkWidget := o.parentOverlay.GtkWidget()
242	if gtkWidget != nil {
243		gtkWidget.AddController(&o.clickOutsideController.EventController)
244	}
245
246	log.Debug().Msg("click outside handler attached to parent overlay")
247}
248
249// handleClickOutside checks if a click is outside the omnibox bounds and hides if so.
250func (o *Omnibox) handleClickOutside(clickX, clickY float64) {
251	o.mu.RLock()
252	visible := o.visible
253	o.mu.RUnlock()
254
255	if !visible || o.outerBox == nil || o.parentOverlay == nil {
256		return
257	}
258
259	// Get omnibox position relative to parent overlay using graphene points
260	srcPoint := &graphene.Point{X: 0, Y: 0}
261	outPoint := &graphene.Point{}
262	ok := o.outerBox.ComputePoint(o.parentOverlay.GtkWidget(), srcPoint, outPoint)
263	if !ok {
264		return
265	}
266
267	boxX := float64(outPoint.X)
268	boxY := float64(outPoint.Y)
269
270	// Get omnibox dimensions
271	boxWidth := float64(o.outerBox.GetAllocatedWidth())
272	boxHeight := float64(o.outerBox.GetAllocatedHeight())
273
274	// Check if click is inside omnibox bounds
275	isInside := clickX >= boxX && clickX <= boxX+boxWidth &&
276		clickY >= boxY && clickY <= boxY+boxHeight
277
278	if !isInside {
279		logging.FromContext(o.ctx).Debug().
280			Float64("clickX", clickX).
281			Float64("clickY", clickY).
282			Float64("boxX", boxX).
283			Float64("boxY", boxY).
284			Float64("boxWidth", boxWidth).
285			Float64("boxHeight", boxHeight).
286			Msg("click outside omnibox, hiding")
287		o.Hide(o.ctx)
288	}
289}
290
291// WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
292// This is useful for adding the omnibox to a PaneView overlay.
293func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
294	if o.outerBox == nil {
295		return nil
296	}
297	return factory.WrapWidget(&o.outerBox.Widget)
298}
299
300// resizeAndCenter adjusts the omnibox size based on content and centers it.
301// rowCount is the number of result rows to display (0 = no content, max 10).
302func (o *Omnibox) resizeAndCenter(rowCount int) {
303	if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil {
304		return
305	}
306
307	// Calculate width using shared helper
308	width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
309
310	// Cap at max results
311	if rowCount > OmniboxListDefaults.MaxVisibleRows {
312		rowCount = OmniboxListDefaults.MaxVisibleRows
313	}
314
315	// Schedule measurement after GTK has laid out widgets
316	// This ensures we get accurate heights from actual rendered content
317	var cb glib.SourceFunc = func(uintptr) bool {
318		o.measureAndResize(width, rowCount)
319		return false // One-shot
320	}
321	glib.IdleAdd(&cb, 0)
322}
323
324// measureAndResize calculates and sets the omnibox height based on row count.
325// Uses measured widget heights when available, falls back to estimates.
326func (o *Omnibox) measureAndResize(width, rowCount int) {
327	if o.outerBox == nil || o.mainBox == nil {
328		return
329	}
330
331	log := logging.FromContext(o.ctx)
332
333	var rowHeight int
334
335	if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
336		// Use measured values from GTK4 Measure API
337		rowHeight = o.measuredHeights.singleRow
338	} else {
339		// Fallback to scaled estimate (before first measurement)
340		rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
341	}
342
343	// Calculate content height for the scrolled window
344	// This is the key to dynamic sizing - limit the ScrolledWindow's max height
345	contentHeight := 0
346	if rowCount > 0 {
347		contentHeight = rowCount * rowHeight
348	}
349
350	// Cap at max results
351	maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
352	if contentHeight > maxContentHeight {
353		contentHeight = maxContentHeight
354	}
355
356	// Set size constraints on ScrolledWindow using shared helper
357	SetScrolledWindowHeight(o.scrolledWin, contentHeight)
358
359	// Force layout recalculation
360	o.outerBox.QueueResize()
361
362	log.Debug().
363		Int("width", width).
364		Int("contentHeight", contentHeight).
365		Int("rowHeight", rowHeight).
366		Int("rows", rowCount).
367		Bool("measured", o.measuredHeights.valid).
368		Msg("omnibox resized")
369}
370
371// createWidgets builds the GTK widget hierarchy.
372func (o *Omnibox) createWidgets() error {
373	if err := o.initOuterBox(); err != nil {
374		return err
375	}
376	if err := o.initMainBox(); err != nil {
377		return err
378	}
379	if err := o.initHeader(); err != nil {
380		return err
381	}
382	if err := o.initEntry(); err != nil {
383		return err
384	}
385	if err := o.initList(); err != nil {
386		return err
387	}
388	o.assembleWidgets()
389	return nil
390}
391
392// setupKeyboardHandling adds keyboard event handling.
393func (o *Omnibox) setupKeyboardHandling() {
394	log := logging.FromContext(o.ctx)
395	controller := gtk.NewEventControllerKey()
396	if controller == nil {
397		log.Error().Msg("failed to create event controller key")
398		return
399	}
400
401	// Set capture phase to intercept before entry
402	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
403
404	keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
405		return o.handleKeyPress(keyval, keycode, state)
406	}
407	o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
408	controller.ConnectKeyPressed(&keyPressedCb)
409
410	o.outerBox.AddController(&controller.EventController)
411}
412
413func (o *Omnibox) initOuterBox() error {
414	o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
415	if o.outerBox == nil {
416		return errNilWidget("outerBox")
417	}
418	o.outerBox.AddCssClass("omnibox-outer")
419	o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
420	o.outerBox.SetValign(gtk.AlignStartValue)  // Align to top
421	o.outerBox.SetVisible(false)               // Hidden by default
422	return nil
423}
424
425func (o *Omnibox) initMainBox() error {
426	o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
427	if o.mainBox == nil {
428		return errNilWidget("mainBox")
429	}
430	o.mainBox.AddCssClass("omnibox-container")
431	return nil
432}
433
434func (o *Omnibox) initHeader() error {
435	o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
436	if o.headerBox == nil {
437		return errNilWidget("headerBox")
438	}
439	o.headerBox.AddCssClass("omnibox-header")
440	o.headerBox.SetHexpand(true)
441
442	o.historyBtn = gtk.NewButtonWithLabel("History")
443	if o.historyBtn == nil {
444		return errNilWidget("historyBtn")
445	}
446	o.historyBtn.AddCssClass("omnibox-header-btn")
447	o.historyBtn.AddCssClass("omnibox-header-active")
448	o.historyBtn.SetCanFocus(false)
449
450	o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
451	if o.favoritesBtn == nil {
452		return errNilWidget("favoritesBtn")
453	}
454	o.favoritesBtn.AddCssClass("omnibox-header-btn")
455	o.favoritesBtn.SetCanFocus(false)
456
457	historyClickCb := func(_ gtk.Button) {
458		o.setViewMode(ViewModeHistory)
459	}
460	o.retainedCallbacks = append(o.retainedCallbacks, historyClickCb)
461	o.historyBtn.ConnectClicked(&historyClickCb)
462
463	favoritesClickCb := func(_ gtk.Button) {
464		o.setViewMode(ViewModeFavorites)
465	}
466	o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
467	o.favoritesBtn.ConnectClicked(&favoritesClickCb)
468
469	o.bangBadge = gtk.NewLabel(nil)
470	if o.bangBadge != nil {
471		o.bangBadge.AddCssClass("omnibox-bang-badge")
472		o.bangBadge.SetVisible(false)
473	}
474
475	o.zoomLabel = gtk.NewLabel(nil)
476	if o.zoomLabel != nil {
477		o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
478		o.zoomLabel.SetVisible(false)
479	}
480	return nil
481}
482
483func (o *Omnibox) initEntry() error {
484	o.entry = gtk.NewSearchEntry()
485	if o.entry == nil {
486		return errNilWidget("entry")
487	}
488	o.entry.AddCssClass("omnibox-entry")
489	o.entry.SetHexpand(true)
490
491	placeholder := "Search history or enter URL… (! lists bangs)"
492	o.entry.SetPlaceholderText(&placeholder)
493
494	// Create overlay for ghost text
495	o.entryOverlay = gtk.NewOverlay()
496	if o.entryOverlay == nil {
497		return errNilWidget("entryOverlay")
498	}
499	o.entryOverlay.SetChild(&o.entry.Widget)
500	o.entryOverlay.SetHexpand(true)
501
502	// Create ghost label for autocomplete suffix
503	o.ghostLabel = gtk.NewLabel(nil)
504	if o.ghostLabel == nil {
505		return errNilWidget("ghostLabel")
506	}
507	o.ghostLabel.AddCssClass("omnibox-ghost")
508	o.ghostLabel.SetHalign(gtk.AlignStartValue)
509	o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
510	o.ghostLabel.SetXalign(0)
511	o.ghostLabel.SetVisible(false)
512	o.ghostLabel.SetCanTarget(false)                   // Don't intercept clicks
513	o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
514
515	o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
516	o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
517
518	return nil
519}
520
521func (o *Omnibox) initList() error {
522	o.scrolledWin = gtk.NewScrolledWindow()
523	if o.scrolledWin == nil {
524		return errNilWidget("scrolledWin")
525	}
526	o.scrolledWin.AddCssClass("omnibox-scrolled")
527	o.scrolledWin.SetVexpand(true)
528	o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
529	o.scrolledWin.SetPropagateNaturalHeight(true)
530
531	o.listBox = gtk.NewListBox()
532	if o.listBox == nil {
533		return errNilWidget("listBox")
534	}
535	o.listBox.AddCssClass("omnibox-listbox")
536	o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
537
538	rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
539		if rowPtr == 0 {
540			o.mu.Lock()
541			o.selectedIndex = -1
542			o.mu.Unlock()
543			return
544		}
545		row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
546		if row != nil {
547			o.mu.Lock()
548			o.selectedIndex = row.GetIndex()
549			o.mu.Unlock()
550		}
551	}
552	o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
553	o.listBox.ConnectRowSelected(&rowSelectedCb)
554
555	// Handle row activation (click or Enter) - navigate directly to the URL
556	rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
557		if rowPtr == 0 {
558			return
559		}
560		row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
561		if row == nil {
562			return
563		}
564		idx := row.GetIndex()
565		o.mu.RLock()
566		mode := o.viewMode
567		bangMode := o.bangMode
568		suggestions := o.suggestions
569		favorites := o.favorites
570		o.mu.RUnlock()
571
572		// Don't navigate in bang mode - activating a bang should fill it in
573		if bangMode {
574			return
575		}
576
577		var targetURL string
578		if mode == ViewModeHistory {
579			if idx >= 0 && idx < len(suggestions) {
580				targetURL = suggestions[idx].URL
581			}
582		} else {
583			if idx >= 0 && idx < len(favorites) {
584				targetURL = favorites[idx].URL
585			}
586		}
587
588		if targetURL != "" && o.onNavigate != nil {
589			o.Hide(o.ctx)
590			o.onNavigate(targetURL)
591		}
592	}
593	o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
594	o.listBox.ConnectRowActivated(&rowActivatedCb)
595
596	return nil
597}
598
599func (o *Omnibox) assembleWidgets() {
600	o.headerBox.Append(&o.historyBtn.Widget)
601	o.headerBox.Append(&o.favoritesBtn.Widget)
602
603	endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
604	if endBox != nil {
605		endBox.SetHexpand(true)
606		endBox.SetHalign(gtk.AlignEndValue)
607		if o.bangBadge != nil {
608			endBox.Append(&o.bangBadge.Widget)
609		}
610		if o.zoomLabel != nil {
611			endBox.Append(&o.zoomLabel.Widget)
612		}
613		o.headerBox.Append(&endBox.Widget)
614	}
615
616	o.scrolledWin.SetChild(&o.listBox.Widget)
617	o.mainBox.Append(&o.headerBox.Widget)
618	o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
619	o.mainBox.Append(&o.scrolledWin.Widget)
620	o.outerBox.Append(&o.mainBox.Widget)
621}
622
623// setupEntryChanged wires entry text changes to debounced search.
624func (o *Omnibox) setupEntryChanged() {
625	// SearchEntry has built-in debouncing via search-changed signal
626	changedCb := func(_ gtk.SearchEntry) {
627		o.onEntryChanged()
628	}
629	o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
630	o.entry.ConnectSearchChanged(&changedCb)
631}
632
633// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
634func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
635	if onFocusIn == nil && onFocusOut == nil {
636		return
637	}
638
639	focusController := gtk.NewEventControllerFocus()
640	if focusController == nil {
641		return
642	}
643
644	if onFocusIn != nil {
645		entry := o.entry // Capture for closure
646		focusInCb := func(_ gtk.EventControllerFocus) {
647			onFocusIn(entry)
648		}
649		o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
650		focusController.ConnectEnter(&focusInCb)
651	}
652
653	if onFocusOut != nil {
654		focusOutCb := func(_ gtk.EventControllerFocus) {
655			onFocusOut()
656		}
657		o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
658		focusController.ConnectLeave(&focusOutCb)
659	}
660
661	o.entry.AddController(&focusController.EventController)
662}
663
664// hasGhost returns whether ghost text is currently visible.
665func (o *Omnibox) hasGhost() bool {
666	o.mu.RLock()
667	defer o.mu.RUnlock()
668	return o.hasGhostText
669}
670
671// hasUserNavigated returns whether user has navigated with arrow keys.
672func (o *Omnibox) hasUserNavigated() bool {
673	o.mu.RLock()
674	defer o.mu.RUnlock()
675	return o.hasNavigated
676}
677
678// handleKeyPress processes keyboard events.
679// Returns true if the event was handled.
680func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
681	ctrl := state&gdk.ControlMaskValue != 0
682
683	switch keyval {
684	case uint(gdk.KEY_Escape):
685		text := o.entry.GetText()
686		if text != "" {
687			o.entry.SetText("")
688			return true
689		}
690		o.Hide(o.ctx)
691		return true
692
693	case uint(gdk.KEY_l), uint(gdk.KEY_L):
694		// Ctrl+L toggles omnibox (hides when visible)
695		if ctrl {
696			o.Hide(o.ctx)
697			return true
698		}
699
700	case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
701		o.navigateToSelected()
702		return true
703
704	case uint(gdk.KEY_Up):
705		o.selectPrevious()
706		o.updateGhostFromSelection()
707		return true
708
709	case uint(gdk.KEY_Down):
710		o.selectNext()
711		o.updateGhostFromSelection()
712		return true
713
714	case uint(gdk.KEY_Tab):
715		// Tab accepts ghost text if present, otherwise toggles view mode
716		if o.hasGhost() {
717			o.acceptGhostCompletion()
718			return true
719		}
720		o.toggleViewMode()
721		return true
722
723	case uint(gdk.KEY_Right):
724		// Arrow Right accepts ghost text if present
725		if o.hasGhost() {
726			o.acceptGhostCompletion()
727			return true
728		}
729		return false // Let entry handle normal cursor movement
730
731	case uint(gdk.KEY_space):
732		// Space toggles favorite only if user has navigated with arrow keys
733		if o.hasUserNavigated() {
734			o.toggleFavorite()
735			return true
736		}
737		return false // Let entry handle space for typing
738
739	case uint(gdk.KEY_y):
740		// 'y' yanks (copies) the selected URL to clipboard when navigating
741		if o.hasUserNavigated() {
742			o.yankSelectedURL()
743			return true
744		}
745		return false // Let entry handle 'y' for typing
746
747	default:
748		return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
749	}
750
751	return false // Let entry handle the key
752}
753
754// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
755// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
756func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
757	if !ctrl {
758		return false
759	}
760
761	const tenthItemIndex = 9
762
763	// First try keyval (works for QWERTY layouts)
764	switch keyval {
765	case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
766		uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
767		idx := int(keyval - uint(gdk.KEY_1))
768		o.selectAndNavigate(idx)
769		return true
770	case uint(gdk.KEY_0):
771		o.selectAndNavigate(tenthItemIndex)
772		return true
773	}
774
775	// Fallback: use hardware keycode for non-QWERTY layouts
776	// This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
777	if index, ok := input.KeycodeToDigitIndex(keycode); ok {
778		log := logging.FromContext(o.ctx)
779		log.Debug().
780			Uint("keycode", keycode).
781			Int("index", index).
782			Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
783		o.selectAndNavigate(index)
784		return true
785	}
786
787	return false
788}
789
790// onEntryChanged handles text input changes with debouncing.
791func (o *Omnibox) onEntryChanged() {
792	// Skip if we're programmatically accepting ghost text
793	o.mu.RLock()
794	isAccepting := o.isAcceptingGhost
795	o.mu.RUnlock()
796	if isAccepting {
797		return
798	}
799
800	// Reset navigation state when user types - space should type, not toggle favorite
801	o.mu.Lock()
802	o.hasNavigated = false
803	// Update realInput with what user actually typed
804	o.realInput = o.entry.GetText()
805	o.mu.Unlock()
806
807	// Clear ghost text when user types (it will be recalculated after search)
808	o.clearGhostText()
809
810	o.debounceMu.Lock()
811	if o.debounceTimer != nil {
812		o.debounceTimer.Stop()
813	}
814	o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
815		o.performSearch()
816	})
817	o.debounceMu.Unlock()
818}
819
820// setGhostText displays a ghost completion suffix in the overlay label.
821// The ghost text shows what would complete the current input.
822// originalInput is the input that was used for the autocomplete query.
823// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
824func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
825	if o.ghostLabel == nil || suffix == "" {
826		o.clearGhostText()
827		return
828	}
829
830	// Store ghost state optimistically - will be validated on main thread
831	o.mu.Lock()
832	o.ghostSuffix = suffix
833	o.ghostFullText = fullText
834	o.hasGhostText = true
835	o.mu.Unlock()
836
837	var cb glib.SourceFunc = func(uintptr) bool {
838		if o.ghostLabel == nil || o.entry == nil {
839			return false
840		}
841
842		// Check if input has changed since the autocomplete query was made
843		// This check runs on GTK main thread where entry.GetText() is reliable
844		// Skip this check when originalInput is empty (row selection replaces full input)
845		if originalInput != "" && o.entry.GetText() != originalInput {
846			// Input changed, skip this stale result - a newer query will handle it
847			return false
848		}
849
850		// Get the entry's Pango context and create a layout to measure text width
851		pangoCtx := o.entry.GetPangoContext()
852		if pangoCtx == nil {
853			return false
854		}
855
856		// Create layout to measure the input text width
857		layout := pango.NewLayout(pangoCtx)
858		layout.SetText(originalInput, len(originalInput))
859
860		// Get the width in pixels
861		var widthPx, heightPx int
862		layout.GetPixelSize(&widthPx, &heightPx)
863
864		// Set the ghost label text (just the suffix) and position it horizontally
865		o.ghostLabel.SetText(suffix)
866
867		marginStart := widthPx
868		delegate := o.entry.GetDelegate()
869		if delegate != nil && o.entryOverlay != nil {
870			textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
871			if textWidget != nil {
872				var textBounds graphene.Rect
873				if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
874					cursorPos := uint(utf8.RuneCountInString(originalInput))
875					var strongRect, weakRect graphene.Rect
876					textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
877					cursorX := float64(textBounds.GetX() + strongRect.GetX())
878					marginStart = int(math.Round(cursorX))
879				}
880			}
881		}
882
883		// Clamp horizontal position to entry bounds
884		if marginStart < 0 {
885			marginStart = 0
886		}
887		if o.entryOverlay != nil {
888			var entryBounds graphene.Rect
889			if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
890				minStart := int(math.Round(float64(entryBounds.GetX())))
891				if marginStart < minStart {
892					marginStart = minStart
893				}
894			}
895		}
896
897		o.ghostLabel.SetMarginStart(marginStart)
898		// Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
899		o.ghostLabel.SetVisible(true)
900
901		// Hide placeholder text so it doesn't show through ghost text
902		emptyPlaceholder := ""
903		o.entry.SetPlaceholderText(&emptyPlaceholder)
904
905		return false
906	}
907	glib.IdleAdd(&cb, 0)
908}
909
910// clearGhostText hides the ghost completion text.
911func (o *Omnibox) clearGhostText() {
912	o.mu.Lock()
913	o.ghostSuffix = ""
914	o.ghostFullText = ""
915	o.hasGhostText = false
916	o.mu.Unlock()
917
918	if o.ghostLabel == nil {
919		return
920	}
921
922	var cb glib.SourceFunc = func(uintptr) bool {
923		if o.ghostLabel != nil {
924			o.ghostLabel.SetVisible(false)
925			o.ghostLabel.SetText("")
926		}
927		// Restore placeholder text
928		if o.entry != nil {
929			placeholder := "Search history or enter URL… (! lists bangs)"
930			o.entry.SetPlaceholderText(&placeholder)
931		}
932		return false
933	}
934	glib.IdleAdd(&cb, 0)
935}
936
937// acceptGhostCompletion accepts the ghost text and fills the input.
938func (o *Omnibox) acceptGhostCompletion() {
939	o.mu.Lock()
940	if !o.hasGhostText || o.ghostFullText == "" {
941		o.mu.Unlock()
942		return
943	}
944	fullText := o.ghostFullText
945	o.isAcceptingGhost = true
946	o.realInput = fullText
947	o.ghostSuffix = ""
948	o.ghostFullText = ""
949	o.hasGhostText = false
950	o.mu.Unlock()
951
952	// Update entry text and clear ghost label
953	o.entry.SetText(fullText)
954	o.entry.SetPosition(-1) // Move cursor to end
955
956	if o.ghostLabel != nil {
957		o.ghostLabel.SetVisible(false)
958	}
959
960	o.mu.Lock()
961	o.isAcceptingGhost = false
962	o.mu.Unlock()
963}
964
965// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
966// The query parameter is the search query that was used, ensuring we don't use stale input.
967func (o *Omnibox) updateGhostFromSuggestion(query string) {
968	if o.autocompleteUC == nil {
969		return
970	}
971
972	o.mu.RLock()
973	bangMode := o.bangMode
974	o.mu.RUnlock()
975
976	// Use the query that triggered this search, not realInput which may have changed
977	userInput := query
978
979	// Don't show ghost text in bang mode (it has its own completion)
980	if bangMode || userInput == "" {
981		o.clearGhostText()
982		return
983	}
984
985	go func() {
986		output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
987		if !output.Found || output.Suggestion == nil {
988			o.clearGhostText()
989			return
990		}
991		// Pass the original input so setGhostText can verify it's still current
992		o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
993	}()
994}
995
996// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
997// When a row is selected via arrow keys, show the URL as ghost text even if
998// the user's input isn't a prefix - this shows what Tab would fill in.
999func (o *Omnibox) updateGhostFromURL(targetURL string) {
1000	if targetURL == "" {
1001		o.clearGhostText()
1002		return
1003	}
1004
1005	o.mu.RLock()
1006	userInput := o.realInput
1007	o.mu.RUnlock()
1008
1009	// Strip protocol from URL for cleaner display
1010	displayURL := autocomplete.StripProtocol(targetURL)
1011
1012	// Try to compute proper suffix if input is a prefix of the URL
1013	if o.autocompleteUC != nil && userInput != "" {
1014		output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)
1015		if output.Found && output.Suggestion != nil {
1016			o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
1017			return
1018		}
1019	}
1020
1021	// If user has typed something but it's not a prefix match, don't show ghost text
1022	// This prevents confusing ghost text when scrolling through history
1023	if userInput != "" {
1024		o.clearGhostText()
1025		return
1026	}
1027
1028	// Only show full URL as ghost text when input is empty (initial state)
1029	o.setGhostText("", displayURL, displayURL)
1030}
1031
1032// updateGhostFromSelection updates ghost text based on the currently selected row.
1033func (o *Omnibox) updateGhostFromSelection() {
1034	o.mu.RLock()
1035	idx := o.selectedIndex
1036	mode := o.viewMode
1037	bangMode := o.bangMode
1038	suggestions := o.suggestions
1039	favorites := o.favorites
1040	o.mu.RUnlock()
1041
1042	// No ghost text in bang mode
1043	if bangMode {
1044		return
1045	}
1046
1047	var targetURL string
1048	if mode == ViewModeHistory {
1049		if idx >= 0 && idx < len(suggestions) {
1050			targetURL = suggestions[idx].URL
1051		}
1052	} else {
1053		if idx >= 0 && idx < len(favorites) {
1054			targetURL = favorites[idx].URL
1055		}
1056	}
1057
1058	if targetURL == "" {
1059		o.clearGhostText()
1060		return
1061	}
1062
1063	o.updateGhostFromURL(targetURL)
1064}
1065
1066// performSearch executes the search based on current view mode and query.
1067func (o *Omnibox) performSearch() {
1068	o.mu.RLock()
1069	visible := o.visible
1070	mode := o.viewMode
1071	o.mu.RUnlock()
1072
1073	// Skip search if omnibox is hidden
1074	if !visible {
1075		return
1076	}
1077
1078	query := o.entry.GetText()
1079
1080	// Skip duplicate queries
1081	o.debounceMu.Lock()
1082	if query == o.lastQuery && query != "" {
1083		o.debounceMu.Unlock()
1084		return
1085	}
1086	o.lastQuery = query
1087	o.debounceMu.Unlock()
1088
1089	if strings.HasPrefix(query, "!") {
1090		o.updateBangDetection(query)
1091		o.loadBangSuggestions(query)
1092		return
1093	}
1094
1095	o.clearBangState()
1096
1097	if mode == ViewModeFavorites {
1098		o.loadFavorites(query)
1099		return
1100	}
1101
1102	// History search
1103	if query == "" {
1104		o.loadInitialHistory()
1105		return
1106	}
1107
1108	// Perform fuzzy search
1109	go func() {
1110		ctx := o.ctx
1111		log := logging.FromContext(ctx)
1112		if o.historyUC == nil {
1113			return
1114		}
1115
1116		// Run history search and favorite URL fetch in parallel
1117		type searchResult struct {
1118			output *usecase.SearchOutput
1119			err    error
1120		}
1121		searchCh := make(chan searchResult, 1)
1122		favCh := make(chan map[string]struct{}, 1)
1123
1124		go func() {
1125			searchInput := usecase.SearchInput{
1126				Query: query,
1127				Limit: OmniboxListDefaults.MaxResults,
1128			}
1129			output, err := o.historyUC.Search(ctx, searchInput)
1130			searchCh <- searchResult{output, err}
1131		}()
1132
1133		go func() {
1134			favCh <- o.getFavoriteURLs(ctx)
1135		}()
1136
1137		// Wait for both results
1138		sr := <-searchCh
1139		favoriteURLs := <-favCh
1140
1141		if sr.err != nil {
1142			log.Error().Err(sr.err).Msg("history search failed")
1143			return
1144		}
1145		if sr.output == nil {
1146			return
1147		}
1148
1149		suggestions := make([]Suggestion, 0, len(sr.output.Matches))
1150		for _, r := range sr.output.Matches {
1151			_, isFav := favoriteURLs[r.Entry.URL]
1152			suggestions = append(suggestions, Suggestion{
1153				URL:        r.Entry.URL,
1154				Title:      r.Entry.Title,
1155				IsFavorite: isFav,
1156			})
1157		}
1158
1159		// Marshal back to GTK main thread, passing query to ensure ghost text uses correct input
1160		o.idleAddUpdateSuggestions(suggestions, query)
1161	}()
1162}
1163
1164// loadInitialHistory loads history based on InitialBehavior config.
1165func (o *Omnibox) loadInitialHistory() {
1166	if o.historyUC == nil {
1167		return
1168	}
1169
1170	go func() {
1171		ctx := o.ctx
1172		log := logging.FromContext(ctx)
1173		var suggestions []Suggestion
1174
1175		switch o.initialBehavior {
1176		case "none":
1177			o.idleAddUpdateSuggestions(nil, "")
1178			return
1179
1180		case "most_visited", "recent", "":
1181			// Run history fetch and favorite URL fetch in parallel
1182			type historyResult struct {
1183				results []*entity.HistoryEntry
1184				err     error
1185			}
1186			historyCh := make(chan historyResult, 1)
1187			favCh := make(chan map[string]struct{}, 1)
1188
1189			go func() {
1190				// TODO: Implement GetMostVisited in use case if needed
1191				results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
1192				historyCh <- historyResult{results, err}
1193			}()
1194
1195			go func() {
1196				favCh <- o.getFavoriteURLs(ctx)
1197			}()
1198
1199			// Wait for both results
1200			hr := <-historyCh
1201			favoriteURLs := <-favCh
1202
1203			if hr.err != nil {
1204				log.Error().Err(hr.err).Msg("failed to load recent history")
1205				return
1206			}
1207			if hr.results == nil {
1208				return
1209			}
1210
1211			suggestions = make([]Suggestion, 0, len(hr.results))
1212			for _, r := range hr.results {
1213				_, isFav := favoriteURLs[r.URL]
1214				suggestions = append(suggestions, Suggestion{
1215					URL:        r.URL,
1216					Title:      r.Title,
1217					IsFavorite: isFav,
1218				})
1219			}
1220		}
1221
1222		o.idleAddUpdateSuggestions(suggestions, "")
1223	}()
1224}
1225
1226// loadFavorites loads favorites, optionally filtered by query.
1227func (o *Omnibox) loadFavorites(query string) {
1228	if o.favoritesUC == nil {
1229		return
1230	}
1231
1232	go func() {
1233		ctx := o.ctx
1234		log := logging.FromContext(ctx)
1235		results, err := o.favoritesUC.GetAll(ctx)
1236		if err != nil {
1237			log.Error().Err(err).Msg("failed to load favorites")
1238			return
1239		}
1240
1241		favorites := make([]Favorite, 0, len(results))
1242		queryLower := strings.ToLower(query)
1243		for i, r := range results {
1244			// Filter by query if provided
1245			if query != "" {
1246				titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
1247				urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
1248				if !titleMatch && !urlMatch {
1249					continue
1250				}
1251			}
1252			favorites = append(favorites, Favorite{
1253				ID:       int64(r.ID),
1254				URL:      r.URL,
1255				Title:    r.Title,
1256				Position: i,
1257			})
1258		}
1259
1260		o.idleAddUpdateFavorites(favorites, query)
1261	}()
1262}
1263
1264func (o *Omnibox) loadBangSuggestions(query string) {
1265	if o.shortcutsUC == nil {
1266		return
1267	}
1268	output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query})
1269	suggestions := make([]BangSuggestion, len(output.Suggestions))
1270	for i, s := range output.Suggestions {
1271		suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
1272	}
1273	o.idleAddUpdateBangSuggestions(suggestions)
1274}
1275
1276func (o *Omnibox) updateBangDetection(query string) {
1277	if o.shortcutsUC == nil {
1278		o.clearDetectedBang()
1279		return
1280	}
1281	output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
1282	if output.Key == "" {
1283		o.clearDetectedBang()
1284		return
1285	}
1286
1287	o.setDetectedBang(output.Key, output.Description)
1288}
1289
1290func (o *Omnibox) clearBangState() {
1291	o.mu.Lock()
1292	o.bangMode = false
1293	o.bangSuggestions = nil
1294	o.mu.Unlock()
1295
1296	o.clearDetectedBang()
1297}
1298
1299func (o *Omnibox) setDetectedBang(key, description string) {
1300	o.mu.Lock()
1301	o.detectedBang = key
1302	o.mu.Unlock()
1303
1304	if o.bangBadge == nil {
1305		return
1306	}
1307	label := description
1308	var cb glib.SourceFunc = func(uintptr) bool {
1309		o.entry.AddCssClass("omnibox-entry-bang-active")
1310		o.bangBadge.SetText(label)
1311		o.bangBadge.SetVisible(true)
1312		return false
1313	}
1314	glib.IdleAdd(&cb, 0)
1315}
1316
1317func (o *Omnibox) clearDetectedBang() {
1318	o.mu.Lock()
1319	o.detectedBang = ""
1320	o.mu.Unlock()
1321
1322	if o.bangBadge == nil {
1323		return
1324	}
1325	var cb glib.SourceFunc = func(uintptr) bool {
1326		o.entry.RemoveCssClass("omnibox-entry-bang-active")
1327		o.bangBadge.SetVisible(false)
1328		return false
1329	}
1330	glib.IdleAdd(&cb, 0)
1331}
1332
1333func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
1334	var cb glib.SourceFunc = func(uintptr) bool {
1335		o.updateBangSuggestions(suggestions)
1336		return false
1337	}
1338	glib.IdleAdd(&cb, 0)
1339}
1340
1341func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1342	o.mu.Lock()
1343	o.bangMode = true
1344	o.bangSuggestions = suggestions
1345	o.selectedIndex = -1
1346	o.mu.Unlock()
1347
1348	o.rebuildList()
1349
1350	rowCount := len(suggestions)
1351	if o.scrolledWin != nil {
1352		o.scrolledWin.SetVisible(rowCount > 0)
1353	}
1354	o.resizeAndCenter(rowCount)
1355
1356	if rowCount > 0 {
1357		o.selectIndex(0)
1358	}
1359}
1360
1361// updateSuggestions updates the list with history suggestions.
1362// The query parameter is the search query that triggered this update.
1363func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1364	o.mu.Lock()
1365	o.bangMode = false
1366	o.bangSuggestions = nil
1367	o.suggestions = suggestions
1368	o.selectedIndex = -1
1369	o.mu.Unlock()
1370
1371	o.rebuildList()
1372
1373	// Hide scrolled window when there are no suggestions
1374	rowCount := len(suggestions)
1375	if o.scrolledWin != nil {
1376		o.scrolledWin.SetVisible(rowCount > 0)
1377	}
1378	o.resizeAndCenter(rowCount)
1379
1380	// Select first item if available and update ghost text
1381	if rowCount > 0 {
1382		o.selectIndex(0)
1383		// Update ghost text from first suggestion
1384		o.updateGhostFromSuggestion(query)
1385	} else {
1386		o.clearGhostText()
1387	}
1388}
1389
1390// updateFavorites updates the list with favorites.
1391// The query parameter is the search query that triggered this update.
1392func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1393	o.mu.Lock()
1394	o.bangMode = false
1395	o.bangSuggestions = nil
1396	o.favorites = favorites
1397	o.selectedIndex = -1
1398	o.mu.Unlock()
1399
1400	o.rebuildList()
1401
1402	// Hide scrolled window when there are no favorites
1403	rowCount := len(favorites)
1404	if o.scrolledWin != nil {
1405		o.scrolledWin.SetVisible(rowCount > 0)
1406	}
1407	o.resizeAndCenter(rowCount)
1408
1409	// Select first item if available and update ghost text
1410	if rowCount > 0 {
1411		o.selectIndex(0)
1412		// Update ghost text from first favorite
1413		o.updateGhostFromSuggestion(query)
1414	} else {
1415		o.clearGhostText()
1416	}
1417}
1418
1419// rebuildList rebuilds the ListBox contents.
1420func (o *Omnibox) rebuildList() {
1421	// Clear existing rows
1422	o.listBox.RemoveAll()
1423
1424	o.mu.RLock()
1425	mode := o.viewMode
1426	suggestions := o.suggestions
1427	favorites := o.favorites
1428	bangMode := o.bangMode
1429	bangSuggestions := o.bangSuggestions
1430	o.mu.RUnlock()
1431
1432	if bangMode {
1433		for i, b := range bangSuggestions {
1434			row := o.createBangRow(b, i)
1435			if row != nil {
1436				o.listBox.Append(&row.Widget)
1437			}
1438		}
1439	} else if mode == ViewModeHistory {
1440		for i, s := range suggestions {
1441			row := o.createSuggestionRow(s, i)
1442			if row != nil {
1443				o.listBox.Append(&row.Widget)
1444			}
1445		}
1446	} else {
1447		for i, f := range favorites {
1448			row := o.createFavoriteRow(f, i)
1449			if row != nil {
1450				o.listBox.Append(&row.Widget)
1451			}
1452		}
1453	}
1454
1455	// Schedule measurement if not yet cached and we have rows
1456	if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
1457		var cb glib.SourceFunc = func(uintptr) bool {
1458			if o.parentOverlay == nil {
1459				return false
1460			}
1461			width := o.parentOverlay.GetAllocatedWidth()
1462			if width <= 0 {
1463				return false // Overlay not allocated yet, skip
1464			}
1465			forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
1466			if o.measureComponentHeights(forWidth) {
1467				// Re-trigger resize with accurate measurements
1468				o.mu.RLock()
1469				var count int
1470				if o.bangMode {
1471					count = len(o.bangSuggestions)
1472				} else if o.viewMode == ViewModeHistory {
1473					count = len(o.suggestions)
1474				} else {
1475					count = len(o.favorites)
1476				}
1477				o.mu.RUnlock()
1478				o.measureAndResize(forWidth, count)
1479			}
1480			return false
1481		}
1482		glib.IdleAdd(&cb, 0)
1483	}
1484}
1485
1486// createFaviconImage creates a favicon image with async loading from cache.
1487func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1488	favicon := gtk.NewImage()
1489	if favicon == nil {
1490		return nil
1491	}
1492	favicon.SetFromIconName(&fallbackIcon)
1493	favicon.SetPixelSize(int(16 * o.uiScale))
1494	favicon.AddCssClass("omnibox-favicon")
1495
1496	// Async load favicon from cache
1497	if o.faviconAdapter != nil && rawURL != "" {
1498		o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
1499			if texture != nil {
1500				var cb glib.SourceFunc = func(data uintptr) bool {
1501					favicon.SetFromPaintable(texture)
1502					return false
1503				}
1504				glib.IdleAdd(&cb, 0)
1505			}
1506		})
1507	}
1508
1509	return favicon
1510}
1511
1512// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1513// Uses rawURL for both favicon fetching and display.
1514func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1515	return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1516}
1517
1518// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1519// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1520func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
1521	row := gtk.NewListBoxRow()
1522	if row == nil {
1523		return nil
1524	}
1525	row.AddCssClass("omnibox-row")
1526
1527	const rowSpacing = 8
1528	hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1529	if hbox == nil {
1530		return nil
1531	}
1532	hbox.SetHexpand(true)
1533
1534	// Favicon image (vertically centered)
1535	if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
1536		favicon.SetValign(gtk.AlignCenterValue)
1537		hbox.Append(&favicon.Widget)
1538	}
1539
1540	// Vertical box for title + URL
1541	textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
1542	if textBox == nil {
1543		return nil
1544	}
1545	textBox.SetHexpand(true)
1546	textBox.SetValign(gtk.AlignCenterValue)
1547
1548	// Title label (or URL if no title)
1549	displayTitle := title
1550	if displayTitle == "" {
1551		displayTitle = displayURL
1552	}
1553	titleLabel := gtk.NewLabel(nil)
1554	if titleLabel != nil {
1555		titleLabel.SetText(displayTitle)
1556		titleLabel.AddCssClass("omnibox-suggestion-title")
1557		titleLabel.SetHalign(gtk.AlignStartValue)
1558		titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1559		textBox.Append(&titleLabel.Widget)
1560	}
1561
1562	// URL label (only if title exists and differs from URL)
1563	if title != "" && title != displayURL {
1564		urlLabel := gtk.NewLabel(nil)
1565		if urlLabel != nil {
1566			urlLabel.SetText(displayURL)
1567			urlLabel.AddCssClass("omnibox-suggestion-url")
1568			urlLabel.SetHalign(gtk.AlignStartValue)
1569			urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1570			textBox.Append(&urlLabel.Widget)
1571		}
1572	}
1573
1574	hbox.Append(&textBox.Widget)
1575
1576	// Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
1577	const maxShortcutIndex = 9
1578	if index <= maxShortcutIndex {
1579		shortcutLabel := gtk.NewLabel(nil)
1580		if shortcutLabel != nil {
1581			if index < maxShortcutIndex {
1582				shortcutLabel.SetText(formatShortcut(index + 1))
1583			} else {
1584				shortcutLabel.SetText("Ctrl+0")
1585			}
1586			shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1587			shortcutLabel.SetValign(gtk.AlignCenterValue)
1588			hbox.Append(&shortcutLabel.Widget)
1589		}
1590	}
1591
1592	row.SetChild(&hbox.Widget)
1593	return row
1594}
1595
1596// createSuggestionRow creates a ListBoxRow for a suggestion.
1597func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1598	row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
1599	if row != nil && s.IsFavorite {
1600		row.AddCssClass("omnibox-row-favorite")
1601	}
1602	return row
1603}
1604
1605// createFavoriteRow creates a ListBoxRow for a favorite.
1606func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
1607	return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
1608}
1609
1610func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1611	// Pass description as URL param (displayed as secondary label) and empty
1612	// faviconURL to skip async favicon fetching - bang rows use static icon only
1613	row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
1614	if row != nil {
1615		row.AddCssClass("omnibox-row-bang")
1616	}
1617	return row
1618}
1619
1620// selectIndex selects a row by index.
1621func (o *Omnibox) selectIndex(index int) {
1622	// Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
1623	// which also acquires the mutex, causing deadlock
1624	row := o.listBox.GetRowAtIndex(index)
1625	if row != nil {
1626		o.listBox.SelectRow(row)
1627		// selectedIndex is updated by rowSelectedCb
1628	}
1629}
1630
1631// selectNext moves selection down.
1632func (o *Omnibox) selectNext() {
1633	o.mu.Lock()
1634	current := o.selectedIndex
1635	mode := o.viewMode
1636	bangMode := o.bangMode
1637	var maxIndex int
1638	if bangMode {
1639		maxIndex = len(o.bangSuggestions) - 1
1640	} else if mode == ViewModeHistory {
1641		maxIndex = len(o.suggestions) - 1
1642	} else {
1643		maxIndex = len(o.favorites) - 1
1644	}
1645	o.hasNavigated = true // User is navigating with arrow keys
1646	o.mu.Unlock()
1647
1648	if maxIndex < 0 {
1649		return
1650	}
1651
1652	newIndex := current + 1
1653	if newIndex > maxIndex {
1654		newIndex = 0 // Wrap around
1655	}
1656	o.selectIndex(newIndex)
1657}
1658
1659// selectPrevious moves selection up.
1660func (o *Omnibox) selectPrevious() {
1661	o.mu.Lock()
1662	current := o.selectedIndex
1663	mode := o.viewMode
1664	bangMode := o.bangMode
1665	var maxIndex int
1666	if bangMode {
1667		maxIndex = len(o.bangSuggestions) - 1
1668	} else if mode == ViewModeHistory {
1669		maxIndex = len(o.suggestions) - 1
1670	} else {
1671		maxIndex = len(o.favorites) - 1
1672	}
1673	o.hasNavigated = true // User is navigating with arrow keys
1674	o.mu.Unlock()
1675
1676	if maxIndex < 0 {
1677		return
1678	}
1679
1680	newIndex := current - 1
1681	if newIndex < 0 {
1682		newIndex = maxIndex // Wrap around
1683	}
1684	o.selectIndex(newIndex)
1685}
1686
1687// selectAndNavigate selects an index and navigates to it.
1688func (o *Omnibox) selectAndNavigate(index int) {
1689	o.selectIndex(index)
1690	o.navigateToSelected()
1691}
1692
1693// navigateToSelected navigates to the currently selected item or typed URL.
1694// If the user typed a URL-like string, prioritize navigating to that directly.
1695func (o *Omnibox) navigateToSelected() {
1696	o.mu.RLock()
1697	mode := o.viewMode
1698	idx := o.selectedIndex
1699	suggestions := o.suggestions
1700	favorites := o.favorites
1701	bangMode := o.bangMode
1702	bangSuggestions := o.bangSuggestions
1703	o.mu.RUnlock()
1704
1705	entryText := o.entry.GetText()
1706
1707	if bangMode {
1708		// If user typed a full bang query, navigate using the bang URL.
1709		if o.shortcutsUC != nil {
1710			navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
1711			if navOutput.Valid {
1712				targetURL := o.buildURL(navOutput.Text)
1713				if targetURL == "" {
1714					return
1715				}
1716				o.Hide(o.ctx)
1717				if o.onNavigate != nil {
1718					o.onNavigate(targetURL)
1719				}
1720				return
1721			}
1722		}
1723
1724		// Otherwise, Enter autocompletes the selected bang.
1725		if idx >= 0 && idx < len(bangSuggestions) {
1726			o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
1727			o.entry.SetPosition(-1)
1728			return
1729		}
1730	}
1731
1732	var targetURL string
1733
1734	// If user has selected a result, navigate to that result
1735	// Otherwise, treat entry text as URL/search
1736	if idx >= 0 {
1737		if mode == ViewModeHistory {
1738			if idx < len(suggestions) {
1739				targetURL = suggestions[idx].URL
1740			}
1741		} else {
1742			if idx < len(favorites) {
1743				targetURL = favorites[idx].URL
1744			}
1745		}
1746	} else {
1747		// No selection - use entry text as URL/search
1748		targetURL = o.buildURL(entryText)
1749	}
1750
1751	if targetURL == "" {
1752		return
1753	}
1754
1755	o.Hide(o.ctx)
1756
1757	if o.onNavigate != nil {
1758		o.onNavigate(targetURL)
1759	}
1760}
1761
1762// toggleFavorite adds or removes the selected item from favorites.
1763// In History mode: adds the selected item to favorites
1764// In Favorites mode: removes the selected item from favorites
1765func (o *Omnibox) toggleFavorite() {
1766	log := logging.FromContext(o.ctx)
1767
1768	o.mu.RLock()
1769	mode := o.viewMode
1770	idx := o.selectedIndex
1771	suggestions := o.suggestions
1772	favorites := o.favorites
1773	o.mu.RUnlock()
1774
1775	if o.favoritesUC == nil {
1776		log.Warn().Msg("toggle favorite: favoritesUC is nil")
1777		return
1778	}
1779
1780	if mode == ViewModeHistory {
1781		// Toggle favorite in history mode
1782		if idx < 0 || idx >= len(suggestions) {
1783			log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection")
1784			return
1785		}
1786
1787		s := suggestions[idx]
1788		if s.URL == "" {
1789			log.Debug().Msg("toggle favorite: empty URL")
1790			return
1791		}
1792
1793		go func() {
1794			ctx := o.ctx
1795			goLog := logging.FromContext(ctx)
1796
1797			result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
1798			if err != nil {
1799				goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite")
1800				if o.onToast != nil {
1801					msg := "Failed to add favorite"
1802					if s.IsFavorite {
1803						msg = "Failed to remove favorite"
1804					}
1805					cb := glib.SourceFunc(func(_ uintptr) bool {
1806						o.onToast(ctx, msg, ToastError)
1807						return false
1808					})
1809					glib.IdleAdd(&cb, 0)
1810				}
1811				return
1812			}
1813
1814			// Update suggestion state
1815			o.mu.Lock()
1816			if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {
1817				o.suggestions[idx].IsFavorite = result.Added
1818			}
1819			o.mu.Unlock()
1820
1821			// Update row CSS and show toast on GTK main thread
1822			cb := glib.SourceFunc(func(_ uintptr) bool {
1823				o.updateRowFavoriteIndicator(idx, result.Added)
1824				if o.onToast != nil {
1825					o.onToast(ctx, result.Message, ToastSuccess)
1826				}
1827				return false
1828			})
1829			glib.IdleAdd(&cb, 0)
1830		}()
1831	} else {
1832		// Remove from favorites
1833		if idx < 0 || idx >= len(favorites) {
1834			log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
1835			return
1836		}
1837
1838		f := favorites[idx]
1839		if f.ID == 0 {
1840			log.Debug().Msg("remove favorite: invalid ID")
1841			return
1842		}
1843
1844		go func() {
1845			ctx := o.ctx
1846			log := logging.FromContext(ctx)
1847
1848			log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")
1849
1850			err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
1851			if err != nil {
1852				log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
1853				// Show error toast
1854				if o.onToast != nil {
1855					cb := glib.SourceFunc(func(_ uintptr) bool {
1856						o.onToast(ctx, "Failed to remove favorite", ToastError)
1857						return false
1858					})
1859					glib.IdleAdd(&cb, 0)
1860				}
1861				return
1862			}
1863
1864			log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
1865
1866			// Refresh favorites list and show toast
1867			o.loadFavorites(o.entry.GetText())
1868			if o.onToast != nil {
1869				cb := glib.SourceFunc(func(_ uintptr) bool {
1870					o.onToast(ctx, "Favorite removed", ToastSuccess)
1871					return false
1872				})
1873				glib.IdleAdd(&cb, 0)
1874			}
1875		}()
1876	}
1877}
1878
1879// updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
1880// Must be called from GTK main thread (via glib.IdleAdd).
1881func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) {
1882	row := o.listBox.GetRowAtIndex(index)
1883	if row == nil {
1884		return
1885	}
1886	if isFavorite {
1887		row.AddCssClass("omnibox-row-favorite")
1888	} else {
1889		row.RemoveCssClass("omnibox-row-favorite")
1890	}
1891}
1892
1893// yankSelectedURL copies the URL of the selected item to clipboard.
1894func (o *Omnibox) yankSelectedURL() {
1895	log := logging.FromContext(o.ctx)
1896
1897	if o.copyURLUC == nil {
1898		log.Warn().Msg("yank URL: copy URL use case is nil")
1899		return
1900	}
1901
1902	o.mu.RLock()
1903	mode := o.viewMode
1904	idx := o.selectedIndex
1905	suggestions := o.suggestions
1906	favorites := o.favorites
1907	o.mu.RUnlock()
1908
1909	var selectedURL string
1910	if mode == ViewModeHistory {
1911		if idx < 0 || idx >= len(suggestions) {
1912			log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1913			return
1914		}
1915		selectedURL = suggestions[idx].URL
1916	} else {
1917		if idx < 0 || idx >= len(favorites) {
1918			log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1919			return
1920		}
1921		selectedURL = favorites[idx].URL
1922	}
1923
1924	if selectedURL == "" {
1925		log.Debug().Msg("yank URL: empty URL")
1926		return
1927	}
1928
1929	go func() {
1930		ctx := o.ctx
1931		if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
1932			return // Use case already logs the error
1933		}
1934
1935		// Show toast notification on success (must run on GTK main thread)
1936		if o.onToast != nil {
1937			cb := glib.SourceFunc(func(_ uintptr) bool {
1938				o.onToast(ctx, "URL copied", ToastSuccess)
1939				return false // Don't repeat
1940			})
1941			glib.IdleAdd(&cb, 0)
1942		}
1943	}()
1944}
1945
1946// buildURL constructs a URL from text, handling search shortcuts.
1947func (o *Omnibox) buildURL(text string) string {
1948	var shortcutURLs map[string]string
1949	if o.shortcutsUC != nil {
1950		shortcutURLs = o.shortcutsUC.ShortcutURLs()
1951	}
1952	return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
1953}
1954
1955// toggleViewMode switches between history and favorites.
1956func (o *Omnibox) toggleViewMode() {
1957	o.mu.RLock()
1958	current := o.viewMode
1959	o.mu.RUnlock()
1960
1961	if current == ViewModeHistory {
1962		o.setViewMode(ViewModeFavorites)
1963	} else {
1964		o.setViewMode(ViewModeHistory)
1965	}
1966}
1967
1968// setViewMode changes the view mode and updates UI.
1969func (o *Omnibox) setViewMode(mode ViewMode) {
1970	o.mu.Lock()
1971	o.viewMode = mode
1972	o.mu.Unlock()
1973
1974	// Update header button styling
1975	if mode == ViewModeHistory {
1976		o.historyBtn.AddCssClass("omnibox-header-active")
1977		o.favoritesBtn.RemoveCssClass("omnibox-header-active")
1978	} else {
1979		o.historyBtn.RemoveCssClass("omnibox-header-active")
1980		o.favoritesBtn.AddCssClass("omnibox-header-active")
1981	}
1982
1983	// Reload data
1984	o.performSearch()
1985}
1986
1987// Show opens the omnibox with optional initial query.
1988func (o *Omnibox) Show(ctx context.Context, query string) {
1989	log := logging.FromContext(ctx)
1990	log.Debug().Str("query", query).Msg("showing omnibox")
1991
1992	o.mu.Lock()
1993	if o.visible {
1994		o.mu.Unlock()
1995		return
1996	}
1997	o.visible = true
1998	// Initialize ghost text state
1999	o.realInput = query
2000	o.ghostSuffix = ""
2001	o.ghostFullText = ""
2002	o.hasGhostText = false
2003	o.isAcceptingGhost = false
2004	o.mu.Unlock()
2005
2006	// Clear any stale ghost text display
2007	if o.ghostLabel != nil {
2008		o.ghostLabel.SetVisible(false)
2009	}
2010
2011	// Set initial query
2012	o.entry.SetText(query)
2013
2014	// Determine if we expect content initially
2015	// No content expected if: no query AND initialBehavior is "none"
2016	expectContent := query != "" || o.initialBehavior != "none"
2017
2018	// Hide scrolled window if no content expected
2019	if o.scrolledWin != nil {
2020		o.scrolledWin.SetVisible(expectContent)
2021		// Reset content height constraints - will be updated when results arrive
2022		o.scrolledWin.SetMinContentHeight(-1)
2023		o.scrolledWin.SetMaxContentHeight(0)
2024	}
2025
2026	// Calculate dimensions using shared helper
2027	width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
2028
2029	o.mainBox.SetSizeRequest(width, -1)
2030	o.outerBox.SetMarginTop(marginTop)
2031
2032	// Show the omnibox
2033	o.outerBox.SetVisible(true)
2034
2035	// Trigger initial resize (will be updated when results arrive)
2036	o.resizeAndCenter(0)
2037
2038	// Focus the entry
2039	o.entry.GrabFocus()
2040
2041	// Load initial data (may update size later if results found)
2042	o.performSearch()
2043}
2044
2045// Hide closes the omnibox.
2046func (o *Omnibox) Hide(ctx context.Context) {
2047	log := logging.FromContext(ctx)
2048	log.Debug().Msg("hiding omnibox")
2049
2050	o.mu.Lock()
2051	if !o.visible {
2052		o.mu.Unlock()
2053		return
2054	}
2055	o.visible = false
2056	o.mu.Unlock()
2057
2058	// Cancel any pending search
2059	o.debounceMu.Lock()
2060	if o.debounceTimer != nil {
2061		o.debounceTimer.Stop()
2062		o.debounceTimer = nil
2063	}
2064	o.debounceMu.Unlock()
2065
2066	// Hide omnibox
2067	o.outerBox.SetVisible(false)
2068
2069	// Clear state
2070	o.clearBangState()
2071	o.clearGhostText()
2072	o.entry.SetText("")
2073	o.listBox.RemoveAll()
2074
2075	// Reset realInput
2076	o.mu.Lock()
2077	o.realInput = ""
2078	o.mu.Unlock()
2079
2080	if o.onClose != nil {
2081		o.onClose()
2082	}
2083}
2084
2085// Toggle shows if hidden, hides if visible.
2086func (o *Omnibox) Toggle(ctx context.Context) {
2087	o.mu.RLock()
2088	visible := o.visible
2089	o.mu.RUnlock()
2090
2091	if visible {
2092		o.Hide(ctx)
2093	} else {
2094		o.Show(ctx, "")
2095	}
2096}
2097
2098// IsVisible returns whether the omnibox is currently shown.
2099func (o *Omnibox) IsVisible() bool {
2100	o.mu.RLock()
2101	defer o.mu.RUnlock()
2102	return o.visible
2103}
2104
2105// SetOnNavigate sets the callback for URL navigation.
2106func (o *Omnibox) SetOnNavigate(fn func(url string)) {
2107	o.onNavigate = fn
2108}
2109
2110// SetOnClose sets the callback for omnibox close events.
2111func (o *Omnibox) SetOnClose(fn func()) {
2112	o.onClose = fn
2113}
2114
2115// UpdateZoomIndicator updates the zoom percentage display.
2116// Shows the indicator when zoom != 100%, hides it when at 100%.
2117func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2118	var cb glib.SourceFunc = func(data uintptr) bool {
2119		if o.zoomLabel == nil {
2120			return false
2121		}
2122		if factor == 1.0 {
2123			o.zoomLabel.SetVisible(false)
2124		} else {
2125			percentage := int(factor * 100)
2126			o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
2127			o.zoomLabel.SetVisible(true)
2128		}
2129		return false
2130	}
2131	glib.IdleAdd(&cb, 0)
2132}
2133
2134// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2135// The query parameter is passed through to ensure ghost text uses the correct input.
2136func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
2137	var cb glib.SourceFunc = func(data uintptr) bool {
2138		o.updateSuggestions(suggestions, query)
2139		return false // One-shot callback
2140	}
2141	glib.IdleAdd(&cb, 0)
2142}
2143
2144// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
2145// Returns empty map on error to avoid blocking history display.
2146func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2147	if o.favoritesUC == nil {
2148		return make(map[string]struct{})
2149	}
2150	urls, err := o.favoritesUC.GetAllURLs(ctx)
2151	if err != nil {
2152		log := logging.FromContext(ctx)
2153		log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
2154		return make(map[string]struct{})
2155	}
2156	return urls
2157}
2158
2159// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2160// The query parameter is passed through to ensure ghost text uses the correct input.
2161func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
2162	var cb glib.SourceFunc = func(data uintptr) bool {
2163		o.updateFavorites(favorites, query)
2164		return false // One-shot callback, return false to remove source
2165	}
2166	glib.IdleAdd(&cb, 0)
2167}
2168
2169// Widget returns the omnibox widget for embedding in an overlay.
2170func (o *Omnibox) Widget() *gtk.Widget {
2171	if o.outerBox == nil {
2172		return nil
2173	}
2174	return &o.outerBox.Widget
2175}
2176
2177// Destroy cleans up omnibox resources.
2178func (o *Omnibox) Destroy() {
2179	o.debounceMu.Lock()
2180	if o.debounceTimer != nil {
2181		o.debounceTimer.Stop()
2182	}
2183	o.debounceMu.Unlock()
2184
2185	o.mu.Lock()
2186	o.visible = false
2187	o.mu.Unlock()
2188
2189	o.parentOverlay = nil
2190	o.retainedCallbacks = nil
2191	o.clickOutsideController = nil
2192	o.clickOutsideCb = nil
2193
2194	if o.outerBox != nil {
2195		o.outerBox.Unparent()
2196		o.outerBox = nil
2197	}
2198}
2199
2200// formatShortcut formats a shortcut number.
2201func formatShortcut(n int) string {
2202	return "Ctrl+" + string(rune('0'+n))
2203}
2204
2205// measureWidgetHeight returns the natural height of a widget for the given width.
2206// Returns 0 if widget is nil or measurement fails.
2207func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
2208	if widget == nil {
2209		return 0
2210	}
2211	var minHeight, naturalHeight, minBaseline, naturalBaseline int
2212	widget.Measure(
2213		gtk.OrientationVerticalValue,
2214		forWidth,
2215		&minHeight,
2216		&naturalHeight,
2217		&minBaseline,
2218		&naturalBaseline,
2219	)
2220	return naturalHeight
2221}
2222
2223// measureComponentHeights measures and caches all component heights.
2224// Must be called on GTK main thread after widgets have been laid out.
2225func (o *Omnibox) measureComponentHeights(forWidth int) bool {
2226	if o.headerBox == nil || o.entry == nil || o.listBox == nil {
2227		return false
2228	}
2229
2230	headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
2231	entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
2232
2233	// Measure first row if it exists
2234	var rowH int
2235	if row := o.listBox.GetRowAtIndex(0); row != nil {
2236		rowH = measureWidgetHeight(&row.Widget, forWidth)
2237	}
2238
2239	// Validate measurements (header and entry must be positive)
2240	if headerH <= 0 || entryH <= 0 {
2241		return false
2242	}
2243
2244	o.measuredHeights.header = headerH
2245	o.measuredHeights.entry = entryH
2246	if rowH > 0 {
2247		o.measuredHeights.singleRow = rowH
2248		o.measuredHeights.valid = true
2249	}
2250	return true
2251}
2252
2253// errNilWidget creates an error for nil widget creation.
2254func errNilWidget(name string) error {
2255	return &widgetError{name: name}
2256}
2257
2258type widgetError struct {
2259	name string
2260}
2261
2262func (e *widgetError) Error() string {
2263	return "failed to create widget: " + e.name
2264}

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