v0.23.2 bfd5eb4

omnibox.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/domain/url"
13	"github.com/bnema/dumber/internal/logging"
14	"github.com/bnema/dumber/internal/ui/adapter"
15	"github.com/bnema/dumber/internal/ui/input"
16	"github.com/bnema/dumber/internal/ui/layout"
17	"github.com/jwijenbergh/puregotk/v4/gdk"
18	"github.com/jwijenbergh/puregotk/v4/glib"
19	"github.com/jwijenbergh/puregotk/v4/gtk"
20)
21
22const (
23	debounceDelayMs = 150
24	endBoxSpacing   = 6
25)
26
27// ViewMode distinguishes history search from favorites display.
28type ViewMode string
29
30const (
31	ViewModeHistory   ViewMode = "history"
32	ViewModeFavorites ViewMode = "favorites"
33)
34
35// Suggestion represents a search result from history.
36type Suggestion struct {
37	URL        string
38	Title      string
39	FaviconURL string
40	IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
41}
42
43// BangSuggestion represents a configured bang shortcut.
44// A bang is invoked by typing "!<key>".
45type BangSuggestion struct {
46	Key         string
47	Description string
48}
49
50// Favorite represents a bookmarked URL.
51type Favorite struct {
52	ID         int64
53	URL        string
54	Title      string
55	FaviconURL string
56	Position   int
57}
58
59// Omnibox is the native GTK4 address bar / command palette.
60type Omnibox struct {
61	// GTK widgets
62	outerBox     *gtk.Box // Outer container for positioning
63	mainBox      *gtk.Box // Main content box
64	headerBox    *gtk.Box
65	historyBtn   *gtk.Button
66	favoritesBtn *gtk.Button
67	zoomLabel    *gtk.Label
68	bangBadge    *gtk.Label
69	entry        *gtk.SearchEntry
70	scrolledWin  *gtk.ScrolledWindow
71	listBox      *gtk.ListBox
72
73	// Parent overlay reference for sizing (set via SetParentOverlay)
74	parentOverlay layout.OverlayWidget
75
76	// State
77	mu              sync.RWMutex
78	visible         bool
79	viewMode        ViewMode
80	selectedIndex   int
81	suggestions     []Suggestion
82	favorites       []Favorite
83	bangSuggestions []BangSuggestion
84	bangMode        bool
85	detectedBang    string
86	hasNavigated    bool // true if user navigated with arrow keys (enables space to toggle favorite)
87
88	// Dependencies
89	historyUC       *usecase.SearchHistoryUseCase
90	favoritesUC     *usecase.ManageFavoritesUseCase
91	faviconAdapter  *adapter.FaviconAdapter
92	copyURLUC       *usecase.CopyURLUseCase
93	shortcutsUC     *usecase.SearchShortcutsUseCase
94	defaultSearch   string
95	initialBehavior string
96	ctx             context.Context
97
98	// Callbacks
99	onNavigate func(url string)
100	onClose    func()
101	onToast    func(ctx context.Context, message string, level ToastLevel)
102
103	// Debouncing
104	debounceTimer *time.Timer
105	debounceMu    sync.Mutex
106	lastQuery     string // Prevent duplicate searches
107
108	// Scaling
109	uiScale float64
110
111	// Cached measurements (populated after first layout)
112	measuredHeights struct {
113		header    int  // headerBox natural height
114		entry     int  // entry natural height
115		singleRow int  // single ListBoxRow natural height
116		valid     bool // whether cache is valid
117	}
118
119	// Callback retention: keep GTK signal callbacks reachable by Go GC.
120	retainedCallbacks []interface{}
121}
122
123// OmniboxConfig holds configuration for creating an Omnibox.
124type OmniboxConfig struct {
125	HistoryUC       *usecase.SearchHistoryUseCase
126	FavoritesUC     *usecase.ManageFavoritesUseCase
127	FaviconAdapter  *adapter.FaviconAdapter
128	CopyURLUC       *usecase.CopyURLUseCase
129	ShortcutsUC     *usecase.SearchShortcutsUseCase
130	DefaultSearch   string
131	InitialBehavior string
132	UIScale         float64                                                     // UI scale for favicon sizing
133	OnNavigate      func(url string)                                            // Callback when user navigates via omnibox
134	OnToast         func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
135	OnFocusIn       func(entry *gtk.SearchEntry)                                // Callback when entry gains focus (for accent picker)
136	OnFocusOut      func()                                                      // Callback when entry loses focus
137}
138
139// NewOmnibox creates a new native GTK4 omnibox widget.
140// Call SetParentOverlay() before Show() to set the parent for sizing.
141func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
142	log := logging.FromContext(ctx)
143
144	uiScale := cfg.UIScale
145	if uiScale <= 0 {
146		uiScale = 1.0
147	}
148
149	o := &Omnibox{
150		viewMode:        ViewModeHistory,
151		selectedIndex:   -1,
152		historyUC:       cfg.HistoryUC,
153		favoritesUC:     cfg.FavoritesUC,
154		faviconAdapter:  cfg.FaviconAdapter,
155		copyURLUC:       cfg.CopyURLUC,
156		shortcutsUC:     cfg.ShortcutsUC,
157		defaultSearch:   cfg.DefaultSearch,
158		initialBehavior: cfg.InitialBehavior,
159		onToast:         cfg.OnToast,
160		ctx:             ctx,
161		uiScale:         uiScale,
162	}
163
164	if err := o.createWidgets(); err != nil {
165		log.Error().Err(err).Msg("failed to create omnibox widgets")
166		return nil
167	}
168
169	o.setupKeyboardHandling()
170	o.setupEntryChanged()
171	o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
172
173	// Set navigation callback if provided
174	if cfg.OnNavigate != nil {
175		o.onNavigate = cfg.OnNavigate
176	}
177
178	log.Debug().Msg("omnibox created")
179	return o
180}
181
182// SetParentOverlay sets the overlay widget used for sizing calculations.
183// Must be called before Show().
184func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) {
185	o.mu.Lock()
186	defer o.mu.Unlock()
187	o.parentOverlay = overlay
188}
189
190// WidgetAsLayout returns the omnibox's outer widget as a layout.Widget.
191// This is useful for adding the omnibox to a PaneView overlay.
192func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
193	if o.outerBox == nil {
194		return nil
195	}
196	return factory.WrapWidget(&o.outerBox.Widget)
197}
198
199// resizeAndCenter adjusts the omnibox size based on content and centers it.
200// rowCount is the number of result rows to display (0 = no content, max 10).
201func (o *Omnibox) resizeAndCenter(rowCount int) {
202	if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil {
203		return
204	}
205
206	// Calculate width using shared helper
207	width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
208
209	// Cap at max results
210	if rowCount > OmniboxListDefaults.MaxVisibleRows {
211		rowCount = OmniboxListDefaults.MaxVisibleRows
212	}
213
214	// Schedule measurement after GTK has laid out widgets
215	// This ensures we get accurate heights from actual rendered content
216	var cb glib.SourceFunc = func(uintptr) bool {
217		o.measureAndResize(width, rowCount)
218		return false // One-shot
219	}
220	glib.IdleAdd(&cb, 0)
221}
222
223// measureAndResize calculates and sets the omnibox height based on row count.
224// Uses measured widget heights when available, falls back to estimates.
225func (o *Omnibox) measureAndResize(width, rowCount int) {
226	if o.outerBox == nil || o.mainBox == nil {
227		return
228	}
229
230	log := logging.FromContext(o.ctx)
231
232	var rowHeight int
233
234	if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
235		// Use measured values from GTK4 Measure API
236		rowHeight = o.measuredHeights.singleRow
237	} else {
238		// Fallback to scaled estimate (before first measurement)
239		rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
240	}
241
242	// Calculate content height for the scrolled window
243	// This is the key to dynamic sizing - limit the ScrolledWindow's max height
244	contentHeight := 0
245	if rowCount > 0 {
246		contentHeight = rowCount * rowHeight
247	}
248
249	// Cap at max results
250	maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
251	if contentHeight > maxContentHeight {
252		contentHeight = maxContentHeight
253	}
254
255	// Set size constraints on ScrolledWindow using shared helper
256	SetScrolledWindowHeight(o.scrolledWin, contentHeight)
257
258	// Force layout recalculation
259	o.outerBox.QueueResize()
260
261	log.Debug().
262		Int("width", width).
263		Int("contentHeight", contentHeight).
264		Int("rowHeight", rowHeight).
265		Int("rows", rowCount).
266		Bool("measured", o.measuredHeights.valid).
267		Msg("omnibox resized")
268}
269
270// createWidgets builds the GTK widget hierarchy.
271func (o *Omnibox) createWidgets() error {
272	if err := o.initOuterBox(); err != nil {
273		return err
274	}
275	if err := o.initMainBox(); err != nil {
276		return err
277	}
278	if err := o.initHeader(); err != nil {
279		return err
280	}
281	if err := o.initEntry(); err != nil {
282		return err
283	}
284	if err := o.initList(); err != nil {
285		return err
286	}
287	o.assembleWidgets()
288	return nil
289}
290
291// setupKeyboardHandling adds keyboard event handling.
292func (o *Omnibox) setupKeyboardHandling() {
293	log := logging.FromContext(o.ctx)
294	controller := gtk.NewEventControllerKey()
295	if controller == nil {
296		log.Error().Msg("failed to create event controller key")
297		return
298	}
299
300	// Set capture phase to intercept before entry
301	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
302
303	keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
304		return o.handleKeyPress(keyval, keycode, state)
305	}
306	o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
307	controller.ConnectKeyPressed(&keyPressedCb)
308
309	o.outerBox.AddController(&controller.EventController)
310}
311
312func (o *Omnibox) initOuterBox() error {
313	o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
314	if o.outerBox == nil {
315		return errNilWidget("outerBox")
316	}
317	o.outerBox.AddCssClass("omnibox-outer")
318	o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
319	o.outerBox.SetValign(gtk.AlignStartValue)  // Align to top
320	o.outerBox.SetVisible(false)               // Hidden by default
321	return nil
322}
323
324func (o *Omnibox) initMainBox() error {
325	o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
326	if o.mainBox == nil {
327		return errNilWidget("mainBox")
328	}
329	o.mainBox.AddCssClass("omnibox-container")
330	return nil
331}
332
333func (o *Omnibox) initHeader() error {
334	o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
335	if o.headerBox == nil {
336		return errNilWidget("headerBox")
337	}
338	o.headerBox.AddCssClass("omnibox-header")
339	o.headerBox.SetHexpand(true)
340
341	o.historyBtn = gtk.NewButtonWithLabel("History")
342	if o.historyBtn == nil {
343		return errNilWidget("historyBtn")
344	}
345	o.historyBtn.AddCssClass("omnibox-header-btn")
346	o.historyBtn.AddCssClass("omnibox-header-active")
347	o.historyBtn.SetCanFocus(false)
348
349	o.favoritesBtn = gtk.NewButtonWithLabel("Favorites")
350	if o.favoritesBtn == nil {
351		return errNilWidget("favoritesBtn")
352	}
353	o.favoritesBtn.AddCssClass("omnibox-header-btn")
354	o.favoritesBtn.SetCanFocus(false)
355
356	historyClickCb := func(_ gtk.Button) {
357		o.setViewMode(ViewModeHistory)
358	}
359	o.retainedCallbacks = append(o.retainedCallbacks, historyClickCb)
360	o.historyBtn.ConnectClicked(&historyClickCb)
361
362	favoritesClickCb := func(_ gtk.Button) {
363		o.setViewMode(ViewModeFavorites)
364	}
365	o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
366	o.favoritesBtn.ConnectClicked(&favoritesClickCb)
367
368	o.bangBadge = gtk.NewLabel(nil)
369	if o.bangBadge != nil {
370		o.bangBadge.AddCssClass("omnibox-bang-badge")
371		o.bangBadge.SetVisible(false)
372	}
373
374	o.zoomLabel = gtk.NewLabel(nil)
375	if o.zoomLabel != nil {
376		o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
377		o.zoomLabel.SetVisible(false)
378	}
379	return nil
380}
381
382func (o *Omnibox) initEntry() error {
383	o.entry = gtk.NewSearchEntry()
384	if o.entry == nil {
385		return errNilWidget("entry")
386	}
387	o.entry.AddCssClass("omnibox-entry")
388	o.entry.SetHexpand(true)
389
390	placeholder := "Search history or enter URL… (! lists bangs)"
391	o.entry.SetPlaceholderText(&placeholder)
392	return nil
393}
394
395func (o *Omnibox) initList() error {
396	o.scrolledWin = gtk.NewScrolledWindow()
397	if o.scrolledWin == nil {
398		return errNilWidget("scrolledWin")
399	}
400	o.scrolledWin.AddCssClass("omnibox-scrolled")
401	o.scrolledWin.SetVexpand(true)
402	o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
403	o.scrolledWin.SetPropagateNaturalHeight(true)
404
405	o.listBox = gtk.NewListBox()
406	if o.listBox == nil {
407		return errNilWidget("listBox")
408	}
409	o.listBox.AddCssClass("omnibox-listbox")
410	o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
411
412	rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
413		if rowPtr == 0 {
414			o.mu.Lock()
415			o.selectedIndex = -1
416			o.mu.Unlock()
417			return
418		}
419		row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
420		if row != nil {
421			o.mu.Lock()
422			o.selectedIndex = row.GetIndex()
423			o.mu.Unlock()
424		}
425	}
426	o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
427	o.listBox.ConnectRowSelected(&rowSelectedCb)
428	return nil
429}
430
431func (o *Omnibox) assembleWidgets() {
432	o.headerBox.Append(&o.historyBtn.Widget)
433	o.headerBox.Append(&o.favoritesBtn.Widget)
434
435	endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
436	if endBox != nil {
437		endBox.SetHexpand(true)
438		endBox.SetHalign(gtk.AlignEndValue)
439		if o.bangBadge != nil {
440			endBox.Append(&o.bangBadge.Widget)
441		}
442		if o.zoomLabel != nil {
443			endBox.Append(&o.zoomLabel.Widget)
444		}
445		o.headerBox.Append(&endBox.Widget)
446	}
447
448	o.scrolledWin.SetChild(&o.listBox.Widget)
449	o.mainBox.Append(&o.headerBox.Widget)
450	o.mainBox.Append(&o.entry.Widget)
451	o.mainBox.Append(&o.scrolledWin.Widget)
452	o.outerBox.Append(&o.mainBox.Widget)
453}
454
455// setupEntryChanged wires entry text changes to debounced search.
456func (o *Omnibox) setupEntryChanged() {
457	// SearchEntry has built-in debouncing via search-changed signal
458	changedCb := func(_ gtk.SearchEntry) {
459		o.onEntryChanged()
460	}
461	o.retainedCallbacks = append(o.retainedCallbacks, changedCb)
462	o.entry.ConnectSearchChanged(&changedCb)
463}
464
465// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
466func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
467	if onFocusIn == nil && onFocusOut == nil {
468		return
469	}
470
471	focusController := gtk.NewEventControllerFocus()
472	if focusController == nil {
473		return
474	}
475
476	if onFocusIn != nil {
477		entry := o.entry // Capture for closure
478		focusInCb := func(_ gtk.EventControllerFocus) {
479			onFocusIn(entry)
480		}
481		o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
482		focusController.ConnectEnter(&focusInCb)
483	}
484
485	if onFocusOut != nil {
486		focusOutCb := func(_ gtk.EventControllerFocus) {
487			onFocusOut()
488		}
489		o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
490		focusController.ConnectLeave(&focusOutCb)
491	}
492
493	o.entry.AddController(&focusController.EventController)
494}
495
496// handleKeyPress processes keyboard events.
497// Returns true if the event was handled.
498func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
499	ctrl := state&gdk.ControlMaskValue != 0
500
501	switch keyval {
502	case uint(gdk.KEY_Escape):
503		text := o.entry.GetText()
504		if text != "" {
505			o.entry.SetText("")
506			return true
507		}
508		o.Hide(o.ctx)
509		return true
510
511	case uint(gdk.KEY_l), uint(gdk.KEY_L):
512		// Ctrl+L toggles omnibox (hides when visible)
513		if ctrl {
514			o.Hide(o.ctx)
515			return true
516		}
517
518	case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
519		o.navigateToSelected()
520		return true
521
522	case uint(gdk.KEY_Up):
523		o.selectPrevious()
524		return true
525
526	case uint(gdk.KEY_Down):
527		o.selectNext()
528		return true
529
530	case uint(gdk.KEY_Tab):
531		o.toggleViewMode()
532		return true
533
534	case uint(gdk.KEY_space):
535		// Space toggles favorite only if user has navigated with arrow keys
536		// Otherwise, let space pass through to entry for typing
537		o.mu.RLock()
538		navigated := o.hasNavigated
539		o.mu.RUnlock()
540		if navigated {
541			o.toggleFavorite()
542			return true
543		}
544		return false // Let entry handle space for typing
545
546	case uint(gdk.KEY_y):
547		// 'y' yanks (copies) the selected URL to clipboard when navigating
548		o.mu.RLock()
549		navigated := o.hasNavigated
550		o.mu.RUnlock()
551		if navigated {
552			o.yankSelectedURL()
553			return true
554		}
555		return false // Let entry handle 'y' for typing
556
557	default:
558		return o.handleCtrlNumberShortcut(keyval, keycode, ctrl)
559	}
560
561	return false // Let entry handle the key
562}
563
564// handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
565// Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
566func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
567	if !ctrl {
568		return false
569	}
570
571	const tenthItemIndex = 9
572
573	// First try keyval (works for QWERTY layouts)
574	switch keyval {
575	case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
576		uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
577		idx := int(keyval - uint(gdk.KEY_1))
578		o.selectAndNavigate(idx)
579		return true
580	case uint(gdk.KEY_0):
581		o.selectAndNavigate(tenthItemIndex)
582		return true
583	}
584
585	// Fallback: use hardware keycode for non-QWERTY layouts
586	// This enables Ctrl+1-9/0 to work on AZERTY, QWERTZ, etc.
587	if index, ok := input.KeycodeToDigitIndex(keycode); ok {
588		log := logging.FromContext(o.ctx)
589		log.Debug().
590			Uint("keycode", keycode).
591			Int("index", index).
592			Msg("omnibox shortcut via hardware keycode fallback (non-QWERTY layout)")
593		o.selectAndNavigate(index)
594		return true
595	}
596
597	return false
598}
599
600// onEntryChanged handles text input changes with debouncing.
601func (o *Omnibox) onEntryChanged() {
602	// Reset navigation state when user types - space should type, not toggle favorite
603	o.mu.Lock()
604	o.hasNavigated = false
605	o.mu.Unlock()
606
607	o.debounceMu.Lock()
608	if o.debounceTimer != nil {
609		o.debounceTimer.Stop()
610	}
611	o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
612		o.performSearch()
613	})
614	o.debounceMu.Unlock()
615}
616
617// performSearch executes the search based on current view mode and query.
618func (o *Omnibox) performSearch() {
619	o.mu.RLock()
620	visible := o.visible
621	mode := o.viewMode
622	o.mu.RUnlock()
623
624	// Skip search if omnibox is hidden
625	if !visible {
626		return
627	}
628
629	query := o.entry.GetText()
630
631	// Skip duplicate queries
632	o.debounceMu.Lock()
633	if query == o.lastQuery && query != "" {
634		o.debounceMu.Unlock()
635		return
636	}
637	o.lastQuery = query
638	o.debounceMu.Unlock()
639
640	if strings.HasPrefix(query, "!") {
641		o.updateBangDetection(query)
642		o.loadBangSuggestions(query)
643		return
644	}
645
646	o.clearBangState()
647
648	if mode == ViewModeFavorites {
649		o.loadFavorites(query)
650		return
651	}
652
653	// History search
654	if query == "" {
655		o.loadInitialHistory()
656		return
657	}
658
659	// Perform fuzzy search
660	go func() {
661		ctx := o.ctx
662		log := logging.FromContext(ctx)
663		if o.historyUC == nil {
664			return
665		}
666
667		// Run history search and favorite URL fetch in parallel
668		type searchResult struct {
669			output *usecase.SearchOutput
670			err    error
671		}
672		searchCh := make(chan searchResult, 1)
673		favCh := make(chan map[string]struct{}, 1)
674
675		go func() {
676			searchInput := usecase.SearchInput{
677				Query: query,
678				Limit: OmniboxListDefaults.MaxResults,
679			}
680			output, err := o.historyUC.Search(ctx, searchInput)
681			searchCh <- searchResult{output, err}
682		}()
683
684		go func() {
685			favCh <- o.getFavoriteURLs(ctx)
686		}()
687
688		// Wait for both results
689		sr := <-searchCh
690		favoriteURLs := <-favCh
691
692		if sr.err != nil {
693			log.Error().Err(sr.err).Msg("history search failed")
694			return
695		}
696		if sr.output == nil {
697			return
698		}
699
700		suggestions := make([]Suggestion, 0, len(sr.output.Matches))
701		for _, r := range sr.output.Matches {
702			_, isFav := favoriteURLs[r.Entry.URL]
703			suggestions = append(suggestions, Suggestion{
704				URL:        r.Entry.URL,
705				Title:      r.Entry.Title,
706				IsFavorite: isFav,
707			})
708		}
709
710		// Marshal back to GTK main thread
711		o.idleAddUpdateSuggestions(suggestions)
712	}()
713}
714
715// loadInitialHistory loads history based on InitialBehavior config.
716func (o *Omnibox) loadInitialHistory() {
717	if o.historyUC == nil {
718		return
719	}
720
721	go func() {
722		ctx := o.ctx
723		log := logging.FromContext(ctx)
724		var suggestions []Suggestion
725
726		switch o.initialBehavior {
727		case "none":
728			o.idleAddUpdateSuggestions(nil)
729			return
730
731		case "most_visited", "recent", "":
732			// Run history fetch and favorite URL fetch in parallel
733			type historyResult struct {
734				results []*entity.HistoryEntry
735				err     error
736			}
737			historyCh := make(chan historyResult, 1)
738			favCh := make(chan map[string]struct{}, 1)
739
740			go func() {
741				// TODO: Implement GetMostVisited in use case if needed
742				results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
743				historyCh <- historyResult{results, err}
744			}()
745
746			go func() {
747				favCh <- o.getFavoriteURLs(ctx)
748			}()
749
750			// Wait for both results
751			hr := <-historyCh
752			favoriteURLs := <-favCh
753
754			if hr.err != nil {
755				log.Error().Err(hr.err).Msg("failed to load recent history")
756				return
757			}
758			if hr.results == nil {
759				return
760			}
761
762			suggestions = make([]Suggestion, 0, len(hr.results))
763			for _, r := range hr.results {
764				_, isFav := favoriteURLs[r.URL]
765				suggestions = append(suggestions, Suggestion{
766					URL:        r.URL,
767					Title:      r.Title,
768					IsFavorite: isFav,
769				})
770			}
771		}
772
773		o.idleAddUpdateSuggestions(suggestions)
774	}()
775}
776
777// loadFavorites loads favorites, optionally filtered by query.
778func (o *Omnibox) loadFavorites(query string) {
779	if o.favoritesUC == nil {
780		return
781	}
782
783	go func() {
784		ctx := o.ctx
785		log := logging.FromContext(ctx)
786		results, err := o.favoritesUC.GetAll(ctx)
787		if err != nil {
788			log.Error().Err(err).Msg("failed to load favorites")
789			return
790		}
791
792		favorites := make([]Favorite, 0, len(results))
793		queryLower := strings.ToLower(query)
794		for i, r := range results {
795			// Filter by query if provided
796			if query != "" {
797				titleMatch := strings.Contains(strings.ToLower(r.Title), queryLower)
798				urlMatch := strings.Contains(strings.ToLower(r.URL), queryLower)
799				if !titleMatch && !urlMatch {
800					continue
801				}
802			}
803			favorites = append(favorites, Favorite{
804				ID:       int64(r.ID),
805				URL:      r.URL,
806				Title:    r.Title,
807				Position: i,
808			})
809		}
810
811		o.idleAddUpdateFavorites(favorites)
812	}()
813}
814
815func (o *Omnibox) loadBangSuggestions(query string) {
816	if o.shortcutsUC == nil {
817		return
818	}
819	output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query})
820	suggestions := make([]BangSuggestion, len(output.Suggestions))
821	for i, s := range output.Suggestions {
822		suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
823	}
824	o.idleAddUpdateBangSuggestions(suggestions)
825}
826
827func (o *Omnibox) updateBangDetection(query string) {
828	if o.shortcutsUC == nil {
829		o.clearDetectedBang()
830		return
831	}
832	output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
833	if output.Key == "" {
834		o.clearDetectedBang()
835		return
836	}
837
838	o.setDetectedBang(output.Key, output.Description)
839}
840
841func (o *Omnibox) clearBangState() {
842	o.mu.Lock()
843	o.bangMode = false
844	o.bangSuggestions = nil
845	o.mu.Unlock()
846
847	o.clearDetectedBang()
848}
849
850func (o *Omnibox) setDetectedBang(key, description string) {
851	o.mu.Lock()
852	o.detectedBang = key
853	o.mu.Unlock()
854
855	if o.bangBadge == nil {
856		return
857	}
858	label := description
859	var cb glib.SourceFunc = func(uintptr) bool {
860		o.entry.AddCssClass("omnibox-entry-bang-active")
861		o.bangBadge.SetText(label)
862		o.bangBadge.SetVisible(true)
863		return false
864	}
865	glib.IdleAdd(&cb, 0)
866}
867
868func (o *Omnibox) clearDetectedBang() {
869	o.mu.Lock()
870	o.detectedBang = ""
871	o.mu.Unlock()
872
873	if o.bangBadge == nil {
874		return
875	}
876	var cb glib.SourceFunc = func(uintptr) bool {
877		o.entry.RemoveCssClass("omnibox-entry-bang-active")
878		o.bangBadge.SetVisible(false)
879		return false
880	}
881	glib.IdleAdd(&cb, 0)
882}
883
884func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
885	var cb glib.SourceFunc = func(uintptr) bool {
886		o.updateBangSuggestions(suggestions)
887		return false
888	}
889	glib.IdleAdd(&cb, 0)
890}
891
892func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
893	o.mu.Lock()
894	o.bangMode = true
895	o.bangSuggestions = suggestions
896	o.selectedIndex = -1
897	o.mu.Unlock()
898
899	o.rebuildList()
900
901	rowCount := len(suggestions)
902	if o.scrolledWin != nil {
903		o.scrolledWin.SetVisible(rowCount > 0)
904	}
905	o.resizeAndCenter(rowCount)
906
907	if rowCount > 0 {
908		o.selectIndex(0)
909	}
910}
911
912// updateSuggestions updates the list with history suggestions.
913func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
914	o.mu.Lock()
915	o.bangMode = false
916	o.bangSuggestions = nil
917	o.suggestions = suggestions
918	o.selectedIndex = -1
919	o.mu.Unlock()
920
921	o.rebuildList()
922
923	// Hide scrolled window when there are no suggestions
924	rowCount := len(suggestions)
925	if o.scrolledWin != nil {
926		o.scrolledWin.SetVisible(rowCount > 0)
927	}
928	o.resizeAndCenter(rowCount)
929
930	// Select first item if available
931	if rowCount > 0 {
932		o.selectIndex(0)
933	}
934}
935
936// updateFavorites updates the list with favorites.
937func (o *Omnibox) updateFavorites(favorites []Favorite) {
938	o.mu.Lock()
939	o.bangMode = false
940	o.bangSuggestions = nil
941	o.favorites = favorites
942	o.selectedIndex = -1
943	o.mu.Unlock()
944
945	o.rebuildList()
946
947	// Hide scrolled window when there are no favorites
948	rowCount := len(favorites)
949	if o.scrolledWin != nil {
950		o.scrolledWin.SetVisible(rowCount > 0)
951	}
952	o.resizeAndCenter(rowCount)
953
954	// Select first item if available
955	if rowCount > 0 {
956		o.selectIndex(0)
957	}
958}
959
960// rebuildList rebuilds the ListBox contents.
961func (o *Omnibox) rebuildList() {
962	// Clear existing rows
963	o.listBox.RemoveAll()
964
965	o.mu.RLock()
966	mode := o.viewMode
967	suggestions := o.suggestions
968	favorites := o.favorites
969	bangMode := o.bangMode
970	bangSuggestions := o.bangSuggestions
971	o.mu.RUnlock()
972
973	if bangMode {
974		for i, b := range bangSuggestions {
975			row := o.createBangRow(b, i)
976			if row != nil {
977				o.listBox.Append(&row.Widget)
978			}
979		}
980	} else if mode == ViewModeHistory {
981		for i, s := range suggestions {
982			row := o.createSuggestionRow(s, i)
983			if row != nil {
984				o.listBox.Append(&row.Widget)
985			}
986		}
987	} else {
988		for i, f := range favorites {
989			row := o.createFavoriteRow(f, i)
990			if row != nil {
991				o.listBox.Append(&row.Widget)
992			}
993		}
994	}
995
996	// Schedule measurement if not yet cached and we have rows
997	if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
998		var cb glib.SourceFunc = func(uintptr) bool {
999			if o.parentOverlay == nil {
1000				return false
1001			}
1002			width := o.parentOverlay.GetAllocatedWidth()
1003			if width <= 0 {
1004				return false // Overlay not allocated yet, skip
1005			}
1006			forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
1007			if o.measureComponentHeights(forWidth) {
1008				// Re-trigger resize with accurate measurements
1009				o.mu.RLock()
1010				var count int
1011				if o.bangMode {
1012					count = len(o.bangSuggestions)
1013				} else if o.viewMode == ViewModeHistory {
1014					count = len(o.suggestions)
1015				} else {
1016					count = len(o.favorites)
1017				}
1018				o.mu.RUnlock()
1019				o.measureAndResize(forWidth, count)
1020			}
1021			return false
1022		}
1023		glib.IdleAdd(&cb, 0)
1024	}
1025}
1026
1027// createFaviconImage creates a favicon image with async loading from cache.
1028func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1029	favicon := gtk.NewImage()
1030	if favicon == nil {
1031		return nil
1032	}
1033	favicon.SetFromIconName(&fallbackIcon)
1034	favicon.SetPixelSize(int(16 * o.uiScale))
1035	favicon.AddCssClass("omnibox-favicon")
1036
1037	// Async load favicon from cache
1038	if o.faviconAdapter != nil && rawURL != "" {
1039		o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
1040			if texture != nil {
1041				var cb glib.SourceFunc = func(data uintptr) bool {
1042					favicon.SetFromPaintable(texture)
1043					return false
1044				}
1045				glib.IdleAdd(&cb, 0)
1046			}
1047		})
1048	}
1049
1050	return favicon
1051}
1052
1053// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1054// Uses rawURL for both favicon fetching and display.
1055func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1056	return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1057}
1058
1059// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1060// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1061func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
1062	row := gtk.NewListBoxRow()
1063	if row == nil {
1064		return nil
1065	}
1066	row.AddCssClass("omnibox-row")
1067
1068	const rowSpacing = 8
1069	hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1070	if hbox == nil {
1071		return nil
1072	}
1073	hbox.SetHexpand(true)
1074
1075	// Favicon image (vertically centered)
1076	if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
1077		favicon.SetValign(gtk.AlignCenterValue)
1078		hbox.Append(&favicon.Widget)
1079	}
1080
1081	// Vertical box for title + URL
1082	textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
1083	if textBox == nil {
1084		return nil
1085	}
1086	textBox.SetHexpand(true)
1087	textBox.SetValign(gtk.AlignCenterValue)
1088
1089	// Title label (or URL if no title)
1090	displayTitle := title
1091	if displayTitle == "" {
1092		displayTitle = displayURL
1093	}
1094	titleLabel := gtk.NewLabel(nil)
1095	if titleLabel != nil {
1096		titleLabel.SetText(displayTitle)
1097		titleLabel.AddCssClass("omnibox-suggestion-title")
1098		titleLabel.SetHalign(gtk.AlignStartValue)
1099		titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1100		textBox.Append(&titleLabel.Widget)
1101	}
1102
1103	// URL label (only if title exists and differs from URL)
1104	if title != "" && title != displayURL {
1105		urlLabel := gtk.NewLabel(nil)
1106		if urlLabel != nil {
1107			urlLabel.SetText(displayURL)
1108			urlLabel.AddCssClass("omnibox-suggestion-url")
1109			urlLabel.SetHalign(gtk.AlignStartValue)
1110			urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1111			textBox.Append(&urlLabel.Widget)
1112		}
1113	}
1114
1115	hbox.Append(&textBox.Widget)
1116
1117	// Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
1118	const maxShortcutIndex = 9
1119	if index <= maxShortcutIndex {
1120		shortcutLabel := gtk.NewLabel(nil)
1121		if shortcutLabel != nil {
1122			if index < maxShortcutIndex {
1123				shortcutLabel.SetText(formatShortcut(index + 1))
1124			} else {
1125				shortcutLabel.SetText("Ctrl+0")
1126			}
1127			shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1128			shortcutLabel.SetValign(gtk.AlignCenterValue)
1129			hbox.Append(&shortcutLabel.Widget)
1130		}
1131	}
1132
1133	row.SetChild(&hbox.Widget)
1134	return row
1135}
1136
1137// createSuggestionRow creates a ListBoxRow for a suggestion.
1138func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1139	row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
1140	if row != nil && s.IsFavorite {
1141		row.AddCssClass("omnibox-row-favorite")
1142	}
1143	return row
1144}
1145
1146// createFavoriteRow creates a ListBoxRow for a favorite.
1147func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
1148	return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
1149}
1150
1151func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1152	// Pass description as URL param (displayed as secondary label) and empty
1153	// faviconURL to skip async favicon fetching - bang rows use static icon only
1154	row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
1155	if row != nil {
1156		row.AddCssClass("omnibox-row-bang")
1157	}
1158	return row
1159}
1160
1161// selectIndex selects a row by index.
1162func (o *Omnibox) selectIndex(index int) {
1163	// Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
1164	// which also acquires the mutex, causing deadlock
1165	row := o.listBox.GetRowAtIndex(index)
1166	if row != nil {
1167		o.listBox.SelectRow(row)
1168		// selectedIndex is updated by rowSelectedCb
1169	}
1170}
1171
1172// selectNext moves selection down.
1173func (o *Omnibox) selectNext() {
1174	o.mu.Lock()
1175	current := o.selectedIndex
1176	mode := o.viewMode
1177	bangMode := o.bangMode
1178	var maxIndex int
1179	if bangMode {
1180		maxIndex = len(o.bangSuggestions) - 1
1181	} else if mode == ViewModeHistory {
1182		maxIndex = len(o.suggestions) - 1
1183	} else {
1184		maxIndex = len(o.favorites) - 1
1185	}
1186	o.hasNavigated = true // User is navigating with arrow keys
1187	o.mu.Unlock()
1188
1189	if maxIndex < 0 {
1190		return
1191	}
1192
1193	newIndex := current + 1
1194	if newIndex > maxIndex {
1195		newIndex = 0 // Wrap around
1196	}
1197	o.selectIndex(newIndex)
1198}
1199
1200// selectPrevious moves selection up.
1201func (o *Omnibox) selectPrevious() {
1202	o.mu.Lock()
1203	current := o.selectedIndex
1204	mode := o.viewMode
1205	bangMode := o.bangMode
1206	var maxIndex int
1207	if bangMode {
1208		maxIndex = len(o.bangSuggestions) - 1
1209	} else if mode == ViewModeHistory {
1210		maxIndex = len(o.suggestions) - 1
1211	} else {
1212		maxIndex = len(o.favorites) - 1
1213	}
1214	o.hasNavigated = true // User is navigating with arrow keys
1215	o.mu.Unlock()
1216
1217	if maxIndex < 0 {
1218		return
1219	}
1220
1221	newIndex := current - 1
1222	if newIndex < 0 {
1223		newIndex = maxIndex // Wrap around
1224	}
1225	o.selectIndex(newIndex)
1226}
1227
1228// selectAndNavigate selects an index and navigates to it.
1229func (o *Omnibox) selectAndNavigate(index int) {
1230	o.selectIndex(index)
1231	o.navigateToSelected()
1232}
1233
1234// navigateToSelected navigates to the currently selected item or typed URL.
1235// If the user typed a URL-like string, prioritize navigating to that directly.
1236func (o *Omnibox) navigateToSelected() {
1237	o.mu.RLock()
1238	mode := o.viewMode
1239	idx := o.selectedIndex
1240	suggestions := o.suggestions
1241	favorites := o.favorites
1242	bangMode := o.bangMode
1243	bangSuggestions := o.bangSuggestions
1244	o.mu.RUnlock()
1245
1246	entryText := o.entry.GetText()
1247
1248	if bangMode {
1249		// If user typed a full bang query, navigate using the bang URL.
1250		if o.shortcutsUC != nil {
1251			navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
1252			if navOutput.Valid {
1253				targetURL := o.buildURL(navOutput.Text)
1254				if targetURL == "" {
1255					return
1256				}
1257				o.Hide(o.ctx)
1258				if o.onNavigate != nil {
1259					o.onNavigate(targetURL)
1260				}
1261				return
1262			}
1263		}
1264
1265		// Otherwise, Enter autocompletes the selected bang.
1266		if idx >= 0 && idx < len(bangSuggestions) {
1267			o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
1268			o.entry.SetPosition(-1)
1269			return
1270		}
1271	}
1272
1273	var targetURL string
1274
1275	// Check if user typed a URL-like string (contains . and no spaces)
1276	// If so, navigate to that URL directly instead of the selection
1277	if o.looksLikeURL(entryText) {
1278		targetURL = o.buildURL(entryText)
1279	} else if idx < 0 {
1280		// No selection - use entry text as URL/search
1281		targetURL = o.buildURL(entryText)
1282	} else if mode == ViewModeHistory {
1283		if idx < len(suggestions) {
1284			targetURL = suggestions[idx].URL
1285		}
1286	} else {
1287		if idx < len(favorites) {
1288			targetURL = favorites[idx].URL
1289		}
1290	}
1291
1292	if targetURL == "" {
1293		return
1294	}
1295
1296	o.Hide(o.ctx)
1297
1298	if o.onNavigate != nil {
1299		o.onNavigate(targetURL)
1300	}
1301}
1302
1303// looksLikeURL checks if the text appears to be a URL (not a search query).
1304// Returns true for strings like "github.com", "google.com/search", etc.
1305func (o *Omnibox) looksLikeURL(text string) bool {
1306	return url.LooksLikeURL(text)
1307}
1308
1309// toggleFavorite adds or removes the selected item from favorites.
1310// In History mode: adds the selected item to favorites
1311// In Favorites mode: removes the selected item from favorites
1312func (o *Omnibox) toggleFavorite() {
1313	log := logging.FromContext(o.ctx)
1314
1315	o.mu.RLock()
1316	mode := o.viewMode
1317	idx := o.selectedIndex
1318	suggestions := o.suggestions
1319	favorites := o.favorites
1320	o.mu.RUnlock()
1321
1322	if o.favoritesUC == nil {
1323		log.Warn().Msg("toggle favorite: favoritesUC is nil")
1324		return
1325	}
1326
1327	if mode == ViewModeHistory {
1328		// Toggle favorite in history mode
1329		if idx < 0 || idx >= len(suggestions) {
1330			log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection")
1331			return
1332		}
1333
1334		s := suggestions[idx]
1335		if s.URL == "" {
1336			log.Debug().Msg("toggle favorite: empty URL")
1337			return
1338		}
1339
1340		go func() {
1341			ctx := o.ctx
1342			goLog := logging.FromContext(ctx)
1343
1344			result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
1345			if err != nil {
1346				goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite")
1347				if o.onToast != nil {
1348					msg := "Failed to add favorite"
1349					if s.IsFavorite {
1350						msg = "Failed to remove favorite"
1351					}
1352					cb := glib.SourceFunc(func(_ uintptr) bool {
1353						o.onToast(ctx, msg, ToastError)
1354						return false
1355					})
1356					glib.IdleAdd(&cb, 0)
1357				}
1358				return
1359			}
1360
1361			// Update suggestion state
1362			o.mu.Lock()
1363			if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {
1364				o.suggestions[idx].IsFavorite = result.Added
1365			}
1366			o.mu.Unlock()
1367
1368			// Update row CSS and show toast on GTK main thread
1369			cb := glib.SourceFunc(func(_ uintptr) bool {
1370				o.updateRowFavoriteIndicator(idx, result.Added)
1371				if o.onToast != nil {
1372					o.onToast(ctx, result.Message, ToastSuccess)
1373				}
1374				return false
1375			})
1376			glib.IdleAdd(&cb, 0)
1377		}()
1378	} else {
1379		// Remove from favorites
1380		if idx < 0 || idx >= len(favorites) {
1381			log.Debug().Int("index", idx).Msg("remove favorite: invalid selection")
1382			return
1383		}
1384
1385		f := favorites[idx]
1386		if f.ID == 0 {
1387			log.Debug().Msg("remove favorite: invalid ID")
1388			return
1389		}
1390
1391		go func() {
1392			ctx := o.ctx
1393			log := logging.FromContext(ctx)
1394
1395			log.Debug().Int64("id", f.ID).Str("url", f.URL).Msg("removing from favorites")
1396
1397			err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
1398			if err != nil {
1399				log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
1400				// Show error toast
1401				if o.onToast != nil {
1402					cb := glib.SourceFunc(func(_ uintptr) bool {
1403						o.onToast(ctx, "Failed to remove favorite", ToastError)
1404						return false
1405					})
1406					glib.IdleAdd(&cb, 0)
1407				}
1408				return
1409			}
1410
1411			log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
1412
1413			// Refresh favorites list and show toast
1414			o.loadFavorites(o.entry.GetText())
1415			if o.onToast != nil {
1416				cb := glib.SourceFunc(func(_ uintptr) bool {
1417					o.onToast(ctx, "Favorite removed", ToastSuccess)
1418					return false
1419				})
1420				glib.IdleAdd(&cb, 0)
1421			}
1422		}()
1423	}
1424}
1425
1426// updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
1427// Must be called from GTK main thread (via glib.IdleAdd).
1428func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) {
1429	row := o.listBox.GetRowAtIndex(index)
1430	if row == nil {
1431		return
1432	}
1433	if isFavorite {
1434		row.AddCssClass("omnibox-row-favorite")
1435	} else {
1436		row.RemoveCssClass("omnibox-row-favorite")
1437	}
1438}
1439
1440// yankSelectedURL copies the URL of the selected item to clipboard.
1441func (o *Omnibox) yankSelectedURL() {
1442	log := logging.FromContext(o.ctx)
1443
1444	if o.copyURLUC == nil {
1445		log.Warn().Msg("yank URL: copy URL use case is nil")
1446		return
1447	}
1448
1449	o.mu.RLock()
1450	mode := o.viewMode
1451	idx := o.selectedIndex
1452	suggestions := o.suggestions
1453	favorites := o.favorites
1454	o.mu.RUnlock()
1455
1456	var selectedURL string
1457	if mode == ViewModeHistory {
1458		if idx < 0 || idx >= len(suggestions) {
1459			log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1460			return
1461		}
1462		selectedURL = suggestions[idx].URL
1463	} else {
1464		if idx < 0 || idx >= len(favorites) {
1465			log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1466			return
1467		}
1468		selectedURL = favorites[idx].URL
1469	}
1470
1471	if selectedURL == "" {
1472		log.Debug().Msg("yank URL: empty URL")
1473		return
1474	}
1475
1476	go func() {
1477		ctx := o.ctx
1478		if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
1479			return // Use case already logs the error
1480		}
1481
1482		// Show toast notification on success (must run on GTK main thread)
1483		if o.onToast != nil {
1484			cb := glib.SourceFunc(func(_ uintptr) bool {
1485				o.onToast(ctx, "URL copied", ToastSuccess)
1486				return false // Don't repeat
1487			})
1488			glib.IdleAdd(&cb, 0)
1489		}
1490	}()
1491}
1492
1493// buildURL constructs a URL from text, handling search shortcuts.
1494func (o *Omnibox) buildURL(text string) string {
1495	var shortcutURLs map[string]string
1496	if o.shortcutsUC != nil {
1497		shortcutURLs = o.shortcutsUC.ShortcutURLs()
1498	}
1499	return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
1500}
1501
1502// toggleViewMode switches between history and favorites.
1503func (o *Omnibox) toggleViewMode() {
1504	o.mu.RLock()
1505	current := o.viewMode
1506	o.mu.RUnlock()
1507
1508	if current == ViewModeHistory {
1509		o.setViewMode(ViewModeFavorites)
1510	} else {
1511		o.setViewMode(ViewModeHistory)
1512	}
1513}
1514
1515// setViewMode changes the view mode and updates UI.
1516func (o *Omnibox) setViewMode(mode ViewMode) {
1517	o.mu.Lock()
1518	o.viewMode = mode
1519	o.mu.Unlock()
1520
1521	// Update header button styling
1522	if mode == ViewModeHistory {
1523		o.historyBtn.AddCssClass("omnibox-header-active")
1524		o.favoritesBtn.RemoveCssClass("omnibox-header-active")
1525	} else {
1526		o.historyBtn.RemoveCssClass("omnibox-header-active")
1527		o.favoritesBtn.AddCssClass("omnibox-header-active")
1528	}
1529
1530	// Reload data
1531	o.performSearch()
1532}
1533
1534// Show opens the omnibox with optional initial query.
1535func (o *Omnibox) Show(ctx context.Context, query string) {
1536	log := logging.FromContext(ctx)
1537	log.Debug().Str("query", query).Msg("showing omnibox")
1538
1539	o.mu.Lock()
1540	if o.visible {
1541		o.mu.Unlock()
1542		return
1543	}
1544	o.visible = true
1545	o.mu.Unlock()
1546
1547	// Set initial query
1548	o.entry.SetText(query)
1549
1550	// Determine if we expect content initially
1551	// No content expected if: no query AND initialBehavior is "none"
1552	expectContent := query != "" || o.initialBehavior != "none"
1553
1554	// Hide scrolled window if no content expected
1555	if o.scrolledWin != nil {
1556		o.scrolledWin.SetVisible(expectContent)
1557		// Reset content height constraints - will be updated when results arrive
1558		o.scrolledWin.SetMinContentHeight(-1)
1559		o.scrolledWin.SetMaxContentHeight(0)
1560	}
1561
1562	// Calculate dimensions using shared helper
1563	width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
1564
1565	o.mainBox.SetSizeRequest(width, -1)
1566	o.outerBox.SetMarginTop(marginTop)
1567
1568	// Show the omnibox
1569	o.outerBox.SetVisible(true)
1570
1571	// Trigger initial resize (will be updated when results arrive)
1572	o.resizeAndCenter(0)
1573
1574	// Focus the entry
1575	o.entry.GrabFocus()
1576
1577	// Load initial data (may update size later if results found)
1578	o.performSearch()
1579}
1580
1581// Hide closes the omnibox.
1582func (o *Omnibox) Hide(ctx context.Context) {
1583	log := logging.FromContext(ctx)
1584	log.Debug().Msg("hiding omnibox")
1585
1586	o.mu.Lock()
1587	if !o.visible {
1588		o.mu.Unlock()
1589		return
1590	}
1591	o.visible = false
1592	o.mu.Unlock()
1593
1594	// Cancel any pending search
1595	o.debounceMu.Lock()
1596	if o.debounceTimer != nil {
1597		o.debounceTimer.Stop()
1598		o.debounceTimer = nil
1599	}
1600	o.debounceMu.Unlock()
1601
1602	// Hide omnibox
1603	o.outerBox.SetVisible(false)
1604
1605	// Clear state
1606	o.clearBangState()
1607	o.entry.SetText("")
1608	o.listBox.RemoveAll()
1609
1610	if o.onClose != nil {
1611		o.onClose()
1612	}
1613}
1614
1615// Toggle shows if hidden, hides if visible.
1616func (o *Omnibox) Toggle(ctx context.Context) {
1617	o.mu.RLock()
1618	visible := o.visible
1619	o.mu.RUnlock()
1620
1621	if visible {
1622		o.Hide(ctx)
1623	} else {
1624		o.Show(ctx, "")
1625	}
1626}
1627
1628// IsVisible returns whether the omnibox is currently shown.
1629func (o *Omnibox) IsVisible() bool {
1630	o.mu.RLock()
1631	defer o.mu.RUnlock()
1632	return o.visible
1633}
1634
1635// SetOnNavigate sets the callback for URL navigation.
1636func (o *Omnibox) SetOnNavigate(fn func(url string)) {
1637	o.onNavigate = fn
1638}
1639
1640// SetOnClose sets the callback for omnibox close events.
1641func (o *Omnibox) SetOnClose(fn func()) {
1642	o.onClose = fn
1643}
1644
1645// UpdateZoomIndicator updates the zoom percentage display.
1646// Shows the indicator when zoom != 100%, hides it when at 100%.
1647func (o *Omnibox) UpdateZoomIndicator(factor float64) {
1648	var cb glib.SourceFunc = func(data uintptr) bool {
1649		if o.zoomLabel == nil {
1650			return false
1651		}
1652		if factor == 1.0 {
1653			o.zoomLabel.SetVisible(false)
1654		} else {
1655			percentage := int(factor * 100)
1656			o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
1657			o.zoomLabel.SetVisible(true)
1658		}
1659		return false
1660	}
1661	glib.IdleAdd(&cb, 0)
1662}
1663
1664// idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
1665func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
1666	var cb glib.SourceFunc = func(data uintptr) bool {
1667		o.updateSuggestions(suggestions)
1668		return false // One-shot callback
1669	}
1670	glib.IdleAdd(&cb, 0)
1671}
1672
1673// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
1674// Returns empty map on error to avoid blocking history display.
1675func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
1676	if o.favoritesUC == nil {
1677		return make(map[string]struct{})
1678	}
1679	urls, err := o.favoritesUC.GetAllURLs(ctx)
1680	if err != nil {
1681		log := logging.FromContext(ctx)
1682		log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
1683		return make(map[string]struct{})
1684	}
1685	return urls
1686}
1687
1688// idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
1689func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
1690	var cb glib.SourceFunc = func(data uintptr) bool {
1691		o.updateFavorites(favorites)
1692		return false // One-shot callback, return false to remove source
1693	}
1694	glib.IdleAdd(&cb, 0)
1695}
1696
1697// Widget returns the omnibox widget for embedding in an overlay.
1698func (o *Omnibox) Widget() *gtk.Widget {
1699	if o.outerBox == nil {
1700		return nil
1701	}
1702	return &o.outerBox.Widget
1703}
1704
1705// Destroy cleans up omnibox resources.
1706func (o *Omnibox) Destroy() {
1707	o.debounceMu.Lock()
1708	if o.debounceTimer != nil {
1709		o.debounceTimer.Stop()
1710	}
1711	o.debounceMu.Unlock()
1712
1713	o.mu.Lock()
1714	o.visible = false
1715	o.mu.Unlock()
1716
1717	o.parentOverlay = nil
1718	o.retainedCallbacks = nil
1719
1720	if o.outerBox != nil {
1721		o.outerBox.Unparent()
1722		o.outerBox = nil
1723	}
1724}
1725
1726// formatShortcut formats a shortcut number.
1727func formatShortcut(n int) string {
1728	return "Ctrl+" + string(rune('0'+n))
1729}
1730
1731// measureWidgetHeight returns the natural height of a widget for the given width.
1732// Returns 0 if widget is nil or measurement fails.
1733func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
1734	if widget == nil {
1735		return 0
1736	}
1737	var minHeight, naturalHeight, minBaseline, naturalBaseline int
1738	widget.Measure(
1739		gtk.OrientationVerticalValue,
1740		forWidth,
1741		&minHeight,
1742		&naturalHeight,
1743		&minBaseline,
1744		&naturalBaseline,
1745	)
1746	return naturalHeight
1747}
1748
1749// measureComponentHeights measures and caches all component heights.
1750// Must be called on GTK main thread after widgets have been laid out.
1751func (o *Omnibox) measureComponentHeights(forWidth int) bool {
1752	if o.headerBox == nil || o.entry == nil || o.listBox == nil {
1753		return false
1754	}
1755
1756	headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
1757	entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
1758
1759	// Measure first row if it exists
1760	var rowH int
1761	if row := o.listBox.GetRowAtIndex(0); row != nil {
1762		rowH = measureWidgetHeight(&row.Widget, forWidth)
1763	}
1764
1765	// Validate measurements (header and entry must be positive)
1766	if headerH <= 0 || entryH <= 0 {
1767		return false
1768	}
1769
1770	o.measuredHeights.header = headerH
1771	o.measuredHeights.entry = entryH
1772	if rowH > 0 {
1773		o.measuredHeights.singleRow = rowH
1774		o.measuredHeights.valid = true
1775	}
1776	return true
1777}
1778
1779// errNilWidget creates an error for nil widget creation.
1780func errNilWidget(name string) error {
1781	return &widgetError{name: name}
1782}
1783
1784type widgetError struct {
1785	name string
1786}
1787
1788func (e *widgetError) Error() string {
1789	return "failed to create widget: " + e.name
1790}

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