v0.28.0 3cb3770

omnibox.go RawHistoryFolder

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

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