1package component
2
3import (
4 stdurl "net/url"
5 "strings"
6
7 "github.com/bnema/dumber/internal/domain/autocomplete"
8 "github.com/bnema/dumber/internal/domain/url"
9)
10
11// Omnibox suggestion logic: pure functions over the entry text and the
12// visible result list. The GTK widget in omnibox.go only feeds state in and
13// renders the answer, so this file is the test surface for ghost completion,
14// selection targets, and search-query resolution.
15
16// ghostCompletionRequest is everything ghost completion depends on.
17type ghostCompletionRequest struct {
18 EntryText string
19 SelectedURL string
20 HasExplicitSelection bool
21 Mode ViewMode
22 MaxResults int
23 Suggestions []Suggestion
24 Favorites []Favorite
25}
26
27// ghostCompletion returns the inline completion suffix to show after the
28// entry text, and the full text it completes to. ok is false when nothing
29// should be shown. Host-like input completes to a domain rather than a full
30// redirect URL.
31func ghostCompletion(req ghostCompletionRequest) (fullText, suffix string, ok bool) {
32 _, input, ok := ghostCompletionInput(req.EntryText)
33 if !ok {
34 return "", "", false
35 }
36 fullText, suffix, ok = visibleGhostSuggestion(
37 input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxResults, req.Suggestions, req.Favorites,
38 )
39 if !ok {
40 return "", "", false
41 }
42 fullText, suffix = normalizeGhostSuggestion(input, fullText, suffix)
43 if fullText == "" || suffix == "" {
44 return "", "", false
45 }
46 return fullText, suffix, true
47}
48
49// isGhostEcho reports whether the entry text is the debounced echo of Dumber's
50// own ghost completion: realInput plus the suffix currently displayed. A
51// matching echo must leave the ghost state untouched, but it cannot be told
52// apart from a paste that replaced the selected suffix with the same text, so a
53// deferred Enter still wins and the ghost state is rebuilt from the paste.
54func isGhostEcho(entryText, realInput, ghostSuffix string) bool {
55 return ghostSuffix != "" && entryText == realInput+ghostSuffix
56}
57
58// reconcileEntryState decides how to resync the entry buffer with the
59// realInput/ghostSuffix shadow state. The buffer is the source of truth:
60// search-changed is debounced, so realInput lags behind fresh keystrokes.
61// The buffer is only ever rewritten to strip a still-intact ghost suffix —
62// never to restore older text, which would delete characters the user just
63// typed.
64func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) {
65 if ghostSuffix != "" && buffer == realInput+ghostSuffix {
66 return realInput, true
67 }
68 return buffer, false
69}
70
71func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
72 trimmed := strings.TrimLeft(entryText, " \t")
73 leadingWhitespace = entryText[:len(entryText)-len(trimmed)]
74 if trimmed == "" {
75 return leadingWhitespace, "", false
76 }
77 return leadingWhitespace, trimmed, true
78}
79
80// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
81// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
82func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
83 if queryText == "" || fullText == "" {
84 return "", ""
85 }
86
87 // If input already contains path/query-ish delimiters, keep original completion behavior.
88 if strings.ContainsAny(queryText, "/?#=& ") {
89 return fullText, fallbackSuffix
90 }
91
92 hostOnly := extractHostForCompletion(fullText)
93 if hostOnly != "" {
94 for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
95 if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
96 return candidate, completionSuffix
97 }
98 }
99 }
100
101 return fullText, fallbackSuffix
102}
103
104func extractHostForCompletion(raw string) string {
105 if raw == "" {
106 return ""
107 }
108 if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
109 return strings.ToLower(parsed.Hostname())
110 }
111 trimmed := autocomplete.StripProtocol(raw)
112 trimmed = strings.ToLower(trimmed)
113 for _, sep := range []string{"/", "?", "#"} {
114 if idx := strings.Index(trimmed, sep); idx >= 0 {
115 trimmed = trimmed[:idx]
116 }
117 }
118 return strings.TrimSpace(trimmed)
119}
120
121func visibleGhostSuggestion(
122 query, selectedURL string,
123 hasExplicitSelection bool,
124 mode ViewMode,
125 maxResults int,
126 suggestions []Suggestion,
127 favorites []Favorite,
128) (fullText, suffix string, ok bool) {
129 if hasExplicitSelection {
130 suffix, fullText, ok = autocomplete.ComputeURLCompletionSuffix(query, selectedURL)
131 return fullText, suffix, ok
132 }
133
134 listedURLs := listedURLsForMode(mode, maxResults, suggestions, favorites)
135 suffix, fullText, ok = autocomplete.BestURLCompletion(query, listedURLs)
136 return fullText, suffix, ok
137}
138
139func listedURLsForMode(mode ViewMode, maxResults int, suggestions []Suggestion, favorites []Favorite) []string {
140 if mode == ViewModeHistory {
141 count := limitedCount(len(suggestions), maxResults)
142 urls := make([]string, 0, count)
143 for _, s := range suggestions[:count] {
144 if s.URL != "" {
145 urls = append(urls, s.URL)
146 }
147 }
148 return urls
149 }
150
151 count := limitedCount(len(favorites), maxResults)
152 urls := make([]string, 0, count)
153 for _, f := range favorites[:count] {
154 if f.URL != "" {
155 urls = append(urls, f.URL)
156 }
157 }
158 return urls
159}
160
161func selectedTargetURL(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
162 if idx < 0 {
163 return "", false
164 }
165 return resolveTargetURLForSelection(mode, idx, maxResults, suggestions, favorites), true
166}
167
168func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
169 if idx < 0 || idx >= len(bangSuggestions) {
170 return "", false
171 }
172 return "!" + bangSuggestions[idx].Key + " ", true
173}
174
175func limitedCount(total, maxResults int) int {
176 if total <= 0 {
177 return 0
178 }
179 if maxResults <= 0 || total < maxResults {
180 return total
181 }
182 return maxResults
183}
184
185// navigableCount returns how many rows of the active list are rendered and
186// reachable by keyboard: the active list length capped at MaxResults.
187func navigableCount(bangMode bool, mode ViewMode, bangs, suggestions, favorites int) int {
188 total := favorites
189 switch {
190 case bangMode:
191 total = bangs
192 case mode == ViewModeHistory:
193 total = suggestions
194 }
195 return limitedCount(total, OmniboxListDefaults.MaxResults)
196}
197
198func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
199 if hasGhost && realInput != "" {
200 return realInput
201 }
202 return entryText
203}
204
205func resolveTargetURLForSelection(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) string {
206 if mode == ViewModeHistory {
207 count := limitedCount(len(suggestions), maxResults)
208 if idx >= 0 && idx < count {
209 return suggestions[idx].URL
210 }
211 return ""
212 }
213 count := limitedCount(len(favorites), maxResults)
214 if idx >= 0 && idx < count {
215 return favorites[idx].URL
216 }
217 return ""
218}
219
220func shouldPreferTypedURLNavigation(entryText string) bool {
221 entryText = strings.TrimSpace(entryText)
222 if entryText == "" {
223 return false
224 }
225 return url.LooksLikeURL(entryText)
226}