detached 5195a15

Merge pull request #132 from bnema/feat/omnibox-ghost-completion

feat(omnibox): add inline ghost text autocompletion
commit
5195a15459a03570f5f15edcd200eb4206a35da3
author
bnema · · 2026-01-23 18:20 UTC
committer
GitHub · · 2026-01-23 18:20 UTC
merge of
c0865ed + 1d6b2aa · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +649 -14
A internal/application/usecase/autocomplete.go +186 -0
@@ -0,0 +1,186 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/autocomplete"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// AutocompleteUseCase handles inline autocomplete suggestions.
11+type AutocompleteUseCase struct {
12+ historyUC *SearchHistoryUseCase
13+ favoritesUC *ManageFavoritesUseCase
14+ shortcutsUC *SearchShortcutsUseCase
15+}
16+
17+// NewAutocompleteUseCase creates a new autocomplete use case.
18+func NewAutocompleteUseCase(
19+ historyUC *SearchHistoryUseCase,
20+ favoritesUC *ManageFavoritesUseCase,
21+ shortcutsUC *SearchShortcutsUseCase,
22+) *AutocompleteUseCase {
23+ return &AutocompleteUseCase{
24+ historyUC: historyUC,
25+ favoritesUC: favoritesUC,
26+ shortcutsUC: shortcutsUC,
27+ }
28+}
29+
30+// GetSuggestionInput contains parameters for getting an autocomplete suggestion.
31+type GetSuggestionInput struct {
32+ Input string // Current user input
33+}
34+
35+// GetSuggestionOutput contains the best autocomplete suggestion.
36+type GetSuggestionOutput struct {
37+ Suggestion *autocomplete.Suggestion
38+ Found bool
39+}
40+
41+// GetSuggestion returns the best autocomplete suggestion for the given input.
42+// Priority: Favorites > History (by frecency) > Bang shortcuts.
43+func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
44+ log := logging.FromContext(ctx)
45+
46+ if input.Input == "" {
47+ return &GetSuggestionOutput{Found: false}
48+ }
49+
50+ // Try favorites first (highest priority)
51+ if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil {
52+ log.Debug().
53+ Str("input", input.Input).
54+ Str("match", suggestion.FullText).
55+ Msg("autocomplete: matched favorite")
56+ return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
57+ }
58+
59+ // Try history (second priority)
60+ if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil {
61+ log.Debug().
62+ Str("input", input.Input).
63+ Str("match", suggestion.FullText).
64+ Msg("autocomplete: matched history")
65+ return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
66+ }
67+
68+ // Try bang shortcuts (lowest priority for URL-style completion)
69+ if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil {
70+ log.Debug().
71+ Str("input", input.Input).
72+ Str("match", suggestion.FullText).
73+ Msg("autocomplete: matched bang shortcut")
74+ return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
75+ }
76+
77+ return &GetSuggestionOutput{Found: false}
78+}
79+
80+// findFavoriteSuggestion finds the best matching favorite for the input.
81+func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
82+ if uc.favoritesUC == nil {
83+ return nil
84+ }
85+
86+ favorites, err := uc.favoritesUC.GetAll(ctx)
87+ if err != nil {
88+ return nil
89+ }
90+
91+ for _, fav := range favorites {
92+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)
93+ if ok {
94+ return &autocomplete.Suggestion{
95+ FullText: matchedURL,
96+ Suffix: suffix,
97+ Source: autocomplete.SourceFavorite,
98+ Title: fav.Title,
99+ }
100+ }
101+ }
102+
103+ return nil
104+}
105+
106+// findHistorySuggestion finds the best matching history entry for the input.
107+func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
108+ if uc.historyUC == nil {
109+ return nil
110+ }
111+
112+ // Search history for matches
113+ output, err := uc.historyUC.Search(ctx, SearchInput{
114+ Query: input,
115+ Limit: 10, // Get top 10 to find best prefix match
116+ })
117+ if err != nil || output == nil {
118+ return nil
119+ }
120+
121+ // Find the first result that is a prefix match
122+ for _, match := range output.Matches {
123+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, match.Entry.URL)
124+ if ok {
125+ return &autocomplete.Suggestion{
126+ FullText: matchedURL,
127+ Suffix: suffix,
128+ Source: autocomplete.SourceHistory,
129+ Title: match.Entry.Title,
130+ }
131+ }
132+ }
133+
134+ return nil
135+}
136+
137+// findBangSuggestion finds the best matching bang shortcut for the input.
138+func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
139+ if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
140+ return nil
141+ }
142+
143+ // Get filtered bang suggestions
144+ output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input})
145+ if len(output.Suggestions) == 0 {
146+ return nil
147+ }
148+
149+ // Use the first (best) match
150+ bang := output.Suggestions[0]
151+ fullBang := "!" + bang.Key
152+
153+ suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang)
154+ if ok {
155+ return &autocomplete.Suggestion{
156+ FullText: fullBang,
157+ Suffix: suffix,
158+ Source: autocomplete.SourceBangShortcut,
159+ Title: bang.Description,
160+ }
161+ }
162+
163+ return nil
164+}
165+
166+// GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
167+// This is used when the user selects a row from the list to update ghost text.
168+func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
169+ if input == "" || targetURL == "" {
170+ return &GetSuggestionOutput{Found: false}
171+ }
172+
173+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL)
174+ if !ok {
175+ return &GetSuggestionOutput{Found: false}
176+ }
177+
178+ return &GetSuggestionOutput{
179+ Suggestion: &autocomplete.Suggestion{
180+ FullText: matchedURL,
181+ Suffix: suffix,
182+ Source: autocomplete.SourceHistory, // Default to history for selected rows
183+ },
184+ Found: true,
185+ }
186+}
@@ -0,0 +1,186 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/autocomplete"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// AutocompleteUseCase handles inline autocomplete suggestions.
11+type AutocompleteUseCase struct {
12+ historyUC *SearchHistoryUseCase
13+ favoritesUC *ManageFavoritesUseCase
14+ shortcutsUC *SearchShortcutsUseCase
15+}
16+
17+// NewAutocompleteUseCase creates a new autocomplete use case.
18+func NewAutocompleteUseCase(
19+ historyUC *SearchHistoryUseCase,
20+ favoritesUC *ManageFavoritesUseCase,
21+ shortcutsUC *SearchShortcutsUseCase,
22+) *AutocompleteUseCase {
23+ return &AutocompleteUseCase{
24+ historyUC: historyUC,
25+ favoritesUC: favoritesUC,
26+ shortcutsUC: shortcutsUC,
27+ }
28+}
29+
30+// GetSuggestionInput contains parameters for getting an autocomplete suggestion.
31+type GetSuggestionInput struct {
32+ Input string // Current user input
33+}
34+
35+// GetSuggestionOutput contains the best autocomplete suggestion.
36+type GetSuggestionOutput struct {
37+ Suggestion *autocomplete.Suggestion
38+ Found bool
39+}
40+
41+// GetSuggestion returns the best autocomplete suggestion for the given input.
42+// Priority: Favorites > History (by frecency) > Bang shortcuts.
43+func (uc *AutocompleteUseCase) GetSuggestion(ctx context.Context, input GetSuggestionInput) *GetSuggestionOutput {
44+ log := logging.FromContext(ctx)
45+
46+ if input.Input == "" {
47+ return &GetSuggestionOutput{Found: false}
48+ }
49+
50+ // Try favorites first (highest priority)
51+ if suggestion := uc.findFavoriteSuggestion(ctx, input.Input); suggestion != nil {
52+ log.Debug().
53+ Str("input", input.Input).
54+ Str("match", suggestion.FullText).
55+ Msg("autocomplete: matched favorite")
56+ return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
57+ }
58+
59+ // Try history (second priority)
60+ if suggestion := uc.findHistorySuggestion(ctx, input.Input); suggestion != nil {
61+ log.Debug().
62+ Str("input", input.Input).
63+ Str("match", suggestion.FullText).
64+ Msg("autocomplete: matched history")
65+ return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
66+ }
67+
68+ // Try bang shortcuts (lowest priority for URL-style completion)
69+ if suggestion := uc.findBangSuggestion(ctx, input.Input); suggestion != nil {
70+ log.Debug().
71+ Str("input", input.Input).
72+ Str("match", suggestion.FullText).
73+ Msg("autocomplete: matched bang shortcut")
74+ return &GetSuggestionOutput{Suggestion: suggestion, Found: true}
75+ }
76+
77+ return &GetSuggestionOutput{Found: false}
78+}
79+
80+// findFavoriteSuggestion finds the best matching favorite for the input.
81+func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
82+ if uc.favoritesUC == nil {
83+ return nil
84+ }
85+
86+ favorites, err := uc.favoritesUC.GetAll(ctx)
87+ if err != nil {
88+ return nil
89+ }
90+
91+ for _, fav := range favorites {
92+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)
93+ if ok {
94+ return &autocomplete.Suggestion{
95+ FullText: matchedURL,
96+ Suffix: suffix,
97+ Source: autocomplete.SourceFavorite,
98+ Title: fav.Title,
99+ }
100+ }
101+ }
102+
103+ return nil
104+}
105+
106+// findHistorySuggestion finds the best matching history entry for the input.
107+func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
108+ if uc.historyUC == nil {
109+ return nil
110+ }
111+
112+ // Search history for matches
113+ output, err := uc.historyUC.Search(ctx, SearchInput{
114+ Query: input,
115+ Limit: 10, // Get top 10 to find best prefix match
116+ })
117+ if err != nil || output == nil {
118+ return nil
119+ }
120+
121+ // Find the first result that is a prefix match
122+ for _, match := range output.Matches {
123+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, match.Entry.URL)
124+ if ok {
125+ return &autocomplete.Suggestion{
126+ FullText: matchedURL,
127+ Suffix: suffix,
128+ Source: autocomplete.SourceHistory,
129+ Title: match.Entry.Title,
130+ }
131+ }
132+ }
133+
134+ return nil
135+}
136+
137+// findBangSuggestion finds the best matching bang shortcut for the input.
138+func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input string) *autocomplete.Suggestion {
139+ if uc.shortcutsUC == nil || len(input) < 1 || input[0] != '!' {
140+ return nil
141+ }
142+
143+ // Get filtered bang suggestions
144+ output := uc.shortcutsUC.FilterBangs(ctx, FilterBangsInput{Query: input})
145+ if len(output.Suggestions) == 0 {
146+ return nil
147+ }
148+
149+ // Use the first (best) match
150+ bang := output.Suggestions[0]
151+ fullBang := "!" + bang.Key
152+
153+ suffix, ok := autocomplete.ComputeCompletionSuffix(input, fullBang)
154+ if ok {
155+ return &autocomplete.Suggestion{
156+ FullText: fullBang,
157+ Suffix: suffix,
158+ Source: autocomplete.SourceBangShortcut,
159+ Title: bang.Description,
160+ }
161+ }
162+
163+ return nil
164+}
165+
166+// GetSuggestionForURL returns an autocomplete suggestion specifically for a URL.
167+// This is used when the user selects a row from the list to update ghost text.
168+func (*AutocompleteUseCase) GetSuggestionForURL(_ context.Context, input, targetURL string) *GetSuggestionOutput {
169+ if input == "" || targetURL == "" {
170+ return &GetSuggestionOutput{Found: false}
171+ }
172+
173+ suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, targetURL)
174+ if !ok {
175+ return &GetSuggestionOutput{Found: false}
176+ }
177+
178+ return &GetSuggestionOutput{
179+ Suggestion: &autocomplete.Suggestion{
180+ FullText: matchedURL,
181+ Suffix: suffix,
182+ Source: autocomplete.SourceHistory, // Default to history for selected rows
183+ },
184+ Found: true,
185+ }
186+}
A internal/domain/autocomplete/autocomplete.go +80 -0
@@ -0,0 +1,80 @@
1+// Package autocomplete provides domain types and logic for URL autocompletion.
2+package autocomplete
3+
4+import "strings"
5+
6+// SuggestionSource indicates the origin of an autocomplete suggestion.
7+type SuggestionSource int
8+
9+const (
10+ SourceHistory SuggestionSource = iota
11+ SourceFavorite
12+ SourceBangShortcut
13+)
14+
15+// Suggestion represents a single autocomplete suggestion with its completion suffix.
16+type Suggestion struct {
17+ FullText string // The complete text (URL or bang shortcut)
18+ Suffix string // The suffix to append after the user's input
19+ Source SuggestionSource // Where this suggestion came from
20+ Title string // Display title (page title or description)
21+}
22+
23+// ComputeCompletionSuffix returns the suffix if input is a case-insensitive prefix of fullText.
24+// Returns the suffix and true if input matches as a prefix, otherwise empty string and false.
25+func ComputeCompletionSuffix(input, fullText string) (string, bool) {
26+ if input == "" || fullText == "" {
27+ return "", false
28+ }
29+
30+ inputLower := strings.ToLower(input)
31+ fullLower := strings.ToLower(fullText)
32+
33+ if !strings.HasPrefix(fullLower, inputLower) {
34+ return "", false
35+ }
36+
37+ // Return the original-case suffix from fullText
38+ suffix := fullText[len(input):]
39+ return suffix, suffix != ""
40+}
41+
42+// StripProtocol removes http:// or https:// prefix from a URL for matching.
43+func StripProtocol(url string) string {
44+ if strings.HasPrefix(url, "https://") {
45+ return url[8:]
46+ }
47+ if strings.HasPrefix(url, "http://") {
48+ return url[7:]
49+ }
50+ return url
51+}
52+
53+// ComputeURLCompletionSuffix computes the completion suffix for URLs,
54+// handling protocol stripping for better matching.
55+// It tries matching with and without protocol prefixes.
56+func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL string, ok bool) {
57+ // First try direct match
58+ if suffix, ok := ComputeCompletionSuffix(input, fullURL); ok {
59+ return suffix, fullURL, true
60+ }
61+
62+ // Try matching against URL without protocol
63+ strippedURL := StripProtocol(fullURL)
64+ if suffix, ok := ComputeCompletionSuffix(input, strippedURL); ok {
65+ return suffix, strippedURL, true
66+ }
67+
68+ // Try matching if input has www. but URL doesn't (or vice versa)
69+ inputNoWWW := strings.TrimPrefix(input, "www.")
70+ strippedNoWWW := strings.TrimPrefix(strippedURL, "www.")
71+ if suffix, ok := ComputeCompletionSuffix(inputNoWWW, strippedNoWWW); ok {
72+ // Return suffix relative to input
73+ if strings.HasPrefix(strings.ToLower(strippedURL), strings.ToLower(input)) {
74+ return strippedURL[len(input):], strippedURL, true
75+ }
76+ return suffix, strippedNoWWW, true
77+ }
78+
79+ return "", "", false
80+}
@@ -0,0 +1,80 @@
1+// Package autocomplete provides domain types and logic for URL autocompletion.
2+package autocomplete
3+
4+import "strings"
5+
6+// SuggestionSource indicates the origin of an autocomplete suggestion.
7+type SuggestionSource int
8+
9+const (
10+ SourceHistory SuggestionSource = iota
11+ SourceFavorite
12+ SourceBangShortcut
13+)
14+
15+// Suggestion represents a single autocomplete suggestion with its completion suffix.
16+type Suggestion struct {
17+ FullText string // The complete text (URL or bang shortcut)
18+ Suffix string // The suffix to append after the user's input
19+ Source SuggestionSource // Where this suggestion came from
20+ Title string // Display title (page title or description)
21+}
22+
23+// ComputeCompletionSuffix returns the suffix if input is a case-insensitive prefix of fullText.
24+// Returns the suffix and true if input matches as a prefix, otherwise empty string and false.
25+func ComputeCompletionSuffix(input, fullText string) (string, bool) {
26+ if input == "" || fullText == "" {
27+ return "", false
28+ }
29+
30+ inputLower := strings.ToLower(input)
31+ fullLower := strings.ToLower(fullText)
32+
33+ if !strings.HasPrefix(fullLower, inputLower) {
34+ return "", false
35+ }
36+
37+ // Return the original-case suffix from fullText
38+ suffix := fullText[len(input):]
39+ return suffix, suffix != ""
40+}
41+
42+// StripProtocol removes http:// or https:// prefix from a URL for matching.
43+func StripProtocol(url string) string {
44+ if strings.HasPrefix(url, "https://") {
45+ return url[8:]
46+ }
47+ if strings.HasPrefix(url, "http://") {
48+ return url[7:]
49+ }
50+ return url
51+}
52+
53+// ComputeURLCompletionSuffix computes the completion suffix for URLs,
54+// handling protocol stripping for better matching.
55+// It tries matching with and without protocol prefixes.
56+func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL string, ok bool) {
57+ // First try direct match
58+ if suffix, ok := ComputeCompletionSuffix(input, fullURL); ok {
59+ return suffix, fullURL, true
60+ }
61+
62+ // Try matching against URL without protocol
63+ strippedURL := StripProtocol(fullURL)
64+ if suffix, ok := ComputeCompletionSuffix(input, strippedURL); ok {
65+ return suffix, strippedURL, true
66+ }
67+
68+ // Try matching if input has www. but URL doesn't (or vice versa)
69+ inputNoWWW := strings.TrimPrefix(input, "www.")
70+ strippedNoWWW := strings.TrimPrefix(strippedURL, "www.")
71+ if suffix, ok := ComputeCompletionSuffix(inputNoWWW, strippedNoWWW); ok {
72+ // Return suffix relative to input
73+ if strings.HasPrefix(strings.ToLower(strippedURL), strings.ToLower(input)) {
74+ return strippedURL[len(input):], strippedURL, true
75+ }
76+ return suffix, strippedNoWWW, true
77+ }
78+
79+ return "", "", false
80+}
M internal/ui/app.go +4 -0
@@ -590,6 +590,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
590590 }
591591 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
592592
593+ // Create autocomplete use case for inline suggestions
594+ autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
595+
593596 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
594597 a.omniboxCfg = component.OmniboxConfig{
595598 HistoryUC: a.deps.HistoryUC,
@@ -597,6 +600,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
597600 FaviconAdapter: a.faviconAdapter,
598601 CopyURLUC: a.deps.CopyURLUC,
599602 ShortcutsUC: shortcutsUC,
603+ AutocompleteUC: autocompleteUC,
600604 DefaultSearch: a.deps.Config.DefaultSearchEngine,
601605 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
602606 UIScale: a.deps.Config.DefaultUIScale,
@@ -590,6 +590,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
590 }590 }
591 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)591 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
592 592
593+ // Create autocomplete use case for inline suggestions
594+ autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
595+
593 // Store omnibox config (omnibox is created per-pane via WorkspaceView).596 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
594 a.omniboxCfg = component.OmniboxConfig{597 a.omniboxCfg = component.OmniboxConfig{
595 HistoryUC: a.deps.HistoryUC,598 HistoryUC: a.deps.HistoryUC,
@@ -597,6 +600,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
597 FaviconAdapter: a.faviconAdapter,600 FaviconAdapter: a.faviconAdapter,
598 CopyURLUC: a.deps.CopyURLUC,601 CopyURLUC: a.deps.CopyURLUC,
599 ShortcutsUC: shortcutsUC,602 ShortcutsUC: shortcutsUC,
603+ AutocompleteUC: autocompleteUC,
600 DefaultSearch: a.deps.Config.DefaultSearchEngine,604 DefaultSearch: a.deps.Config.DefaultSearchEngine,
601 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,605 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
602 UIScale: a.deps.Config.DefaultUIScale,606 UIScale: a.deps.Config.DefaultUIScale,
M internal/ui/component/omnibox.go +367 -14
@@ -17,11 +17,13 @@ import (
1717 "github.com/jwijenbergh/puregotk/v4/gdk"
1818 "github.com/jwijenbergh/puregotk/v4/glib"
1919 "github.com/jwijenbergh/puregotk/v4/gtk"
20+ "github.com/jwijenbergh/puregotk/v4/pango"
2021 )
2122
2223 const (
23- debounceDelayMs = 150
24- endBoxSpacing = 6
24+ debounceDelayMs = 50
25+ endBoxSpacing = 6
26+ ghostIconOffsetBase = 18 // Base icon offset for ghost text positioning (scaled by uiScale)
2527 )
2628
2729 // ViewMode distinguishes history search from favorites display.
@@ -67,6 +69,8 @@ type Omnibox struct {
6769 zoomLabel *gtk.Label
6870 bangBadge *gtk.Label
6971 entry *gtk.SearchEntry
72+ entryOverlay *gtk.Overlay // Overlay for ghost text
73+ ghostLabel *gtk.Label // Ghost text completion label
7074 scrolledWin *gtk.ScrolledWindow
7175 listBox *gtk.ListBox
7276
@@ -85,12 +89,20 @@ type Omnibox struct {
8589 detectedBang string
8690 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
8791
92+ // Ghost text state
93+ realInput string // What user actually typed (without ghost suffix)
94+ ghostSuffix string // Completion suffix to display
95+ ghostFullText string // Full text that ghost completes to
96+ hasGhostText bool // Quick check for Tab behavior
97+ isAcceptingGhost bool // Prevent re-trigger during acceptance
98+
8899 // Dependencies
89100 historyUC *usecase.SearchHistoryUseCase
90101 favoritesUC *usecase.ManageFavoritesUseCase
91102 faviconAdapter *adapter.FaviconAdapter
92103 copyURLUC *usecase.CopyURLUseCase
93104 shortcutsUC *usecase.SearchShortcutsUseCase
105+ autocompleteUC *usecase.AutocompleteUseCase
94106 defaultSearch string
95107 initialBehavior string
96108 ctx context.Context
@@ -127,6 +139,7 @@ type OmniboxConfig struct {
127139 FaviconAdapter *adapter.FaviconAdapter
128140 CopyURLUC *usecase.CopyURLUseCase
129141 ShortcutsUC *usecase.SearchShortcutsUseCase
142+ AutocompleteUC *usecase.AutocompleteUseCase
130143 DefaultSearch string
131144 InitialBehavior string
132145 UIScale float64 // UI scale for favicon sizing
@@ -154,6 +167,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
154167 faviconAdapter: cfg.FaviconAdapter,
155168 copyURLUC: cfg.CopyURLUC,
156169 shortcutsUC: cfg.ShortcutsUC,
170+ autocompleteUC: cfg.AutocompleteUC,
157171 defaultSearch: cfg.DefaultSearch,
158172 initialBehavior: cfg.InitialBehavior,
159173 onToast: cfg.OnToast,
@@ -389,6 +403,28 @@ func (o *Omnibox) initEntry() error {
389403
390404 placeholder := "Search history or enter URL… (! lists bangs)"
391405 o.entry.SetPlaceholderText(&placeholder)
406+
407+ // Create overlay for ghost text
408+ o.entryOverlay = gtk.NewOverlay()
409+ if o.entryOverlay == nil {
410+ return errNilWidget("entryOverlay")
411+ }
412+ o.entryOverlay.SetChild(&o.entry.Widget)
413+ o.entryOverlay.SetHexpand(true)
414+
415+ // Create ghost label for autocomplete suffix
416+ o.ghostLabel = gtk.NewLabel(nil)
417+ if o.ghostLabel == nil {
418+ return errNilWidget("ghostLabel")
419+ }
420+ o.ghostLabel.AddCssClass("omnibox-ghost")
421+ o.ghostLabel.SetHalign(gtk.AlignStartValue)
422+ o.ghostLabel.SetValign(gtk.AlignCenterValue)
423+ o.ghostLabel.SetVisible(false)
424+ o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
425+
426+ o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
427+
392428 return nil
393429 }
394430
@@ -425,6 +461,48 @@ func (o *Omnibox) initList() error {
425461 }
426462 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
427463 o.listBox.ConnectRowSelected(&rowSelectedCb)
464+
465+ // Handle row activation (click or Enter) - navigate directly to the URL
466+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
467+ if rowPtr == 0 {
468+ return
469+ }
470+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
471+ if row == nil {
472+ return
473+ }
474+ idx := row.GetIndex()
475+ o.mu.RLock()
476+ mode := o.viewMode
477+ bangMode := o.bangMode
478+ suggestions := o.suggestions
479+ favorites := o.favorites
480+ o.mu.RUnlock()
481+
482+ // Don't navigate in bang mode - activating a bang should fill it in
483+ if bangMode {
484+ return
485+ }
486+
487+ var targetURL string
488+ if mode == ViewModeHistory {
489+ if idx >= 0 && idx < len(suggestions) {
490+ targetURL = suggestions[idx].URL
491+ }
492+ } else {
493+ if idx >= 0 && idx < len(favorites) {
494+ targetURL = favorites[idx].URL
495+ }
496+ }
497+
498+ if targetURL != "" && o.onNavigate != nil {
499+ o.Hide(o.ctx)
500+ o.onNavigate(targetURL)
501+ }
502+ }
503+ o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
504+ o.listBox.ConnectRowActivated(&rowActivatedCb)
505+
428506 return nil
429507 }
430508
@@ -447,7 +525,7 @@ func (o *Omnibox) assembleWidgets() {
447525
448526 o.scrolledWin.SetChild(&o.listBox.Widget)
449527 o.mainBox.Append(&o.headerBox.Widget)
450- o.mainBox.Append(&o.entry.Widget)
528+ o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
451529 o.mainBox.Append(&o.scrolledWin.Widget)
452530 o.outerBox.Append(&o.mainBox.Widget)
453531 }
@@ -493,6 +571,20 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO
493571 o.entry.AddController(&focusController.EventController)
494572 }
495573
574+// hasGhost returns whether ghost text is currently visible.
575+func (o *Omnibox) hasGhost() bool {
576+ o.mu.RLock()
577+ defer o.mu.RUnlock()
578+ return o.hasGhostText
579+}
580+
581+// hasUserNavigated returns whether user has navigated with arrow keys.
582+func (o *Omnibox) hasUserNavigated() bool {
583+ o.mu.RLock()
584+ defer o.mu.RUnlock()
585+ return o.hasNavigated
586+}
587+
496588 // handleKeyPress processes keyboard events.
497589 // Returns true if the event was handled.
498590 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
@@ -521,23 +613,34 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
521613
522614 case uint(gdk.KEY_Up):
523615 o.selectPrevious()
616+ o.updateGhostFromSelection()
524617 return true
525618
526619 case uint(gdk.KEY_Down):
527620 o.selectNext()
621+ o.updateGhostFromSelection()
528622 return true
529623
530624 case uint(gdk.KEY_Tab):
625+ // Tab accepts ghost text if present, otherwise toggles view mode
626+ if o.hasGhost() {
627+ o.acceptGhostCompletion()
628+ return true
629+ }
531630 o.toggleViewMode()
532631 return true
533632
633+ case uint(gdk.KEY_Right):
634+ // Arrow Right accepts ghost text if present
635+ if o.hasGhost() {
636+ o.acceptGhostCompletion()
637+ return true
638+ }
639+ return false // Let entry handle normal cursor movement
640+
534641 case uint(gdk.KEY_space):
535642 // 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 {
643+ if o.hasUserNavigated() {
541644 o.toggleFavorite()
542645 return true
543646 }
@@ -545,10 +648,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
545648
546649 case uint(gdk.KEY_y):
547650 // '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 {
651+ if o.hasUserNavigated() {
552652 o.yankSelectedURL()
553653 return true
554654 }
@@ -599,11 +699,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
599699
600700 // onEntryChanged handles text input changes with debouncing.
601701 func (o *Omnibox) onEntryChanged() {
702+ // Skip if we're programmatically accepting ghost text
703+ o.mu.RLock()
704+ isAccepting := o.isAcceptingGhost
705+ o.mu.RUnlock()
706+ if isAccepting {
707+ return
708+ }
709+
602710 // Reset navigation state when user types - space should type, not toggle favorite
603711 o.mu.Lock()
604712 o.hasNavigated = false
713+ // Update realInput with what user actually typed
714+ o.realInput = o.entry.GetText()
605715 o.mu.Unlock()
606716
717+ // Clear ghost text when user types (it will be recalculated after search)
718+ o.clearGhostText()
719+
607720 o.debounceMu.Lock()
608721 if o.debounceTimer != nil {
609722 o.debounceTimer.Stop()
@@ -614,6 +727,221 @@ func (o *Omnibox) onEntryChanged() {
614727 o.debounceMu.Unlock()
615728 }
616729
730+// setGhostText displays a ghost completion suffix in the overlay label.
731+// The ghost text shows what would complete the current input.
732+// originalInput is the input that was used for the autocomplete query.
733+// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
734+func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
735+ if o.ghostLabel == nil || suffix == "" {
736+ o.clearGhostText()
737+ return
738+ }
739+
740+ o.mu.Lock()
741+ currentInput := o.realInput
742+ // Check if input has changed since the autocomplete query was made
743+ // Skip this check when originalInput is empty (row selection replaces full input)
744+ if originalInput != "" && currentInput != originalInput {
745+ o.mu.Unlock()
746+ // Input changed, skip this stale result - a newer query will handle it
747+ return
748+ }
749+ o.ghostSuffix = suffix
750+ o.ghostFullText = fullText
751+ o.hasGhostText = true
752+ o.mu.Unlock()
753+
754+ var cb glib.SourceFunc = func(uintptr) bool {
755+ if o.ghostLabel == nil || o.entry == nil {
756+ return false
757+ }
758+
759+ // Get the entry's Pango context and create a layout to measure text width
760+ pangoCtx := o.entry.GetPangoContext()
761+ if pangoCtx == nil {
762+ return false
763+ }
764+
765+ // Create layout to measure the input text width
766+ layout := pango.NewLayout(pangoCtx)
767+ layout.SetText(originalInput, len(originalInput))
768+
769+ // Get the width in pixels
770+ var widthPx, heightPx int
771+ layout.GetPixelSize(&widthPx, &heightPx)
772+
773+ // Account for the search icon inside the entry
774+ // Ghost label already has same margin/padding as entry via CSS
775+ // Scale the icon offset by UI scale factor
776+ iconOffset := int(float64(ghostIconOffsetBase) * o.uiScale)
777+ marginStart := widthPx + iconOffset
778+
779+ // Set the ghost label text (just the suffix) and position it
780+ o.ghostLabel.SetText(suffix)
781+ o.ghostLabel.SetMarginStart(marginStart)
782+ o.ghostLabel.SetVisible(true)
783+
784+ // Hide placeholder text so it doesn't show through ghost text
785+ emptyPlaceholder := ""
786+ o.entry.SetPlaceholderText(&emptyPlaceholder)
787+
788+ return false
789+ }
790+ glib.IdleAdd(&cb, 0)
791+}
792+
793+// clearGhostText hides the ghost completion text.
794+func (o *Omnibox) clearGhostText() {
795+ o.mu.Lock()
796+ o.ghostSuffix = ""
797+ o.ghostFullText = ""
798+ o.hasGhostText = false
799+ o.mu.Unlock()
800+
801+ if o.ghostLabel == nil {
802+ return
803+ }
804+
805+ var cb glib.SourceFunc = func(uintptr) bool {
806+ if o.ghostLabel != nil {
807+ o.ghostLabel.SetVisible(false)
808+ o.ghostLabel.SetText("")
809+ }
810+ // Restore placeholder text
811+ if o.entry != nil {
812+ placeholder := "Search history or enter URL… (! lists bangs)"
813+ o.entry.SetPlaceholderText(&placeholder)
814+ }
815+ return false
816+ }
817+ glib.IdleAdd(&cb, 0)
818+}
819+
820+// acceptGhostCompletion accepts the ghost text and fills the input.
821+func (o *Omnibox) acceptGhostCompletion() {
822+ o.mu.Lock()
823+ if !o.hasGhostText || o.ghostFullText == "" {
824+ o.mu.Unlock()
825+ return
826+ }
827+ fullText := o.ghostFullText
828+ o.isAcceptingGhost = true
829+ o.realInput = fullText
830+ o.ghostSuffix = ""
831+ o.ghostFullText = ""
832+ o.hasGhostText = false
833+ o.mu.Unlock()
834+
835+ // Update entry text and clear ghost label
836+ o.entry.SetText(fullText)
837+ o.entry.SetPosition(-1) // Move cursor to end
838+
839+ if o.ghostLabel != nil {
840+ o.ghostLabel.SetVisible(false)
841+ }
842+
843+ o.mu.Lock()
844+ o.isAcceptingGhost = false
845+ o.mu.Unlock()
846+}
847+
848+// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
849+func (o *Omnibox) updateGhostFromSuggestion() {
850+ if o.autocompleteUC == nil {
851+ return
852+ }
853+
854+ o.mu.RLock()
855+ userInput := o.realInput
856+ bangMode := o.bangMode
857+ o.mu.RUnlock()
858+
859+ // Don't show ghost text in bang mode (it has its own completion)
860+ if bangMode || userInput == "" {
861+ o.clearGhostText()
862+ return
863+ }
864+
865+ go func() {
866+ output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
867+ if !output.Found || output.Suggestion == nil {
868+ o.clearGhostText()
869+ return
870+ }
871+ // Pass the original input so setGhostText can verify it's still current
872+ o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
873+ }()
874+}
875+
876+// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
877+// When a row is selected via arrow keys, show the URL as ghost text even if
878+// the user's input isn't a prefix - this shows what Tab would fill in.
879+func (o *Omnibox) updateGhostFromURL(targetURL string) {
880+ if targetURL == "" {
881+ o.clearGhostText()
882+ return
883+ }
884+
885+ o.mu.RLock()
886+ userInput := o.realInput
887+ o.mu.RUnlock()
888+
889+ // Strip protocol from URL for cleaner display
890+ displayURL := targetURL
891+ if strings.HasPrefix(displayURL, "https://") {
892+ displayURL = displayURL[8:]
893+ } else if strings.HasPrefix(displayURL, "http://") {
894+ displayURL = displayURL[7:]
895+ }
896+
897+ // Try to compute proper suffix if input is a prefix of the URL
898+ if o.autocompleteUC != nil && userInput != "" {
899+ output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)
900+ if output.Found && output.Suggestion != nil {
901+ o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
902+ return
903+ }
904+ }
905+
906+ // If no prefix match, show the full URL as ghost text (replacing user's input)
907+ // Use empty string as original input so it shows from the start
908+ o.setGhostText("", displayURL, displayURL)
909+}
910+
911+// updateGhostFromSelection updates ghost text based on the currently selected row.
912+func (o *Omnibox) updateGhostFromSelection() {
913+ o.mu.RLock()
914+ idx := o.selectedIndex
915+ mode := o.viewMode
916+ bangMode := o.bangMode
917+ suggestions := o.suggestions
918+ favorites := o.favorites
919+ o.mu.RUnlock()
920+
921+ // No ghost text in bang mode
922+ if bangMode {
923+ return
924+ }
925+
926+ var targetURL string
927+ if mode == ViewModeHistory {
928+ if idx >= 0 && idx < len(suggestions) {
929+ targetURL = suggestions[idx].URL
930+ }
931+ } else {
932+ if idx >= 0 && idx < len(favorites) {
933+ targetURL = favorites[idx].URL
934+ }
935+ }
936+
937+ if targetURL == "" {
938+ o.clearGhostText()
939+ return
940+ }
941+
942+ o.updateGhostFromURL(targetURL)
943+}
944+
617945 // performSearch executes the search based on current view mode and query.
618946 func (o *Omnibox) performSearch() {
619947 o.mu.RLock()
@@ -927,9 +1255,13 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
9271255 }
9281256 o.resizeAndCenter(rowCount)
9291257
930- // Select first item if available
1258+ // Select first item if available and update ghost text
9311259 if rowCount > 0 {
9321260 o.selectIndex(0)
1261+ // Update ghost text from first suggestion
1262+ o.updateGhostFromSuggestion()
1263+ } else {
1264+ o.clearGhostText()
9331265 }
9341266 }
9351267
@@ -951,9 +1283,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
9511283 }
9521284 o.resizeAndCenter(rowCount)
9531285
954- // Select first item if available
1286+ // Select first item if available and update ghost text
9551287 if rowCount > 0 {
9561288 o.selectIndex(0)
1289+ // Update ghost text from first favorite
1290+ o.updateGhostFromSuggestion()
1291+ } else {
1292+ o.clearGhostText()
9571293 }
9581294 }
9591295
@@ -1536,8 +1872,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
15361872 return
15371873 }
15381874 o.visible = true
1875+ // Initialize ghost text state
1876+ o.realInput = query
1877+ o.ghostSuffix = ""
1878+ o.ghostFullText = ""
1879+ o.hasGhostText = false
1880+ o.isAcceptingGhost = false
15391881 o.mu.Unlock()
15401882
1883+ // Clear any stale ghost text display
1884+ if o.ghostLabel != nil {
1885+ o.ghostLabel.SetVisible(false)
1886+ }
1887+
15411888 // Set initial query
15421889 o.entry.SetText(query)
15431890
@@ -1598,9 +1945,15 @@ func (o *Omnibox) Hide(ctx context.Context) {
15981945
15991946 // Clear state
16001947 o.clearBangState()
1948+ o.clearGhostText()
16011949 o.entry.SetText("")
16021950 o.listBox.RemoveAll()
16031951
1952+ // Reset realInput
1953+ o.mu.Lock()
1954+ o.realInput = ""
1955+ o.mu.Unlock()
1956+
16041957 if o.onClose != nil {
16051958 o.onClose()
16061959 }
@@ -17,11 +17,13 @@ import (
17 "github.com/jwijenbergh/puregotk/v4/gdk"17 "github.com/jwijenbergh/puregotk/v4/gdk"
18 "github.com/jwijenbergh/puregotk/v4/glib"18 "github.com/jwijenbergh/puregotk/v4/glib"
19 "github.com/jwijenbergh/puregotk/v4/gtk"19 "github.com/jwijenbergh/puregotk/v4/gtk"
20+ "github.com/jwijenbergh/puregotk/v4/pango"
20 )21 )
21 22
22 const (23 const (
23- debounceDelayMs = 15024+ debounceDelayMs = 50
24- endBoxSpacing = 625+ endBoxSpacing = 6
26+ ghostIconOffsetBase = 18 // Base icon offset for ghost text positioning (scaled by uiScale)
25 )27 )
26 28
27 // ViewMode distinguishes history search from favorites display.29 // ViewMode distinguishes history search from favorites display.
@@ -67,6 +69,8 @@ type Omnibox struct {
67 zoomLabel *gtk.Label69 zoomLabel *gtk.Label
68 bangBadge *gtk.Label70 bangBadge *gtk.Label
69 entry *gtk.SearchEntry71 entry *gtk.SearchEntry
72+ entryOverlay *gtk.Overlay // Overlay for ghost text
73+ ghostLabel *gtk.Label // Ghost text completion label
70 scrolledWin *gtk.ScrolledWindow74 scrolledWin *gtk.ScrolledWindow
71 listBox *gtk.ListBox75 listBox *gtk.ListBox
72 76
@@ -85,12 +89,20 @@ type Omnibox struct {
85 detectedBang string89 detectedBang string
86 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)90 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
87 91
92+ // Ghost text state
93+ realInput string // What user actually typed (without ghost suffix)
94+ ghostSuffix string // Completion suffix to display
95+ ghostFullText string // Full text that ghost completes to
96+ hasGhostText bool // Quick check for Tab behavior
97+ isAcceptingGhost bool // Prevent re-trigger during acceptance
98+
88 // Dependencies99 // Dependencies
89 historyUC *usecase.SearchHistoryUseCase100 historyUC *usecase.SearchHistoryUseCase
90 favoritesUC *usecase.ManageFavoritesUseCase101 favoritesUC *usecase.ManageFavoritesUseCase
91 faviconAdapter *adapter.FaviconAdapter102 faviconAdapter *adapter.FaviconAdapter
92 copyURLUC *usecase.CopyURLUseCase103 copyURLUC *usecase.CopyURLUseCase
93 shortcutsUC *usecase.SearchShortcutsUseCase104 shortcutsUC *usecase.SearchShortcutsUseCase
105+ autocompleteUC *usecase.AutocompleteUseCase
94 defaultSearch string106 defaultSearch string
95 initialBehavior string107 initialBehavior string
96 ctx context.Context108 ctx context.Context
@@ -127,6 +139,7 @@ type OmniboxConfig struct {
127 FaviconAdapter *adapter.FaviconAdapter139 FaviconAdapter *adapter.FaviconAdapter
128 CopyURLUC *usecase.CopyURLUseCase140 CopyURLUC *usecase.CopyURLUseCase
129 ShortcutsUC *usecase.SearchShortcutsUseCase141 ShortcutsUC *usecase.SearchShortcutsUseCase
142+ AutocompleteUC *usecase.AutocompleteUseCase
130 DefaultSearch string143 DefaultSearch string
131 InitialBehavior string144 InitialBehavior string
132 UIScale float64 // UI scale for favicon sizing145 UIScale float64 // UI scale for favicon sizing
@@ -154,6 +167,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
154 faviconAdapter: cfg.FaviconAdapter,167 faviconAdapter: cfg.FaviconAdapter,
155 copyURLUC: cfg.CopyURLUC,168 copyURLUC: cfg.CopyURLUC,
156 shortcutsUC: cfg.ShortcutsUC,169 shortcutsUC: cfg.ShortcutsUC,
170+ autocompleteUC: cfg.AutocompleteUC,
157 defaultSearch: cfg.DefaultSearch,171 defaultSearch: cfg.DefaultSearch,
158 initialBehavior: cfg.InitialBehavior,172 initialBehavior: cfg.InitialBehavior,
159 onToast: cfg.OnToast,173 onToast: cfg.OnToast,
@@ -389,6 +403,28 @@ func (o *Omnibox) initEntry() error {
389 403
390 placeholder := "Search history or enter URL… (! lists bangs)"404 placeholder := "Search history or enter URL… (! lists bangs)"
391 o.entry.SetPlaceholderText(&placeholder)405 o.entry.SetPlaceholderText(&placeholder)
406+
407+ // Create overlay for ghost text
408+ o.entryOverlay = gtk.NewOverlay()
409+ if o.entryOverlay == nil {
410+ return errNilWidget("entryOverlay")
411+ }
412+ o.entryOverlay.SetChild(&o.entry.Widget)
413+ o.entryOverlay.SetHexpand(true)
414+
415+ // Create ghost label for autocomplete suffix
416+ o.ghostLabel = gtk.NewLabel(nil)
417+ if o.ghostLabel == nil {
418+ return errNilWidget("ghostLabel")
419+ }
420+ o.ghostLabel.AddCssClass("omnibox-ghost")
421+ o.ghostLabel.SetHalign(gtk.AlignStartValue)
422+ o.ghostLabel.SetValign(gtk.AlignCenterValue)
423+ o.ghostLabel.SetVisible(false)
424+ o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
425+
426+ o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
427+
392 return nil428 return nil
393 }429 }
394 430
@@ -425,6 +461,48 @@ func (o *Omnibox) initList() error {
425 }461 }
426 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)462 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
427 o.listBox.ConnectRowSelected(&rowSelectedCb)463 o.listBox.ConnectRowSelected(&rowSelectedCb)
464+
465+ // Handle row activation (click or Enter) - navigate directly to the URL
466+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
467+ if rowPtr == 0 {
468+ return
469+ }
470+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
471+ if row == nil {
472+ return
473+ }
474+ idx := row.GetIndex()
475+ o.mu.RLock()
476+ mode := o.viewMode
477+ bangMode := o.bangMode
478+ suggestions := o.suggestions
479+ favorites := o.favorites
480+ o.mu.RUnlock()
481+
482+ // Don't navigate in bang mode - activating a bang should fill it in
483+ if bangMode {
484+ return
485+ }
486+
487+ var targetURL string
488+ if mode == ViewModeHistory {
489+ if idx >= 0 && idx < len(suggestions) {
490+ targetURL = suggestions[idx].URL
491+ }
492+ } else {
493+ if idx >= 0 && idx < len(favorites) {
494+ targetURL = favorites[idx].URL
495+ }
496+ }
497+
498+ if targetURL != "" && o.onNavigate != nil {
499+ o.Hide(o.ctx)
500+ o.onNavigate(targetURL)
501+ }
502+ }
503+ o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
504+ o.listBox.ConnectRowActivated(&rowActivatedCb)
505+
428 return nil506 return nil
429 }507 }
430 508
@@ -447,7 +525,7 @@ func (o *Omnibox) assembleWidgets() {
447 525
448 o.scrolledWin.SetChild(&o.listBox.Widget)526 o.scrolledWin.SetChild(&o.listBox.Widget)
449 o.mainBox.Append(&o.headerBox.Widget)527 o.mainBox.Append(&o.headerBox.Widget)
450- o.mainBox.Append(&o.entry.Widget)528+ o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
451 o.mainBox.Append(&o.scrolledWin.Widget)529 o.mainBox.Append(&o.scrolledWin.Widget)
452 o.outerBox.Append(&o.mainBox.Widget)530 o.outerBox.Append(&o.mainBox.Widget)
453 }531 }
@@ -493,6 +571,20 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO
493 o.entry.AddController(&focusController.EventController)571 o.entry.AddController(&focusController.EventController)
494 }572 }
495 573
574+// hasGhost returns whether ghost text is currently visible.
575+func (o *Omnibox) hasGhost() bool {
576+ o.mu.RLock()
577+ defer o.mu.RUnlock()
578+ return o.hasGhostText
579+}
580+
581+// hasUserNavigated returns whether user has navigated with arrow keys.
582+func (o *Omnibox) hasUserNavigated() bool {
583+ o.mu.RLock()
584+ defer o.mu.RUnlock()
585+ return o.hasNavigated
586+}
587+
496 // handleKeyPress processes keyboard events.588 // handleKeyPress processes keyboard events.
497 // Returns true if the event was handled.589 // Returns true if the event was handled.
498 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {590 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
@@ -521,23 +613,34 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
521 613
522 case uint(gdk.KEY_Up):614 case uint(gdk.KEY_Up):
523 o.selectPrevious()615 o.selectPrevious()
616+ o.updateGhostFromSelection()
524 return true617 return true
525 618
526 case uint(gdk.KEY_Down):619 case uint(gdk.KEY_Down):
527 o.selectNext()620 o.selectNext()
621+ o.updateGhostFromSelection()
528 return true622 return true
529 623
530 case uint(gdk.KEY_Tab):624 case uint(gdk.KEY_Tab):
625+ // Tab accepts ghost text if present, otherwise toggles view mode
626+ if o.hasGhost() {
627+ o.acceptGhostCompletion()
628+ return true
629+ }
531 o.toggleViewMode()630 o.toggleViewMode()
532 return true631 return true
533 632
633+ case uint(gdk.KEY_Right):
634+ // Arrow Right accepts ghost text if present
635+ if o.hasGhost() {
636+ o.acceptGhostCompletion()
637+ return true
638+ }
639+ return false // Let entry handle normal cursor movement
640+
534 case uint(gdk.KEY_space):641 case uint(gdk.KEY_space):
535 // Space toggles favorite only if user has navigated with arrow keys642 // Space toggles favorite only if user has navigated with arrow keys
536- // Otherwise, let space pass through to entry for typing643+ if o.hasUserNavigated() {
537- o.mu.RLock()
538- navigated := o.hasNavigated
539- o.mu.RUnlock()
540- if navigated {
541 o.toggleFavorite()644 o.toggleFavorite()
542 return true645 return true
543 }646 }
@@ -545,10 +648,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
545 648
546 case uint(gdk.KEY_y):649 case uint(gdk.KEY_y):
547 // 'y' yanks (copies) the selected URL to clipboard when navigating650 // 'y' yanks (copies) the selected URL to clipboard when navigating
548- o.mu.RLock()651+ if o.hasUserNavigated() {
549- navigated := o.hasNavigated
550- o.mu.RUnlock()
551- if navigated {
552 o.yankSelectedURL()652 o.yankSelectedURL()
553 return true653 return true
554 }654 }
@@ -599,11 +699,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
599 699
600 // onEntryChanged handles text input changes with debouncing.700 // onEntryChanged handles text input changes with debouncing.
601 func (o *Omnibox) onEntryChanged() {701 func (o *Omnibox) onEntryChanged() {
702+ // Skip if we're programmatically accepting ghost text
703+ o.mu.RLock()
704+ isAccepting := o.isAcceptingGhost
705+ o.mu.RUnlock()
706+ if isAccepting {
707+ return
708+ }
709+
602 // Reset navigation state when user types - space should type, not toggle favorite710 // Reset navigation state when user types - space should type, not toggle favorite
603 o.mu.Lock()711 o.mu.Lock()
604 o.hasNavigated = false712 o.hasNavigated = false
713+ // Update realInput with what user actually typed
714+ o.realInput = o.entry.GetText()
605 o.mu.Unlock()715 o.mu.Unlock()
606 716
717+ // Clear ghost text when user types (it will be recalculated after search)
718+ o.clearGhostText()
719+
607 o.debounceMu.Lock()720 o.debounceMu.Lock()
608 if o.debounceTimer != nil {721 if o.debounceTimer != nil {
609 o.debounceTimer.Stop()722 o.debounceTimer.Stop()
@@ -614,6 +727,221 @@ func (o *Omnibox) onEntryChanged() {
614 o.debounceMu.Unlock()727 o.debounceMu.Unlock()
615 }728 }
616 729
730+// setGhostText displays a ghost completion suffix in the overlay label.
731+// The ghost text shows what would complete the current input.
732+// originalInput is the input that was used for the autocomplete query.
733+// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
734+func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
735+ if o.ghostLabel == nil || suffix == "" {
736+ o.clearGhostText()
737+ return
738+ }
739+
740+ o.mu.Lock()
741+ currentInput := o.realInput
742+ // Check if input has changed since the autocomplete query was made
743+ // Skip this check when originalInput is empty (row selection replaces full input)
744+ if originalInput != "" && currentInput != originalInput {
745+ o.mu.Unlock()
746+ // Input changed, skip this stale result - a newer query will handle it
747+ return
748+ }
749+ o.ghostSuffix = suffix
750+ o.ghostFullText = fullText
751+ o.hasGhostText = true
752+ o.mu.Unlock()
753+
754+ var cb glib.SourceFunc = func(uintptr) bool {
755+ if o.ghostLabel == nil || o.entry == nil {
756+ return false
757+ }
758+
759+ // Get the entry's Pango context and create a layout to measure text width
760+ pangoCtx := o.entry.GetPangoContext()
761+ if pangoCtx == nil {
762+ return false
763+ }
764+
765+ // Create layout to measure the input text width
766+ layout := pango.NewLayout(pangoCtx)
767+ layout.SetText(originalInput, len(originalInput))
768+
769+ // Get the width in pixels
770+ var widthPx, heightPx int
771+ layout.GetPixelSize(&widthPx, &heightPx)
772+
773+ // Account for the search icon inside the entry
774+ // Ghost label already has same margin/padding as entry via CSS
775+ // Scale the icon offset by UI scale factor
776+ iconOffset := int(float64(ghostIconOffsetBase) * o.uiScale)
777+ marginStart := widthPx + iconOffset
778+
779+ // Set the ghost label text (just the suffix) and position it
780+ o.ghostLabel.SetText(suffix)
781+ o.ghostLabel.SetMarginStart(marginStart)
782+ o.ghostLabel.SetVisible(true)
783+
784+ // Hide placeholder text so it doesn't show through ghost text
785+ emptyPlaceholder := ""
786+ o.entry.SetPlaceholderText(&emptyPlaceholder)
787+
788+ return false
789+ }
790+ glib.IdleAdd(&cb, 0)
791+}
792+
793+// clearGhostText hides the ghost completion text.
794+func (o *Omnibox) clearGhostText() {
795+ o.mu.Lock()
796+ o.ghostSuffix = ""
797+ o.ghostFullText = ""
798+ o.hasGhostText = false
799+ o.mu.Unlock()
800+
801+ if o.ghostLabel == nil {
802+ return
803+ }
804+
805+ var cb glib.SourceFunc = func(uintptr) bool {
806+ if o.ghostLabel != nil {
807+ o.ghostLabel.SetVisible(false)
808+ o.ghostLabel.SetText("")
809+ }
810+ // Restore placeholder text
811+ if o.entry != nil {
812+ placeholder := "Search history or enter URL… (! lists bangs)"
813+ o.entry.SetPlaceholderText(&placeholder)
814+ }
815+ return false
816+ }
817+ glib.IdleAdd(&cb, 0)
818+}
819+
820+// acceptGhostCompletion accepts the ghost text and fills the input.
821+func (o *Omnibox) acceptGhostCompletion() {
822+ o.mu.Lock()
823+ if !o.hasGhostText || o.ghostFullText == "" {
824+ o.mu.Unlock()
825+ return
826+ }
827+ fullText := o.ghostFullText
828+ o.isAcceptingGhost = true
829+ o.realInput = fullText
830+ o.ghostSuffix = ""
831+ o.ghostFullText = ""
832+ o.hasGhostText = false
833+ o.mu.Unlock()
834+
835+ // Update entry text and clear ghost label
836+ o.entry.SetText(fullText)
837+ o.entry.SetPosition(-1) // Move cursor to end
838+
839+ if o.ghostLabel != nil {
840+ o.ghostLabel.SetVisible(false)
841+ }
842+
843+ o.mu.Lock()
844+ o.isAcceptingGhost = false
845+ o.mu.Unlock()
846+}
847+
848+// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
849+func (o *Omnibox) updateGhostFromSuggestion() {
850+ if o.autocompleteUC == nil {
851+ return
852+ }
853+
854+ o.mu.RLock()
855+ userInput := o.realInput
856+ bangMode := o.bangMode
857+ o.mu.RUnlock()
858+
859+ // Don't show ghost text in bang mode (it has its own completion)
860+ if bangMode || userInput == "" {
861+ o.clearGhostText()
862+ return
863+ }
864+
865+ go func() {
866+ output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
867+ if !output.Found || output.Suggestion == nil {
868+ o.clearGhostText()
869+ return
870+ }
871+ // Pass the original input so setGhostText can verify it's still current
872+ o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
873+ }()
874+}
875+
876+// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
877+// When a row is selected via arrow keys, show the URL as ghost text even if
878+// the user's input isn't a prefix - this shows what Tab would fill in.
879+func (o *Omnibox) updateGhostFromURL(targetURL string) {
880+ if targetURL == "" {
881+ o.clearGhostText()
882+ return
883+ }
884+
885+ o.mu.RLock()
886+ userInput := o.realInput
887+ o.mu.RUnlock()
888+
889+ // Strip protocol from URL for cleaner display
890+ displayURL := targetURL
891+ if strings.HasPrefix(displayURL, "https://") {
892+ displayURL = displayURL[8:]
893+ } else if strings.HasPrefix(displayURL, "http://") {
894+ displayURL = displayURL[7:]
895+ }
896+
897+ // Try to compute proper suffix if input is a prefix of the URL
898+ if o.autocompleteUC != nil && userInput != "" {
899+ output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)
900+ if output.Found && output.Suggestion != nil {
901+ o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
902+ return
903+ }
904+ }
905+
906+ // If no prefix match, show the full URL as ghost text (replacing user's input)
907+ // Use empty string as original input so it shows from the start
908+ o.setGhostText("", displayURL, displayURL)
909+}
910+
911+// updateGhostFromSelection updates ghost text based on the currently selected row.
912+func (o *Omnibox) updateGhostFromSelection() {
913+ o.mu.RLock()
914+ idx := o.selectedIndex
915+ mode := o.viewMode
916+ bangMode := o.bangMode
917+ suggestions := o.suggestions
918+ favorites := o.favorites
919+ o.mu.RUnlock()
920+
921+ // No ghost text in bang mode
922+ if bangMode {
923+ return
924+ }
925+
926+ var targetURL string
927+ if mode == ViewModeHistory {
928+ if idx >= 0 && idx < len(suggestions) {
929+ targetURL = suggestions[idx].URL
930+ }
931+ } else {
932+ if idx >= 0 && idx < len(favorites) {
933+ targetURL = favorites[idx].URL
934+ }
935+ }
936+
937+ if targetURL == "" {
938+ o.clearGhostText()
939+ return
940+ }
941+
942+ o.updateGhostFromURL(targetURL)
943+}
944+
617 // performSearch executes the search based on current view mode and query.945 // performSearch executes the search based on current view mode and query.
618 func (o *Omnibox) performSearch() {946 func (o *Omnibox) performSearch() {
619 o.mu.RLock()947 o.mu.RLock()
@@ -927,9 +1255,13 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
927 }1255 }
928 o.resizeAndCenter(rowCount)1256 o.resizeAndCenter(rowCount)
929 1257
930- // Select first item if available1258+ // Select first item if available and update ghost text
931 if rowCount > 0 {1259 if rowCount > 0 {
932 o.selectIndex(0)1260 o.selectIndex(0)
1261+ // Update ghost text from first suggestion
1262+ o.updateGhostFromSuggestion()
1263+ } else {
1264+ o.clearGhostText()
933 }1265 }
934 }1266 }
935 1267
@@ -951,9 +1283,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
951 }1283 }
952 o.resizeAndCenter(rowCount)1284 o.resizeAndCenter(rowCount)
953 1285
954- // Select first item if available1286+ // Select first item if available and update ghost text
955 if rowCount > 0 {1287 if rowCount > 0 {
956 o.selectIndex(0)1288 o.selectIndex(0)
1289+ // Update ghost text from first favorite
1290+ o.updateGhostFromSuggestion()
1291+ } else {
1292+ o.clearGhostText()
957 }1293 }
958 }1294 }
959 1295
@@ -1536,8 +1872,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
1536 return1872 return
1537 }1873 }
1538 o.visible = true1874 o.visible = true
1875+ // Initialize ghost text state
1876+ o.realInput = query
1877+ o.ghostSuffix = ""
1878+ o.ghostFullText = ""
1879+ o.hasGhostText = false
1880+ o.isAcceptingGhost = false
1539 o.mu.Unlock()1881 o.mu.Unlock()
1540 1882
1883+ // Clear any stale ghost text display
1884+ if o.ghostLabel != nil {
1885+ o.ghostLabel.SetVisible(false)
1886+ }
1887+
1541 // Set initial query1888 // Set initial query
1542 o.entry.SetText(query)1889 o.entry.SetText(query)
1543 1890
@@ -1598,9 +1945,15 @@ func (o *Omnibox) Hide(ctx context.Context) {
1598 1945
1599 // Clear state1946 // Clear state
1600 o.clearBangState()1947 o.clearBangState()
1948+ o.clearGhostText()
1601 o.entry.SetText("")1949 o.entry.SetText("")
1602 o.listBox.RemoveAll()1950 o.listBox.RemoveAll()
1603 1951
1952+ // Reset realInput
1953+ o.mu.Lock()
1954+ o.realInput = ""
1955+ o.mu.Unlock()
1956+
1604 if o.onClose != nil {1957 if o.onClose != nil {
1605 o.onClose()1958 o.onClose()
1606 }1959 }
M internal/ui/theme/css.go +12 -0
@@ -409,6 +409,18 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
409409 color: var(--accent);
410410 font-weight: 600;
411411 }
412+
413+/* Ghost text autocomplete overlay - positioned to align with entry text */
414+.omnibox-ghost {
415+ font-size: 1em;
416+ color: var(--muted);
417+ padding: 0.625em 0.75em;
418+ margin: 0.5em 0.75em;
419+ /* Match the entry padding/margin exactly for alignment */
420+ background-color: transparent;
421+ /* Allow clicks to pass through to entry below */
422+ pointer-events: none;
423+}
412424 `
413425 }
414426
@@ -409,6 +409,18 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
409 color: var(--accent);409 color: var(--accent);
410 font-weight: 600;410 font-weight: 600;
411 }411 }
412+
413+/* Ghost text autocomplete overlay - positioned to align with entry text */
414+.omnibox-ghost {
415+ font-size: 1em;
416+ color: var(--muted);
417+ padding: 0.625em 0.75em;
418+ margin: 0.5em 0.75em;
419+ /* Match the entry padding/margin exactly for alignment */
420+ background-color: transparent;
421+ /* Allow clicks to pass through to entry below */
422+ pointer-events: none;
423+}
412 `424 `
413 }425 }
414 426

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