v0.27.1 c78ce83

omnibox.go RawHistoryFolder

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

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