v0.26.2 3cd9d2e

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

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