detached df1aa18

feat(omnibox): add inline ghost text autocompletion

commit
df1aa186b7d3a6cdfb138dd9fe405862b0635789
author
brice · · 2026-01-22 17:14 UTC
committer
bnema · · 2026-01-23 18:07 UTC
parent
c0865ed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +664 -4
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 (uc *AutocompleteUseCase) GetSuggestionForURL(ctx 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 (uc *AutocompleteUseCase) GetSuggestionForURL(ctx 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 string, 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 string, 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 +382 -4
@@ -17,10 +17,11 @@ 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+ debounceDelayMs = 50
2425 endBoxSpacing = 6
2526 )
2627
@@ -67,6 +68,8 @@ type Omnibox struct {
6768 zoomLabel *gtk.Label
6869 bangBadge *gtk.Label
6970 entry *gtk.SearchEntry
71+ entryOverlay *gtk.Overlay // Overlay for ghost text
72+ ghostLabel *gtk.Label // Ghost text completion label
7073 scrolledWin *gtk.ScrolledWindow
7174 listBox *gtk.ListBox
7275
@@ -85,12 +88,20 @@ type Omnibox struct {
8588 detectedBang string
8689 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
8790
91+ // Ghost text state
92+ realInput string // What user actually typed (without ghost suffix)
93+ ghostSuffix string // Completion suffix to display
94+ ghostFullText string // Full text that ghost completes to
95+ hasGhostText bool // Quick check for Tab behavior
96+ isAcceptingGhost bool // Prevent re-trigger during acceptance
97+
8898 // Dependencies
8999 historyUC *usecase.SearchHistoryUseCase
90100 favoritesUC *usecase.ManageFavoritesUseCase
91101 faviconAdapter *adapter.FaviconAdapter
92102 copyURLUC *usecase.CopyURLUseCase
93103 shortcutsUC *usecase.SearchShortcutsUseCase
104+ autocompleteUC *usecase.AutocompleteUseCase
94105 defaultSearch string
95106 initialBehavior string
96107 ctx context.Context
@@ -127,6 +138,7 @@ type OmniboxConfig struct {
127138 FaviconAdapter *adapter.FaviconAdapter
128139 CopyURLUC *usecase.CopyURLUseCase
129140 ShortcutsUC *usecase.SearchShortcutsUseCase
141+ AutocompleteUC *usecase.AutocompleteUseCase
130142 DefaultSearch string
131143 InitialBehavior string
132144 UIScale float64 // UI scale for favicon sizing
@@ -154,6 +166,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
154166 faviconAdapter: cfg.FaviconAdapter,
155167 copyURLUC: cfg.CopyURLUC,
156168 shortcutsUC: cfg.ShortcutsUC,
169+ autocompleteUC: cfg.AutocompleteUC,
157170 defaultSearch: cfg.DefaultSearch,
158171 initialBehavior: cfg.InitialBehavior,
159172 onToast: cfg.OnToast,
@@ -389,6 +402,28 @@ func (o *Omnibox) initEntry() error {
389402
390403 placeholder := "Search history or enter URL… (! lists bangs)"
391404 o.entry.SetPlaceholderText(&placeholder)
405+
406+ // Create overlay for ghost text
407+ o.entryOverlay = gtk.NewOverlay()
408+ if o.entryOverlay == nil {
409+ return errNilWidget("entryOverlay")
410+ }
411+ o.entryOverlay.SetChild(&o.entry.Widget)
412+ o.entryOverlay.SetHexpand(true)
413+
414+ // Create ghost label for autocomplete suffix
415+ o.ghostLabel = gtk.NewLabel(nil)
416+ if o.ghostLabel == nil {
417+ return errNilWidget("ghostLabel")
418+ }
419+ o.ghostLabel.AddCssClass("omnibox-ghost")
420+ o.ghostLabel.SetHalign(gtk.AlignStartValue)
421+ o.ghostLabel.SetValign(gtk.AlignCenterValue)
422+ o.ghostLabel.SetVisible(false)
423+ o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
424+
425+ o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
426+
392427 return nil
393428 }
394429
@@ -425,6 +460,48 @@ func (o *Omnibox) initList() error {
425460 }
426461 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
427462 o.listBox.ConnectRowSelected(&rowSelectedCb)
463+
464+ // Handle row activation (click or Enter) - navigate directly to the URL
465+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
466+ if rowPtr == 0 {
467+ return
468+ }
469+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
470+ if row == nil {
471+ return
472+ }
473+ idx := row.GetIndex()
474+ o.mu.RLock()
475+ mode := o.viewMode
476+ bangMode := o.bangMode
477+ suggestions := o.suggestions
478+ favorites := o.favorites
479+ o.mu.RUnlock()
480+
481+ // Don't navigate in bang mode - activating a bang should fill it in
482+ if bangMode {
483+ return
484+ }
485+
486+ var targetURL string
487+ if mode == ViewModeHistory {
488+ if idx >= 0 && idx < len(suggestions) {
489+ targetURL = suggestions[idx].URL
490+ }
491+ } else {
492+ if idx >= 0 && idx < len(favorites) {
493+ targetURL = favorites[idx].URL
494+ }
495+ }
496+
497+ if targetURL != "" && o.onNavigate != nil {
498+ o.Hide(o.ctx)
499+ o.onNavigate(targetURL)
500+ }
501+ }
502+ o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
503+ o.listBox.ConnectRowActivated(&rowActivatedCb)
504+
428505 return nil
429506 }
430507
@@ -447,7 +524,7 @@ func (o *Omnibox) assembleWidgets() {
447524
448525 o.scrolledWin.SetChild(&o.listBox.Widget)
449526 o.mainBox.Append(&o.headerBox.Widget)
450- o.mainBox.Append(&o.entry.Widget)
527+ o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
451528 o.mainBox.Append(&o.scrolledWin.Widget)
452529 o.outerBox.Append(&o.mainBox.Widget)
453530 }
@@ -521,16 +598,37 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
521598
522599 case uint(gdk.KEY_Up):
523600 o.selectPrevious()
601+ o.updateGhostFromSelection()
524602 return true
525603
526604 case uint(gdk.KEY_Down):
527605 o.selectNext()
606+ o.updateGhostFromSelection()
528607 return true
529608
530609 case uint(gdk.KEY_Tab):
610+ // Tab accepts ghost text if present, otherwise toggles view mode
611+ o.mu.RLock()
612+ hasGhost := o.hasGhostText
613+ o.mu.RUnlock()
614+ if hasGhost && o.cursorAtEnd() {
615+ o.acceptGhostCompletion()
616+ return true
617+ }
531618 o.toggleViewMode()
532619 return true
533620
621+ case uint(gdk.KEY_Right):
622+ // Arrow Right accepts ghost text if present and cursor at end
623+ o.mu.RLock()
624+ hasGhost := o.hasGhostText
625+ o.mu.RUnlock()
626+ if hasGhost && o.cursorAtEnd() {
627+ o.acceptGhostCompletion()
628+ return true
629+ }
630+ return false // Let entry handle normal cursor movement
631+
534632 case uint(gdk.KEY_space):
535633 // Space toggles favorite only if user has navigated with arrow keys
536634 // Otherwise, let space pass through to entry for typing
@@ -599,11 +697,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
599697
600698 // onEntryChanged handles text input changes with debouncing.
601699 func (o *Omnibox) onEntryChanged() {
700+ // Skip if we're programmatically accepting ghost text
701+ o.mu.RLock()
702+ isAccepting := o.isAcceptingGhost
703+ o.mu.RUnlock()
704+ if isAccepting {
705+ return
706+ }
707+
602708 // Reset navigation state when user types - space should type, not toggle favorite
603709 o.mu.Lock()
604710 o.hasNavigated = false
711+ // Update realInput with what user actually typed
712+ o.realInput = o.entry.GetText()
605713 o.mu.Unlock()
606714
715+ // Clear ghost text when user types (it will be recalculated after search)
716+ o.clearGhostText()
717+
607718 o.debounceMu.Lock()
608719 if o.debounceTimer != nil {
609720 o.debounceTimer.Stop()
@@ -614,6 +725,248 @@ func (o *Omnibox) onEntryChanged() {
614725 o.debounceMu.Unlock()
615726 }
616727
728+// setGhostText displays a ghost completion suffix in the overlay label.
729+// The ghost text shows what would complete the current input.
730+// originalInput is the input that was used for the autocomplete query.
731+// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
732+func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
733+ if o.ghostLabel == nil || suffix == "" {
734+ o.clearGhostText()
735+ return
736+ }
737+
738+ o.mu.Lock()
739+ currentInput := o.realInput
740+ // Check if input has changed since the autocomplete query was made
741+ // Skip this check when originalInput is empty (row selection replaces full input)
742+ if originalInput != "" && currentInput != originalInput {
743+ o.mu.Unlock()
744+ // Input changed, skip this stale result - a newer query will handle it
745+ return
746+ }
747+ o.ghostSuffix = suffix
748+ o.ghostFullText = fullText
749+ o.hasGhostText = true
750+ o.mu.Unlock()
751+
752+ var cb glib.SourceFunc = func(uintptr) bool {
753+ if o.ghostLabel == nil || o.entry == nil {
754+ return false
755+ }
756+
757+ // Get the entry's Pango context and create a layout to measure text width
758+ pangoCtx := o.entry.GetPangoContext()
759+ if pangoCtx == nil {
760+ return false
761+ }
762+
763+ // Create layout to measure the input text width
764+ layout := pango.NewLayout(pangoCtx)
765+ layout.SetText(originalInput, len(originalInput))
766+
767+ // Get the width in pixels
768+ var widthPx, heightPx int
769+ layout.GetPixelSize(&widthPx, &heightPx)
770+
771+ // Account for the search icon inside the entry
772+ // Ghost label already has same margin/padding as entry via CSS
773+ // Scale the icon offset by UI scale factor (base ~18px at scale 1.0)
774+ iconOffset := int(18.0 * o.uiScale)
775+ marginStart := widthPx + iconOffset
776+
777+ // Set the ghost label text (just the suffix) and position it
778+ o.ghostLabel.SetText(suffix)
779+ o.ghostLabel.SetMarginStart(marginStart)
780+ o.ghostLabel.SetVisible(true)
781+
782+ // Hide placeholder text so it doesn't show through ghost text
783+ emptyPlaceholder := ""
784+ o.entry.SetPlaceholderText(&emptyPlaceholder)
785+
786+ return false
787+ }
788+ glib.IdleAdd(&cb, 0)
789+}
790+
791+// clearGhostText hides the ghost completion text.
792+func (o *Omnibox) clearGhostText() {
793+ o.mu.Lock()
794+ o.ghostSuffix = ""
795+ o.ghostFullText = ""
796+ o.hasGhostText = false
797+ o.mu.Unlock()
798+
799+ if o.ghostLabel == nil {
800+ return
801+ }
802+
803+ var cb glib.SourceFunc = func(uintptr) bool {
804+ if o.ghostLabel != nil {
805+ o.ghostLabel.SetVisible(false)
806+ o.ghostLabel.SetText("")
807+ }
808+ // Restore placeholder text
809+ if o.entry != nil {
810+ placeholder := "Search history or enter URL… (! lists bangs)"
811+ o.entry.SetPlaceholderText(&placeholder)
812+ }
813+ return false
814+ }
815+ glib.IdleAdd(&cb, 0)
816+}
817+
818+// acceptGhostCompletion accepts the ghost text and fills the input.
819+// Returns true if ghost text was accepted.
820+func (o *Omnibox) acceptGhostCompletion() bool {
821+ o.mu.Lock()
822+ if !o.hasGhostText || o.ghostFullText == "" {
823+ o.mu.Unlock()
824+ return false
825+ }
826+ fullText := o.ghostFullText
827+ o.isAcceptingGhost = true
828+ o.realInput = fullText
829+ o.ghostSuffix = ""
830+ o.ghostFullText = ""
831+ o.hasGhostText = false
832+ o.mu.Unlock()
833+
834+ // Update entry text and clear ghost label
835+ o.entry.SetText(fullText)
836+ o.entry.SetPosition(-1) // Move cursor to end
837+
838+ if o.ghostLabel != nil {
839+ o.ghostLabel.SetVisible(false)
840+ }
841+
842+ o.mu.Lock()
843+ o.isAcceptingGhost = false
844+ o.mu.Unlock()
845+
846+ return true
847+}
848+
849+// cursorAtEnd returns true if the entry cursor is at the end of the text.
850+func (o *Omnibox) cursorAtEnd() bool {
851+ text := o.entry.GetText()
852+ // If entry is empty, cursor is trivially "at end" - allow ghost acceptance
853+ // This handles the case where user navigates with arrows from empty input
854+ if len(text) == 0 {
855+ return true
856+ }
857+ // SearchEntry doesn't have GetPosition directly, but we can use the editable interface
858+ // For simplicity, we'll assume cursor is at end after any input
859+ // This works because Tab/ArrowRight are handled in capture phase before entry processes them
860+ return true
861+}
862+
863+// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
864+func (o *Omnibox) updateGhostFromSuggestion() {
865+ if o.autocompleteUC == nil {
866+ return
867+ }
868+
869+ o.mu.RLock()
870+ input := o.realInput
871+ bangMode := o.bangMode
872+ o.mu.RUnlock()
873+
874+ // Don't show ghost text in bang mode (it has its own completion)
875+ if bangMode || input == "" {
876+ o.clearGhostText()
877+ return
878+ }
879+
880+ go func() {
881+ output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: input})
882+ if !output.Found || output.Suggestion == nil {
883+ o.clearGhostText()
884+ return
885+ }
886+ // Pass the original input so setGhostText can verify it's still current
887+ o.setGhostText(input, output.Suggestion.Suffix, output.Suggestion.FullText)
888+ }()
889+}
890+
891+// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
892+// When a row is selected via arrow keys, show the URL as ghost text even if
893+// the user's input isn't a prefix - this shows what Tab would fill in.
894+func (o *Omnibox) updateGhostFromURL(targetURL string) {
895+ if targetURL == "" {
896+ o.clearGhostText()
897+ return
898+ }
899+
900+ o.mu.RLock()
901+ input := o.realInput
902+ o.mu.RUnlock()
903+
904+ // Strip protocol from URL for cleaner display
905+ displayURL := targetURL
906+ if strings.HasPrefix(displayURL, "https://") {
907+ displayURL = displayURL[8:]
908+ } else if strings.HasPrefix(displayURL, "http://") {
909+ displayURL = displayURL[7:]
910+ }
911+
912+ // Try to compute proper suffix if input is a prefix of the URL
913+ if o.autocompleteUC != nil && input != "" {
914+ output := o.autocompleteUC.GetSuggestionForURL(o.ctx, input, targetURL)
915+ if output.Found && output.Suggestion != nil {
916+ o.setGhostText(input, output.Suggestion.Suffix, output.Suggestion.FullText)
917+ return
918+ }
919+ }
920+
921+ // If no prefix match, show the full URL as ghost text (replacing user's input)
922+ // Use empty string as original input so it shows from the start
923+ o.setGhostText("", displayURL, displayURL)
924+}
925+
926+// updateGhostFromSelection updates ghost text based on the currently selected row.
927+func (o *Omnibox) updateGhostFromSelection() {
928+ o.mu.RLock()
929+ idx := o.selectedIndex
930+ mode := o.viewMode
931+ bangMode := o.bangMode
932+ suggestions := o.suggestions
933+ favorites := o.favorites
934+ o.mu.RUnlock()
935+
936+ // No ghost text in bang mode
937+ if bangMode {
938+ return
939+ }
940+
941+ var targetURL string
942+ if mode == ViewModeHistory {
943+ if idx >= 0 && idx < len(suggestions) {
944+ targetURL = suggestions[idx].URL
945+ }
946+ } else {
947+ if idx >= 0 && idx < len(favorites) {
948+ targetURL = favorites[idx].URL
949+ }
950+ }
951+
952+ if targetURL == "" {
953+ o.clearGhostText()
954+ return
955+ }
956+
957+ o.updateGhostFromURL(targetURL)
958+}
959+
960+// escapeMarkup escapes special XML/Pango markup characters.
961+func escapeMarkup(s string) string {
962+ s = strings.ReplaceAll(s, "&", "&amp;")
963+ s = strings.ReplaceAll(s, "<", "&lt;")
964+ s = strings.ReplaceAll(s, ">", "&gt;")
965+ s = strings.ReplaceAll(s, "'", "&apos;")
966+ s = strings.ReplaceAll(s, "\"", "&quot;")
967+ return s
968+}
969+
617970 // performSearch executes the search based on current view mode and query.
618971 func (o *Omnibox) performSearch() {
619972 o.mu.RLock()
@@ -927,9 +1280,13 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
9271280 }
9281281 o.resizeAndCenter(rowCount)
9291282
930- // Select first item if available
1283+ // Select first item if available and update ghost text
9311284 if rowCount > 0 {
9321285 o.selectIndex(0)
1286+ // Update ghost text from first suggestion
1287+ o.updateGhostFromSuggestion()
1288+ } else {
1289+ o.clearGhostText()
9331290 }
9341291 }
9351292
@@ -951,9 +1308,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
9511308 }
9521309 o.resizeAndCenter(rowCount)
9531310
954- // Select first item if available
1311+ // Select first item if available and update ghost text
9551312 if rowCount > 0 {
9561313 o.selectIndex(0)
1314+ // Update ghost text from first favorite
1315+ o.updateGhostFromSuggestion()
1316+ } else {
1317+ o.clearGhostText()
9571318 }
9581319 }
9591320
@@ -1536,8 +1897,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
15361897 return
15371898 }
15381899 o.visible = true
1900+ // Initialize ghost text state
1901+ o.realInput = query
1902+ o.ghostSuffix = ""
1903+ o.ghostFullText = ""
1904+ o.hasGhostText = false
1905+ o.isAcceptingGhost = false
15391906 o.mu.Unlock()
15401907
1908+ // Clear any stale ghost text display
1909+ if o.ghostLabel != nil {
1910+ o.ghostLabel.SetVisible(false)
1911+ }
1912+
15411913 // Set initial query
15421914 o.entry.SetText(query)
15431915
@@ -1598,9 +1970,15 @@ func (o *Omnibox) Hide(ctx context.Context) {
15981970
15991971 // Clear state
16001972 o.clearBangState()
1973+ o.clearGhostText()
16011974 o.entry.SetText("")
16021975 o.listBox.RemoveAll()
16031976
1977+ // Reset realInput
1978+ o.mu.Lock()
1979+ o.realInput = ""
1980+ o.mu.Unlock()
1981+
16041982 if o.onClose != nil {
16051983 o.onClose()
16061984 }
@@ -17,10 +17,11 @@ 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
25 )26 )
26 27
@@ -67,6 +68,8 @@ type Omnibox struct {
67 zoomLabel *gtk.Label68 zoomLabel *gtk.Label
68 bangBadge *gtk.Label69 bangBadge *gtk.Label
69 entry *gtk.SearchEntry70 entry *gtk.SearchEntry
71+ entryOverlay *gtk.Overlay // Overlay for ghost text
72+ ghostLabel *gtk.Label // Ghost text completion label
70 scrolledWin *gtk.ScrolledWindow73 scrolledWin *gtk.ScrolledWindow
71 listBox *gtk.ListBox74 listBox *gtk.ListBox
72 75
@@ -85,12 +88,20 @@ type Omnibox struct {
85 detectedBang string88 detectedBang string
86 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)89 hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
87 90
91+ // Ghost text state
92+ realInput string // What user actually typed (without ghost suffix)
93+ ghostSuffix string // Completion suffix to display
94+ ghostFullText string // Full text that ghost completes to
95+ hasGhostText bool // Quick check for Tab behavior
96+ isAcceptingGhost bool // Prevent re-trigger during acceptance
97+
88 // Dependencies98 // Dependencies
89 historyUC *usecase.SearchHistoryUseCase99 historyUC *usecase.SearchHistoryUseCase
90 favoritesUC *usecase.ManageFavoritesUseCase100 favoritesUC *usecase.ManageFavoritesUseCase
91 faviconAdapter *adapter.FaviconAdapter101 faviconAdapter *adapter.FaviconAdapter
92 copyURLUC *usecase.CopyURLUseCase102 copyURLUC *usecase.CopyURLUseCase
93 shortcutsUC *usecase.SearchShortcutsUseCase103 shortcutsUC *usecase.SearchShortcutsUseCase
104+ autocompleteUC *usecase.AutocompleteUseCase
94 defaultSearch string105 defaultSearch string
95 initialBehavior string106 initialBehavior string
96 ctx context.Context107 ctx context.Context
@@ -127,6 +138,7 @@ type OmniboxConfig struct {
127 FaviconAdapter *adapter.FaviconAdapter138 FaviconAdapter *adapter.FaviconAdapter
128 CopyURLUC *usecase.CopyURLUseCase139 CopyURLUC *usecase.CopyURLUseCase
129 ShortcutsUC *usecase.SearchShortcutsUseCase140 ShortcutsUC *usecase.SearchShortcutsUseCase
141+ AutocompleteUC *usecase.AutocompleteUseCase
130 DefaultSearch string142 DefaultSearch string
131 InitialBehavior string143 InitialBehavior string
132 UIScale float64 // UI scale for favicon sizing144 UIScale float64 // UI scale for favicon sizing
@@ -154,6 +166,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
154 faviconAdapter: cfg.FaviconAdapter,166 faviconAdapter: cfg.FaviconAdapter,
155 copyURLUC: cfg.CopyURLUC,167 copyURLUC: cfg.CopyURLUC,
156 shortcutsUC: cfg.ShortcutsUC,168 shortcutsUC: cfg.ShortcutsUC,
169+ autocompleteUC: cfg.AutocompleteUC,
157 defaultSearch: cfg.DefaultSearch,170 defaultSearch: cfg.DefaultSearch,
158 initialBehavior: cfg.InitialBehavior,171 initialBehavior: cfg.InitialBehavior,
159 onToast: cfg.OnToast,172 onToast: cfg.OnToast,
@@ -389,6 +402,28 @@ func (o *Omnibox) initEntry() error {
389 402
390 placeholder := "Search history or enter URL… (! lists bangs)"403 placeholder := "Search history or enter URL… (! lists bangs)"
391 o.entry.SetPlaceholderText(&placeholder)404 o.entry.SetPlaceholderText(&placeholder)
405+
406+ // Create overlay for ghost text
407+ o.entryOverlay = gtk.NewOverlay()
408+ if o.entryOverlay == nil {
409+ return errNilWidget("entryOverlay")
410+ }
411+ o.entryOverlay.SetChild(&o.entry.Widget)
412+ o.entryOverlay.SetHexpand(true)
413+
414+ // Create ghost label for autocomplete suffix
415+ o.ghostLabel = gtk.NewLabel(nil)
416+ if o.ghostLabel == nil {
417+ return errNilWidget("ghostLabel")
418+ }
419+ o.ghostLabel.AddCssClass("omnibox-ghost")
420+ o.ghostLabel.SetHalign(gtk.AlignStartValue)
421+ o.ghostLabel.SetValign(gtk.AlignCenterValue)
422+ o.ghostLabel.SetVisible(false)
423+ o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
424+
425+ o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
426+
392 return nil427 return nil
393 }428 }
394 429
@@ -425,6 +460,48 @@ func (o *Omnibox) initList() error {
425 }460 }
426 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)461 o.retainedCallbacks = append(o.retainedCallbacks, rowSelectedCb)
427 o.listBox.ConnectRowSelected(&rowSelectedCb)462 o.listBox.ConnectRowSelected(&rowSelectedCb)
463+
464+ // Handle row activation (click or Enter) - navigate directly to the URL
465+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
466+ if rowPtr == 0 {
467+ return
468+ }
469+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
470+ if row == nil {
471+ return
472+ }
473+ idx := row.GetIndex()
474+ o.mu.RLock()
475+ mode := o.viewMode
476+ bangMode := o.bangMode
477+ suggestions := o.suggestions
478+ favorites := o.favorites
479+ o.mu.RUnlock()
480+
481+ // Don't navigate in bang mode - activating a bang should fill it in
482+ if bangMode {
483+ return
484+ }
485+
486+ var targetURL string
487+ if mode == ViewModeHistory {
488+ if idx >= 0 && idx < len(suggestions) {
489+ targetURL = suggestions[idx].URL
490+ }
491+ } else {
492+ if idx >= 0 && idx < len(favorites) {
493+ targetURL = favorites[idx].URL
494+ }
495+ }
496+
497+ if targetURL != "" && o.onNavigate != nil {
498+ o.Hide(o.ctx)
499+ o.onNavigate(targetURL)
500+ }
501+ }
502+ o.retainedCallbacks = append(o.retainedCallbacks, rowActivatedCb)
503+ o.listBox.ConnectRowActivated(&rowActivatedCb)
504+
428 return nil505 return nil
429 }506 }
430 507
@@ -447,7 +524,7 @@ func (o *Omnibox) assembleWidgets() {
447 524
448 o.scrolledWin.SetChild(&o.listBox.Widget)525 o.scrolledWin.SetChild(&o.listBox.Widget)
449 o.mainBox.Append(&o.headerBox.Widget)526 o.mainBox.Append(&o.headerBox.Widget)
450- o.mainBox.Append(&o.entry.Widget)527+ o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support
451 o.mainBox.Append(&o.scrolledWin.Widget)528 o.mainBox.Append(&o.scrolledWin.Widget)
452 o.outerBox.Append(&o.mainBox.Widget)529 o.outerBox.Append(&o.mainBox.Widget)
453 }530 }
@@ -521,16 +598,37 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
521 598
522 case uint(gdk.KEY_Up):599 case uint(gdk.KEY_Up):
523 o.selectPrevious()600 o.selectPrevious()
601+ o.updateGhostFromSelection()
524 return true602 return true
525 603
526 case uint(gdk.KEY_Down):604 case uint(gdk.KEY_Down):
527 o.selectNext()605 o.selectNext()
606+ o.updateGhostFromSelection()
528 return true607 return true
529 608
530 case uint(gdk.KEY_Tab):609 case uint(gdk.KEY_Tab):
610+ // Tab accepts ghost text if present, otherwise toggles view mode
611+ o.mu.RLock()
612+ hasGhost := o.hasGhostText
613+ o.mu.RUnlock()
614+ if hasGhost && o.cursorAtEnd() {
615+ o.acceptGhostCompletion()
616+ return true
617+ }
531 o.toggleViewMode()618 o.toggleViewMode()
532 return true619 return true
533 620
621+ case uint(gdk.KEY_Right):
622+ // Arrow Right accepts ghost text if present and cursor at end
623+ o.mu.RLock()
624+ hasGhost := o.hasGhostText
625+ o.mu.RUnlock()
626+ if hasGhost && o.cursorAtEnd() {
627+ o.acceptGhostCompletion()
628+ return true
629+ }
630+ return false // Let entry handle normal cursor movement
631+
534 case uint(gdk.KEY_space):632 case uint(gdk.KEY_space):
535 // Space toggles favorite only if user has navigated with arrow keys633 // Space toggles favorite only if user has navigated with arrow keys
536 // Otherwise, let space pass through to entry for typing634 // Otherwise, let space pass through to entry for typing
@@ -599,11 +697,24 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
599 697
600 // onEntryChanged handles text input changes with debouncing.698 // onEntryChanged handles text input changes with debouncing.
601 func (o *Omnibox) onEntryChanged() {699 func (o *Omnibox) onEntryChanged() {
700+ // Skip if we're programmatically accepting ghost text
701+ o.mu.RLock()
702+ isAccepting := o.isAcceptingGhost
703+ o.mu.RUnlock()
704+ if isAccepting {
705+ return
706+ }
707+
602 // Reset navigation state when user types - space should type, not toggle favorite708 // Reset navigation state when user types - space should type, not toggle favorite
603 o.mu.Lock()709 o.mu.Lock()
604 o.hasNavigated = false710 o.hasNavigated = false
711+ // Update realInput with what user actually typed
712+ o.realInput = o.entry.GetText()
605 o.mu.Unlock()713 o.mu.Unlock()
606 714
715+ // Clear ghost text when user types (it will be recalculated after search)
716+ o.clearGhostText()
717+
607 o.debounceMu.Lock()718 o.debounceMu.Lock()
608 if o.debounceTimer != nil {719 if o.debounceTimer != nil {
609 o.debounceTimer.Stop()720 o.debounceTimer.Stop()
@@ -614,6 +725,248 @@ func (o *Omnibox) onEntryChanged() {
614 o.debounceMu.Unlock()725 o.debounceMu.Unlock()
615 }726 }
616 727
728+// setGhostText displays a ghost completion suffix in the overlay label.
729+// The ghost text shows what would complete the current input.
730+// originalInput is the input that was used for the autocomplete query.
731+// If originalInput is empty, the ghost text replaces the entire input (used for row selection).
732+func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
733+ if o.ghostLabel == nil || suffix == "" {
734+ o.clearGhostText()
735+ return
736+ }
737+
738+ o.mu.Lock()
739+ currentInput := o.realInput
740+ // Check if input has changed since the autocomplete query was made
741+ // Skip this check when originalInput is empty (row selection replaces full input)
742+ if originalInput != "" && currentInput != originalInput {
743+ o.mu.Unlock()
744+ // Input changed, skip this stale result - a newer query will handle it
745+ return
746+ }
747+ o.ghostSuffix = suffix
748+ o.ghostFullText = fullText
749+ o.hasGhostText = true
750+ o.mu.Unlock()
751+
752+ var cb glib.SourceFunc = func(uintptr) bool {
753+ if o.ghostLabel == nil || o.entry == nil {
754+ return false
755+ }
756+
757+ // Get the entry's Pango context and create a layout to measure text width
758+ pangoCtx := o.entry.GetPangoContext()
759+ if pangoCtx == nil {
760+ return false
761+ }
762+
763+ // Create layout to measure the input text width
764+ layout := pango.NewLayout(pangoCtx)
765+ layout.SetText(originalInput, len(originalInput))
766+
767+ // Get the width in pixels
768+ var widthPx, heightPx int
769+ layout.GetPixelSize(&widthPx, &heightPx)
770+
771+ // Account for the search icon inside the entry
772+ // Ghost label already has same margin/padding as entry via CSS
773+ // Scale the icon offset by UI scale factor (base ~18px at scale 1.0)
774+ iconOffset := int(18.0 * o.uiScale)
775+ marginStart := widthPx + iconOffset
776+
777+ // Set the ghost label text (just the suffix) and position it
778+ o.ghostLabel.SetText(suffix)
779+ o.ghostLabel.SetMarginStart(marginStart)
780+ o.ghostLabel.SetVisible(true)
781+
782+ // Hide placeholder text so it doesn't show through ghost text
783+ emptyPlaceholder := ""
784+ o.entry.SetPlaceholderText(&emptyPlaceholder)
785+
786+ return false
787+ }
788+ glib.IdleAdd(&cb, 0)
789+}
790+
791+// clearGhostText hides the ghost completion text.
792+func (o *Omnibox) clearGhostText() {
793+ o.mu.Lock()
794+ o.ghostSuffix = ""
795+ o.ghostFullText = ""
796+ o.hasGhostText = false
797+ o.mu.Unlock()
798+
799+ if o.ghostLabel == nil {
800+ return
801+ }
802+
803+ var cb glib.SourceFunc = func(uintptr) bool {
804+ if o.ghostLabel != nil {
805+ o.ghostLabel.SetVisible(false)
806+ o.ghostLabel.SetText("")
807+ }
808+ // Restore placeholder text
809+ if o.entry != nil {
810+ placeholder := "Search history or enter URL… (! lists bangs)"
811+ o.entry.SetPlaceholderText(&placeholder)
812+ }
813+ return false
814+ }
815+ glib.IdleAdd(&cb, 0)
816+}
817+
818+// acceptGhostCompletion accepts the ghost text and fills the input.
819+// Returns true if ghost text was accepted.
820+func (o *Omnibox) acceptGhostCompletion() bool {
821+ o.mu.Lock()
822+ if !o.hasGhostText || o.ghostFullText == "" {
823+ o.mu.Unlock()
824+ return false
825+ }
826+ fullText := o.ghostFullText
827+ o.isAcceptingGhost = true
828+ o.realInput = fullText
829+ o.ghostSuffix = ""
830+ o.ghostFullText = ""
831+ o.hasGhostText = false
832+ o.mu.Unlock()
833+
834+ // Update entry text and clear ghost label
835+ o.entry.SetText(fullText)
836+ o.entry.SetPosition(-1) // Move cursor to end
837+
838+ if o.ghostLabel != nil {
839+ o.ghostLabel.SetVisible(false)
840+ }
841+
842+ o.mu.Lock()
843+ o.isAcceptingGhost = false
844+ o.mu.Unlock()
845+
846+ return true
847+}
848+
849+// cursorAtEnd returns true if the entry cursor is at the end of the text.
850+func (o *Omnibox) cursorAtEnd() bool {
851+ text := o.entry.GetText()
852+ // If entry is empty, cursor is trivially "at end" - allow ghost acceptance
853+ // This handles the case where user navigates with arrows from empty input
854+ if len(text) == 0 {
855+ return true
856+ }
857+ // SearchEntry doesn't have GetPosition directly, but we can use the editable interface
858+ // For simplicity, we'll assume cursor is at end after any input
859+ // This works because Tab/ArrowRight are handled in capture phase before entry processes them
860+ return true
861+}
862+
863+// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
864+func (o *Omnibox) updateGhostFromSuggestion() {
865+ if o.autocompleteUC == nil {
866+ return
867+ }
868+
869+ o.mu.RLock()
870+ input := o.realInput
871+ bangMode := o.bangMode
872+ o.mu.RUnlock()
873+
874+ // Don't show ghost text in bang mode (it has its own completion)
875+ if bangMode || input == "" {
876+ o.clearGhostText()
877+ return
878+ }
879+
880+ go func() {
881+ output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: input})
882+ if !output.Found || output.Suggestion == nil {
883+ o.clearGhostText()
884+ return
885+ }
886+ // Pass the original input so setGhostText can verify it's still current
887+ o.setGhostText(input, output.Suggestion.Suffix, output.Suggestion.FullText)
888+ }()
889+}
890+
891+// updateGhostFromURL updates ghost text based on a specific URL (from row selection).
892+// When a row is selected via arrow keys, show the URL as ghost text even if
893+// the user's input isn't a prefix - this shows what Tab would fill in.
894+func (o *Omnibox) updateGhostFromURL(targetURL string) {
895+ if targetURL == "" {
896+ o.clearGhostText()
897+ return
898+ }
899+
900+ o.mu.RLock()
901+ input := o.realInput
902+ o.mu.RUnlock()
903+
904+ // Strip protocol from URL for cleaner display
905+ displayURL := targetURL
906+ if strings.HasPrefix(displayURL, "https://") {
907+ displayURL = displayURL[8:]
908+ } else if strings.HasPrefix(displayURL, "http://") {
909+ displayURL = displayURL[7:]
910+ }
911+
912+ // Try to compute proper suffix if input is a prefix of the URL
913+ if o.autocompleteUC != nil && input != "" {
914+ output := o.autocompleteUC.GetSuggestionForURL(o.ctx, input, targetURL)
915+ if output.Found && output.Suggestion != nil {
916+ o.setGhostText(input, output.Suggestion.Suffix, output.Suggestion.FullText)
917+ return
918+ }
919+ }
920+
921+ // If no prefix match, show the full URL as ghost text (replacing user's input)
922+ // Use empty string as original input so it shows from the start
923+ o.setGhostText("", displayURL, displayURL)
924+}
925+
926+// updateGhostFromSelection updates ghost text based on the currently selected row.
927+func (o *Omnibox) updateGhostFromSelection() {
928+ o.mu.RLock()
929+ idx := o.selectedIndex
930+ mode := o.viewMode
931+ bangMode := o.bangMode
932+ suggestions := o.suggestions
933+ favorites := o.favorites
934+ o.mu.RUnlock()
935+
936+ // No ghost text in bang mode
937+ if bangMode {
938+ return
939+ }
940+
941+ var targetURL string
942+ if mode == ViewModeHistory {
943+ if idx >= 0 && idx < len(suggestions) {
944+ targetURL = suggestions[idx].URL
945+ }
946+ } else {
947+ if idx >= 0 && idx < len(favorites) {
948+ targetURL = favorites[idx].URL
949+ }
950+ }
951+
952+ if targetURL == "" {
953+ o.clearGhostText()
954+ return
955+ }
956+
957+ o.updateGhostFromURL(targetURL)
958+}
959+
960+// escapeMarkup escapes special XML/Pango markup characters.
961+func escapeMarkup(s string) string {
962+ s = strings.ReplaceAll(s, "&", "&amp;")
963+ s = strings.ReplaceAll(s, "<", "&lt;")
964+ s = strings.ReplaceAll(s, ">", "&gt;")
965+ s = strings.ReplaceAll(s, "'", "&apos;")
966+ s = strings.ReplaceAll(s, "\"", "&quot;")
967+ return s
968+}
969+
617 // performSearch executes the search based on current view mode and query.970 // performSearch executes the search based on current view mode and query.
618 func (o *Omnibox) performSearch() {971 func (o *Omnibox) performSearch() {
619 o.mu.RLock()972 o.mu.RLock()
@@ -927,9 +1280,13 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
927 }1280 }
928 o.resizeAndCenter(rowCount)1281 o.resizeAndCenter(rowCount)
929 1282
930- // Select first item if available1283+ // Select first item if available and update ghost text
931 if rowCount > 0 {1284 if rowCount > 0 {
932 o.selectIndex(0)1285 o.selectIndex(0)
1286+ // Update ghost text from first suggestion
1287+ o.updateGhostFromSuggestion()
1288+ } else {
1289+ o.clearGhostText()
933 }1290 }
934 }1291 }
935 1292
@@ -951,9 +1308,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite) {
951 }1308 }
952 o.resizeAndCenter(rowCount)1309 o.resizeAndCenter(rowCount)
953 1310
954- // Select first item if available1311+ // Select first item if available and update ghost text
955 if rowCount > 0 {1312 if rowCount > 0 {
956 o.selectIndex(0)1313 o.selectIndex(0)
1314+ // Update ghost text from first favorite
1315+ o.updateGhostFromSuggestion()
1316+ } else {
1317+ o.clearGhostText()
957 }1318 }
958 }1319 }
959 1320
@@ -1536,8 +1897,19 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
1536 return1897 return
1537 }1898 }
1538 o.visible = true1899 o.visible = true
1900+ // Initialize ghost text state
1901+ o.realInput = query
1902+ o.ghostSuffix = ""
1903+ o.ghostFullText = ""
1904+ o.hasGhostText = false
1905+ o.isAcceptingGhost = false
1539 o.mu.Unlock()1906 o.mu.Unlock()
1540 1907
1908+ // Clear any stale ghost text display
1909+ if o.ghostLabel != nil {
1910+ o.ghostLabel.SetVisible(false)
1911+ }
1912+
1541 // Set initial query1913 // Set initial query
1542 o.entry.SetText(query)1914 o.entry.SetText(query)
1543 1915
@@ -1598,9 +1970,15 @@ func (o *Omnibox) Hide(ctx context.Context) {
1598 1970
1599 // Clear state1971 // Clear state
1600 o.clearBangState()1972 o.clearBangState()
1973+ o.clearGhostText()
1601 o.entry.SetText("")1974 o.entry.SetText("")
1602 o.listBox.RemoveAll()1975 o.listBox.RemoveAll()
1603 1976
1977+ // Reset realInput
1978+ o.mu.Lock()
1979+ o.realInput = ""
1980+ o.mu.Unlock()
1981+
1604 if o.onClose != nil {1982 if o.onClose != nil {
1605 o.onClose()1983 o.onClose()
1606 }1984 }
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