v0.22.0 a85da87

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

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