fix: improve omnibox URL intent and ghost completion stability
Prioritize typed URL-like input on Enter and normalize bare IP targets to HTTP so local/tailnet services open reliably. Stabilize ghost completion by handling leading whitespace, resetting stale query state between sessions, and suppressing ghost rewrites during deletion to keep backspace behavior predictable.
- commit
- 39e6692e93c1f15758ea05cd376378d926748e8b
- author
- brice · · 2026-02-17 17:37 UTC
- parent
- 5960dd8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +246 -8
M internal/domain/url/normalize.go +22 -0
| @@ -3,6 +3,7 @@ package url | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | + | "net" | |
| 6 | 7 | "net/url" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| @@ -85,6 +86,9 @@ func Normalize(input string) string { | |||
| 85 | 86 | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | |
| 86 | 87 | return "http://" + input | |
| 87 | 88 | } | |
| 89 | + | if looksLikeIPAddressWithOptionalPortOrPath(input) { | |
| 90 | + | return "http://" + input | |
| 91 | + | } | |
| 88 | 92 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | |
| 89 | 93 | return "https://" + input | |
| 90 | 94 | } | |
| @@ -92,6 +96,24 @@ func Normalize(input string) string { | |||
| 92 | 96 | return input | |
| 93 | 97 | } | |
| 94 | 98 | ||
| 99 | + | func looksLikeIPAddressWithOptionalPortOrPath(input string) bool { | |
| 100 | + | if input == "" || strings.Contains(input, " ") { | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | parsed, err := url.Parse("http://" + input) | |
| 105 | + | if err != nil { | |
| 106 | + | return false | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | hostname := parsed.Hostname() | |
| 110 | + | if hostname == "" { | |
| 111 | + | return false | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | return net.ParseIP(hostname) != nil | |
| 115 | + | } | |
| 116 | + | ||
| 95 | 117 | // LooksLikeURL checks if the input appears to be a URL (not a search query). | |
| 96 | 118 | // Returns true for strings like "github.com", "google.com/search", etc. | |
| 97 | 119 | // Also returns true for URLs with explicit schemes like "dumb://". | |
| @@ -3,6 +3,7 @@ package url | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net" | |||
| 6 | "net/url" | 7 | "net/url" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| @@ -85,6 +86,9 @@ func Normalize(input string) string { | |||||
| 85 | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | 86 | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | ||
| 86 | return "http://" + input | 87 | return "http://" + input | ||
| 87 | } | 88 | } | ||
| 89 | + | if looksLikeIPAddressWithOptionalPortOrPath(input) { | |||
| 90 | + | return "http://" + input | |||
| 91 | + | } | |||
| 88 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | 92 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | ||
| 89 | return "https://" + input | 93 | return "https://" + input | ||
| 90 | } | 94 | } | ||
| @@ -92,6 +96,24 @@ func Normalize(input string) string { | |||||
| 92 | return input | 96 | return input | ||
| 93 | } | 97 | } | ||
| 94 | 98 | ||||
| 99 | + | func looksLikeIPAddressWithOptionalPortOrPath(input string) bool { | |||
| 100 | + | if input == "" || strings.Contains(input, " ") { | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | parsed, err := url.Parse("http://" + input) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return false | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | hostname := parsed.Hostname() | |||
| 110 | + | if hostname == "" { | |||
| 111 | + | return false | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | return net.ParseIP(hostname) != nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 95 | // LooksLikeURL checks if the input appears to be a URL (not a search query). | 117 | // LooksLikeURL checks if the input appears to be a URL (not a search query). | ||
| 96 | // Returns true for strings like "github.com", "google.com/search", etc. | 118 | // Returns true for strings like "github.com", "google.com/search", etc. | ||
| 97 | // Also returns true for URLs with explicit schemes like "dumb://". | 119 | // Also returns true for URLs with explicit schemes like "dumb://". | ||
M internal/domain/url/normalize_test.go +26 -0
| @@ -390,6 +390,11 @@ func TestLooksLikeURL(t *testing.T) { | |||
| 390 | 390 | input: "localhostevil.com", | |
| 391 | 391 | want: true, | |
| 392 | 392 | }, | |
| 393 | + | { | |
| 394 | + | name: "ipv4 with port", | |
| 395 | + | input: "100.64.0.10:3000", | |
| 396 | + | want: true, | |
| 397 | + | }, | |
| 393 | 398 | } | |
| 394 | 399 | ||
| 395 | 400 | for _, tt := range tests { | |
| @@ -402,6 +407,27 @@ func TestLooksLikeURL(t *testing.T) { | |||
| 402 | 407 | } | |
| 403 | 408 | } | |
| 404 | 409 | ||
| 410 | + | func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) { | |
| 411 | + | tests := []struct { | |
| 412 | + | name string | |
| 413 | + | input string | |
| 414 | + | want string | |
| 415 | + | }{ | |
| 416 | + | {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"}, | |
| 417 | + | {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"}, | |
| 418 | + | {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"}, | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | for _, tt := range tests { | |
| 422 | + | t.Run(tt.name, func(t *testing.T) { | |
| 423 | + | got := Normalize(tt.input) | |
| 424 | + | if got != tt.want { | |
| 425 | + | t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want) | |
| 426 | + | } | |
| 427 | + | }) | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 405 | 431 | func TestIsExternalScheme(t *testing.T) { | |
| 406 | 432 | tests := []struct { | |
| 407 | 433 | name string | |
| @@ -390,6 +390,11 @@ func TestLooksLikeURL(t *testing.T) { | |||||
| 390 | input: "localhostevil.com", | 390 | input: "localhostevil.com", | ||
| 391 | want: true, | 391 | want: true, | ||
| 392 | }, | 392 | }, | ||
| 393 | + | { | |||
| 394 | + | name: "ipv4 with port", | |||
| 395 | + | input: "100.64.0.10:3000", | |||
| 396 | + | want: true, | |||
| 397 | + | }, | |||
| 393 | } | 398 | } | ||
| 394 | 399 | ||||
| 395 | for _, tt := range tests { | 400 | for _, tt := range tests { | ||
| @@ -402,6 +407,27 @@ func TestLooksLikeURL(t *testing.T) { | |||||
| 402 | } | 407 | } | ||
| 403 | } | 408 | } | ||
| 404 | 409 | ||||
| 410 | + | func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) { | |||
| 411 | + | tests := []struct { | |||
| 412 | + | name string | |||
| 413 | + | input string | |||
| 414 | + | want string | |||
| 415 | + | }{ | |||
| 416 | + | {name: "tailnet ipv4", input: "100.64.0.10", want: "http://100.64.0.10"}, | |||
| 417 | + | {name: "tailnet ipv4 with port", input: "100.64.0.10:8080", want: "http://100.64.0.10:8080"}, | |||
| 418 | + | {name: "ipv4 with path", input: "100.64.0.10/api", want: "http://100.64.0.10/api"}, | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | for _, tt := range tests { | |||
| 422 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 423 | + | got := Normalize(tt.input) | |||
| 424 | + | if got != tt.want { | |||
| 425 | + | t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want) | |||
| 426 | + | } | |||
| 427 | + | }) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 405 | func TestIsExternalScheme(t *testing.T) { | 431 | func TestIsExternalScheme(t *testing.T) { | ||
| 406 | tests := []struct { | 432 | tests := []struct { | ||
| 407 | name string | 433 | name string | ||
M internal/ui/component/omnibox.go +78 -8
| @@ -98,6 +98,7 @@ type Omnibox struct { | |||
| 98 | 98 | ghostFullText string // Full text that ghost completes to | |
| 99 | 99 | hasGhostText bool // Quick check for Tab behavior | |
| 100 | 100 | isAcceptingGhost bool // Prevent re-trigger during acceptance | |
| 101 | + | suppressGhost bool // Temporarily disable ghost completion after deletion keys | |
| 101 | 102 | ||
| 102 | 103 | // Dependencies | |
| 103 | 104 | historyUC *usecase.SearchHistoryUseCase | |
| @@ -681,6 +682,10 @@ func (o *Omnibox) hasUserNavigated() bool { | |||
| 681 | 682 | // handleKeyPress processes keyboard events. | |
| 682 | 683 | // Returns true if the event was handled. | |
| 683 | 684 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 685 | + | o.mu.Lock() | |
| 686 | + | o.suppressGhost = nextGhostSuppressionState(o.suppressGhost, keyval) | |
| 687 | + | o.mu.Unlock() | |
| 688 | + | ||
| 684 | 689 | ctrl := state&gdk.ControlMaskValue != 0 | |
| 685 | 690 | ||
| 686 | 691 | switch keyval { | |
| @@ -754,6 +759,22 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 754 | 759 | return false // Let entry handle the key | |
| 755 | 760 | } | |
| 756 | 761 | ||
| 762 | + | func nextGhostSuppressionState(_ bool, keyval uint) bool { | |
| 763 | + | if isDeletionKey(keyval) { | |
| 764 | + | return true | |
| 765 | + | } | |
| 766 | + | return false | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | func isDeletionKey(keyval uint) bool { | |
| 770 | + | switch keyval { | |
| 771 | + | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | |
| 772 | + | return true | |
| 773 | + | default: | |
| 774 | + | return false | |
| 775 | + | } | |
| 776 | + | } | |
| 777 | + | ||
| 757 | 778 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | |
| 758 | 779 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | |
| 759 | 780 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | |
| @@ -813,6 +834,7 @@ func (o *Omnibox) onEntryChanged() { | |||
| 813 | 834 | ||
| 814 | 835 | // Reset navigation state when user types - space should type, not toggle favorite | |
| 815 | 836 | o.mu.Lock() | |
| 837 | + | previousInput := o.realInput | |
| 816 | 838 | o.hasNavigated = false | |
| 817 | 839 | // Update realInput with what user actually typed | |
| 818 | 840 | o.realInput = entryText | |
| @@ -820,6 +842,11 @@ func (o *Omnibox) onEntryChanged() { | |||
| 820 | 842 | ||
| 821 | 843 | // Clear ghost text when user types (it will be recalculated after search) | |
| 822 | 844 | o.clearGhostText() | |
| 845 | + | // Recompute immediately from currently visible rows to avoid transient gaps | |
| 846 | + | // where suggestions are visible but ghost text is missing until async search returns. | |
| 847 | + | if shouldUpdateGhostImmediately(previousInput, entryText) { | |
| 848 | + | o.updateGhostFromSelectionWithInput(entryText) | |
| 849 | + | } | |
| 823 | 850 | ||
| 824 | 851 | o.debounceMu.Lock() | |
| 825 | 852 | if o.debounceTimer != nil { | |
| @@ -831,6 +858,10 @@ func (o *Omnibox) onEntryChanged() { | |||
| 831 | 858 | o.debounceMu.Unlock() | |
| 832 | 859 | } | |
| 833 | 860 | ||
| 861 | + | func shouldUpdateGhostImmediately(previousInput, entryText string) bool { | |
| 862 | + | return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput) | |
| 863 | + | } | |
| 864 | + | ||
| 834 | 865 | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |
| 835 | 866 | o.mu.RLock() | |
| 836 | 867 | hasGhost := o.hasGhostText | |
| @@ -1047,10 +1078,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1047 | 1078 | mode := o.viewMode | |
| 1048 | 1079 | bangMode := o.bangMode | |
| 1049 | 1080 | hasNavigated := o.hasNavigated | |
| 1081 | + | suppressGhost := o.suppressGhost | |
| 1050 | 1082 | suggestions := o.suggestions | |
| 1051 | 1083 | favorites := o.favorites | |
| 1052 | 1084 | o.mu.RUnlock() | |
| 1053 | 1085 | ||
| 1086 | + | if suppressGhost { | |
| 1087 | + | o.clearGhostTextIfInput(entryText) | |
| 1088 | + | return | |
| 1089 | + | } | |
| 1090 | + | ||
| 1054 | 1091 | // No ghost text in bang mode | |
| 1055 | 1092 | if bangMode { | |
| 1056 | 1093 | o.clearGhostTextIfInput(entryText) | |
| @@ -1096,6 +1133,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||
| 1096 | 1133 | return | |
| 1097 | 1134 | } | |
| 1098 | 1135 | ||
| 1136 | + | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | |
| 1137 | + | if !ok { | |
| 1138 | + | o.clearGhostTextIfInput(entryText) | |
| 1139 | + | return | |
| 1140 | + | } | |
| 1141 | + | ||
| 1099 | 1142 | visibleURLs := make([]string, 0, len(suggestions)) | |
| 1100 | 1143 | if mode == ViewModeHistory { | |
| 1101 | 1144 | for _, s := range suggestions { | |
| @@ -1118,7 +1161,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||
| 1118 | 1161 | o.mu.Unlock() | |
| 1119 | 1162 | ||
| 1120 | 1163 | go func() { | |
| 1121 | - | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | |
| 1164 | + | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{ | |
| 1122 | 1165 | VisibleURLs: visibleURLs, | |
| 1123 | 1166 | AllowBangs: false, | |
| 1124 | 1167 | }) | |
| @@ -1129,15 +1172,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||
| 1129 | 1172 | o.clearGhostTextIfInput(entryText) | |
| 1130 | 1173 | return | |
| 1131 | 1174 | } | |
| 1132 | - | fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix) | |
| 1175 | + | fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix) | |
| 1133 | 1176 | if fullText == "" || suffix == "" { | |
| 1134 | 1177 | o.clearGhostTextIfInput(entryText) | |
| 1135 | 1178 | return | |
| 1136 | 1179 | } | |
| 1137 | - | o.setGhostText(entryText, suffix, fullText) | |
| 1180 | + | o.setGhostText(entryText, suffix, leadingWhitespace+fullText) | |
| 1138 | 1181 | }() | |
| 1139 | 1182 | } | |
| 1140 | 1183 | ||
| 1184 | + | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | |
| 1185 | + | trimmed := strings.TrimLeft(entryText, " \t") | |
| 1186 | + | leadingWhitespace = entryText[:len(entryText)-len(trimmed)] | |
| 1187 | + | if trimmed == "" { | |
| 1188 | + | return leadingWhitespace, "", false | |
| 1189 | + | } | |
| 1190 | + | return leadingWhitespace, trimmed, true | |
| 1191 | + | } | |
| 1192 | + | ||
| 1141 | 1193 | // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input | |
| 1142 | 1194 | // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL). | |
| 1143 | 1195 | func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) { | |
| @@ -1867,10 +1919,10 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1867 | 1919 | } | |
| 1868 | 1920 | ||
| 1869 | 1921 | var targetURL string | |
| 1870 | - | ||
| 1871 | - | // If user has selected a result, navigate to that result | |
| 1872 | - | // Otherwise, treat entry text as URL/search | |
| 1873 | - | if idx >= 0 { | |
| 1922 | + | if shouldPreferTypedURLNavigation(entryText) { | |
| 1923 | + | targetURL = o.buildURL(entryText) | |
| 1924 | + | } else if idx >= 0 { | |
| 1925 | + | // If user has selected a result, navigate to that result. | |
| 1874 | 1926 | if mode == ViewModeHistory { | |
| 1875 | 1927 | if idx < len(suggestions) { | |
| 1876 | 1928 | targetURL = suggestions[idx].URL | |
| @@ -1881,7 +1933,7 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1881 | 1933 | } | |
| 1882 | 1934 | } | |
| 1883 | 1935 | } else { | |
| 1884 | - | // No selection - use entry text as URL/search | |
| 1936 | + | // No selection - use entry text as URL/search. | |
| 1885 | 1937 | targetURL = o.buildURL(entryText) | |
| 1886 | 1938 | } | |
| 1887 | 1939 | ||
| @@ -1896,6 +1948,14 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1896 | 1948 | } | |
| 1897 | 1949 | } | |
| 1898 | 1950 | ||
| 1951 | + | func shouldPreferTypedURLNavigation(entryText string) bool { | |
| 1952 | + | entryText = strings.TrimSpace(entryText) | |
| 1953 | + | if entryText == "" { | |
| 1954 | + | return false | |
| 1955 | + | } | |
| 1956 | + | return url.LooksLikeURL(entryText) | |
| 1957 | + | } | |
| 1958 | + | ||
| 1899 | 1959 | // toggleFavorite adds or removes the selected item from favorites. | |
| 1900 | 1960 | // In History mode: adds the selected item to favorites | |
| 1901 | 1961 | // In Favorites mode: removes the selected item from favorites | |
| @@ -2142,6 +2202,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2142 | 2202 | o.ghostFullText = "" | |
| 2143 | 2203 | o.hasGhostText = false | |
| 2144 | 2204 | o.isAcceptingGhost = false | |
| 2205 | + | o.suppressGhost = false | |
| 2145 | 2206 | o.mu.Unlock() | |
| 2146 | 2207 | ||
| 2147 | 2208 | // Set initial query | |
| @@ -2174,6 +2235,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2174 | 2235 | ||
| 2175 | 2236 | // Focus the entry | |
| 2176 | 2237 | o.entry.GrabFocus() | |
| 2238 | + | o.resetSearchSessionState() | |
| 2177 | 2239 | ||
| 2178 | 2240 | // Load initial data (may update size later if results found) | |
| 2179 | 2241 | o.performSearch() | |
| @@ -2212,13 +2274,21 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 2212 | 2274 | // Reset realInput | |
| 2213 | 2275 | o.mu.Lock() | |
| 2214 | 2276 | o.realInput = "" | |
| 2277 | + | o.suppressGhost = false | |
| 2215 | 2278 | o.mu.Unlock() | |
| 2279 | + | o.resetSearchSessionState() | |
| 2216 | 2280 | ||
| 2217 | 2281 | if o.onClose != nil { | |
| 2218 | 2282 | o.onClose() | |
| 2219 | 2283 | } | |
| 2220 | 2284 | } | |
| 2221 | 2285 | ||
| 2286 | + | func (o *Omnibox) resetSearchSessionState() { | |
| 2287 | + | o.debounceMu.Lock() | |
| 2288 | + | o.lastQuery = "" | |
| 2289 | + | o.debounceMu.Unlock() | |
| 2290 | + | } | |
| 2291 | + | ||
| 2222 | 2292 | // Toggle shows if hidden, hides if visible. | |
| 2223 | 2293 | func (o *Omnibox) Toggle(ctx context.Context) { | |
| 2224 | 2294 | o.mu.RLock() | |
| @@ -98,6 +98,7 @@ type Omnibox struct { | |||||
| 98 | ghostFullText string // Full text that ghost completes to | 98 | ghostFullText string // Full text that ghost completes to | ||
| 99 | hasGhostText bool // Quick check for Tab behavior | 99 | hasGhostText bool // Quick check for Tab behavior | ||
| 100 | isAcceptingGhost bool // Prevent re-trigger during acceptance | 100 | isAcceptingGhost bool // Prevent re-trigger during acceptance | ||
| 101 | + | suppressGhost bool // Temporarily disable ghost completion after deletion keys | |||
| 101 | 102 | ||||
| 102 | // Dependencies | 103 | // Dependencies | ||
| 103 | historyUC *usecase.SearchHistoryUseCase | 104 | historyUC *usecase.SearchHistoryUseCase | ||
| @@ -681,6 +682,10 @@ func (o *Omnibox) hasUserNavigated() bool { | |||||
| 681 | // handleKeyPress processes keyboard events. | 682 | // handleKeyPress processes keyboard events. | ||
| 682 | // Returns true if the event was handled. | 683 | // Returns true if the event was handled. | ||
| 683 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 684 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 685 | + | o.mu.Lock() | |||
| 686 | + | o.suppressGhost = nextGhostSuppressionState(o.suppressGhost, keyval) | |||
| 687 | + | o.mu.Unlock() | |||
| 688 | + | ||||
| 684 | ctrl := state&gdk.ControlMaskValue != 0 | 689 | ctrl := state&gdk.ControlMaskValue != 0 | ||
| 685 | 690 | ||||
| 686 | switch keyval { | 691 | switch keyval { | ||
| @@ -754,6 +759,22 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 754 | return false // Let entry handle the key | 759 | return false // Let entry handle the key | ||
| 755 | } | 760 | } | ||
| 756 | 761 | ||||
| 762 | + | func nextGhostSuppressionState(_ bool, keyval uint) bool { | |||
| 763 | + | if isDeletionKey(keyval) { | |||
| 764 | + | return true | |||
| 765 | + | } | |||
| 766 | + | return false | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | func isDeletionKey(keyval uint) bool { | |||
| 770 | + | switch keyval { | |||
| 771 | + | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | |||
| 772 | + | return true | |||
| 773 | + | default: | |||
| 774 | + | return false | |||
| 775 | + | } | |||
| 776 | + | } | |||
| 777 | + | ||||
| 757 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | 778 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | ||
| 758 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | 779 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | ||
| 759 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | 780 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | ||
| @@ -813,6 +834,7 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 813 | 834 | ||||
| 814 | // Reset navigation state when user types - space should type, not toggle favorite | 835 | // Reset navigation state when user types - space should type, not toggle favorite | ||
| 815 | o.mu.Lock() | 836 | o.mu.Lock() | ||
| 837 | + | previousInput := o.realInput | |||
| 816 | o.hasNavigated = false | 838 | o.hasNavigated = false | ||
| 817 | // Update realInput with what user actually typed | 839 | // Update realInput with what user actually typed | ||
| 818 | o.realInput = entryText | 840 | o.realInput = entryText | ||
| @@ -820,6 +842,11 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 820 | 842 | ||||
| 821 | // Clear ghost text when user types (it will be recalculated after search) | 843 | // Clear ghost text when user types (it will be recalculated after search) | ||
| 822 | o.clearGhostText() | 844 | o.clearGhostText() | ||
| 845 | + | // Recompute immediately from currently visible rows to avoid transient gaps | |||
| 846 | + | // where suggestions are visible but ghost text is missing until async search returns. | |||
| 847 | + | if shouldUpdateGhostImmediately(previousInput, entryText) { | |||
| 848 | + | o.updateGhostFromSelectionWithInput(entryText) | |||
| 849 | + | } | |||
| 823 | 850 | ||||
| 824 | o.debounceMu.Lock() | 851 | o.debounceMu.Lock() | ||
| 825 | if o.debounceTimer != nil { | 852 | if o.debounceTimer != nil { | ||
| @@ -831,6 +858,10 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 831 | o.debounceMu.Unlock() | 858 | o.debounceMu.Unlock() | ||
| 832 | } | 859 | } | ||
| 833 | 860 | ||||
| 861 | + | func shouldUpdateGhostImmediately(previousInput, entryText string) bool { | |||
| 862 | + | return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput) | |||
| 863 | + | } | |||
| 864 | + | ||||
| 834 | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | 865 | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | ||
| 835 | o.mu.RLock() | 866 | o.mu.RLock() | ||
| 836 | hasGhost := o.hasGhostText | 867 | hasGhost := o.hasGhostText | ||
| @@ -1047,10 +1078,16 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1047 | mode := o.viewMode | 1078 | mode := o.viewMode | ||
| 1048 | bangMode := o.bangMode | 1079 | bangMode := o.bangMode | ||
| 1049 | hasNavigated := o.hasNavigated | 1080 | hasNavigated := o.hasNavigated | ||
| 1081 | + | suppressGhost := o.suppressGhost | |||
| 1050 | suggestions := o.suggestions | 1082 | suggestions := o.suggestions | ||
| 1051 | favorites := o.favorites | 1083 | favorites := o.favorites | ||
| 1052 | o.mu.RUnlock() | 1084 | o.mu.RUnlock() | ||
| 1053 | 1085 | ||||
| 1086 | + | if suppressGhost { | |||
| 1087 | + | o.clearGhostTextIfInput(entryText) | |||
| 1088 | + | return | |||
| 1089 | + | } | |||
| 1090 | + | ||||
| 1054 | // No ghost text in bang mode | 1091 | // No ghost text in bang mode | ||
| 1055 | if bangMode { | 1092 | if bangMode { | ||
| 1056 | o.clearGhostTextIfInput(entryText) | 1093 | o.clearGhostTextIfInput(entryText) | ||
| @@ -1096,6 +1133,12 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||||
| 1096 | return | 1133 | return | ||
| 1097 | } | 1134 | } | ||
| 1098 | 1135 | ||||
| 1136 | + | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | |||
| 1137 | + | if !ok { | |||
| 1138 | + | o.clearGhostTextIfInput(entryText) | |||
| 1139 | + | return | |||
| 1140 | + | } | |||
| 1141 | + | ||||
| 1099 | visibleURLs := make([]string, 0, len(suggestions)) | 1142 | visibleURLs := make([]string, 0, len(suggestions)) | ||
| 1100 | if mode == ViewModeHistory { | 1143 | if mode == ViewModeHistory { | ||
| 1101 | for _, s := range suggestions { | 1144 | for _, s := range suggestions { | ||
| @@ -1118,7 +1161,7 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||||
| 1118 | o.mu.Unlock() | 1161 | o.mu.Unlock() | ||
| 1119 | 1162 | ||||
| 1120 | go func() { | 1163 | go func() { | ||
| 1121 | - | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | 1164 | + | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, completionInput, usecase.CompletionOptions{ |
| 1122 | VisibleURLs: visibleURLs, | 1165 | VisibleURLs: visibleURLs, | ||
| 1123 | AllowBangs: false, | 1166 | AllowBangs: false, | ||
| 1124 | }) | 1167 | }) | ||
| @@ -1129,15 +1172,24 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||||
| 1129 | o.clearGhostTextIfInput(entryText) | 1172 | o.clearGhostTextIfInput(entryText) | ||
| 1130 | return | 1173 | return | ||
| 1131 | } | 1174 | } | ||
| 1132 | - | fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix) | 1175 | + | fullText, suffix := normalizeGhostSuggestion(completionInput, suggestion.FullText, suggestion.Suffix) |
| 1133 | if fullText == "" || suffix == "" { | 1176 | if fullText == "" || suffix == "" { | ||
| 1134 | o.clearGhostTextIfInput(entryText) | 1177 | o.clearGhostTextIfInput(entryText) | ||
| 1135 | return | 1178 | return | ||
| 1136 | } | 1179 | } | ||
| 1137 | - | o.setGhostText(entryText, suffix, fullText) | 1180 | + | o.setGhostText(entryText, suffix, leadingWhitespace+fullText) |
| 1138 | }() | 1181 | }() | ||
| 1139 | } | 1182 | } | ||
| 1140 | 1183 | ||||
| 1184 | + | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | |||
| 1185 | + | trimmed := strings.TrimLeft(entryText, " \t") | |||
| 1186 | + | leadingWhitespace = entryText[:len(entryText)-len(trimmed)] | |||
| 1187 | + | if trimmed == "" { | |||
| 1188 | + | return leadingWhitespace, "", false | |||
| 1189 | + | } | |||
| 1190 | + | return leadingWhitespace, trimmed, true | |||
| 1191 | + | } | |||
| 1192 | + | ||||
| 1141 | // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input | 1193 | // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input | ||
| 1142 | // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL). | 1194 | // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL). | ||
| 1143 | func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) { | 1195 | func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) { | ||
| @@ -1867,10 +1919,10 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1867 | } | 1919 | } | ||
| 1868 | 1920 | ||||
| 1869 | var targetURL string | 1921 | var targetURL string | ||
| 1870 | - | 1922 | + | if shouldPreferTypedURLNavigation(entryText) { | |
| 1871 | - | // If user has selected a result, navigate to that result | 1923 | + | targetURL = o.buildURL(entryText) |
| 1872 | - | // Otherwise, treat entry text as URL/search | 1924 | + | } else if idx >= 0 { |
| 1873 | - | if idx >= 0 { | 1925 | + | // If user has selected a result, navigate to that result. |
| 1874 | if mode == ViewModeHistory { | 1926 | if mode == ViewModeHistory { | ||
| 1875 | if idx < len(suggestions) { | 1927 | if idx < len(suggestions) { | ||
| 1876 | targetURL = suggestions[idx].URL | 1928 | targetURL = suggestions[idx].URL | ||
| @@ -1881,7 +1933,7 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1881 | } | 1933 | } | ||
| 1882 | } | 1934 | } | ||
| 1883 | } else { | 1935 | } else { | ||
| 1884 | - | // No selection - use entry text as URL/search | 1936 | + | // No selection - use entry text as URL/search. |
| 1885 | targetURL = o.buildURL(entryText) | 1937 | targetURL = o.buildURL(entryText) | ||
| 1886 | } | 1938 | } | ||
| 1887 | 1939 | ||||
| @@ -1896,6 +1948,14 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1896 | } | 1948 | } | ||
| 1897 | } | 1949 | } | ||
| 1898 | 1950 | ||||
| 1951 | + | func shouldPreferTypedURLNavigation(entryText string) bool { | |||
| 1952 | + | entryText = strings.TrimSpace(entryText) | |||
| 1953 | + | if entryText == "" { | |||
| 1954 | + | return false | |||
| 1955 | + | } | |||
| 1956 | + | return url.LooksLikeURL(entryText) | |||
| 1957 | + | } | |||
| 1958 | + | ||||
| 1899 | // toggleFavorite adds or removes the selected item from favorites. | 1959 | // toggleFavorite adds or removes the selected item from favorites. | ||
| 1900 | // In History mode: adds the selected item to favorites | 1960 | // In History mode: adds the selected item to favorites | ||
| 1901 | // In Favorites mode: removes the selected item from favorites | 1961 | // In Favorites mode: removes the selected item from favorites | ||
| @@ -2142,6 +2202,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2142 | o.ghostFullText = "" | 2202 | o.ghostFullText = "" | ||
| 2143 | o.hasGhostText = false | 2203 | o.hasGhostText = false | ||
| 2144 | o.isAcceptingGhost = false | 2204 | o.isAcceptingGhost = false | ||
| 2205 | + | o.suppressGhost = false | |||
| 2145 | o.mu.Unlock() | 2206 | o.mu.Unlock() | ||
| 2146 | 2207 | ||||
| 2147 | // Set initial query | 2208 | // Set initial query | ||
| @@ -2174,6 +2235,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2174 | 2235 | ||||
| 2175 | // Focus the entry | 2236 | // Focus the entry | ||
| 2176 | o.entry.GrabFocus() | 2237 | o.entry.GrabFocus() | ||
| 2238 | + | o.resetSearchSessionState() | |||
| 2177 | 2239 | ||||
| 2178 | // Load initial data (may update size later if results found) | 2240 | // Load initial data (may update size later if results found) | ||
| 2179 | o.performSearch() | 2241 | o.performSearch() | ||
| @@ -2212,13 +2274,21 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 2212 | // Reset realInput | 2274 | // Reset realInput | ||
| 2213 | o.mu.Lock() | 2275 | o.mu.Lock() | ||
| 2214 | o.realInput = "" | 2276 | o.realInput = "" | ||
| 2277 | + | o.suppressGhost = false | |||
| 2215 | o.mu.Unlock() | 2278 | o.mu.Unlock() | ||
| 2279 | + | o.resetSearchSessionState() | |||
| 2216 | 2280 | ||||
| 2217 | if o.onClose != nil { | 2281 | if o.onClose != nil { | ||
| 2218 | o.onClose() | 2282 | o.onClose() | ||
| 2219 | } | 2283 | } | ||
| 2220 | } | 2284 | } | ||
| 2221 | 2285 | ||||
| 2286 | + | func (o *Omnibox) resetSearchSessionState() { | |||
| 2287 | + | o.debounceMu.Lock() | |||
| 2288 | + | o.lastQuery = "" | |||
| 2289 | + | o.debounceMu.Unlock() | |||
| 2290 | + | } | |||
| 2291 | + | ||||
| 2222 | // Toggle shows if hidden, hides if visible. | 2292 | // Toggle shows if hidden, hides if visible. | ||
| 2223 | func (o *Omnibox) Toggle(ctx context.Context) { | 2293 | func (o *Omnibox) Toggle(ctx context.Context) { | ||
| 2224 | o.mu.RLock() | 2294 | o.mu.RLock() | ||
M internal/ui/component/omnibox_ghost_normalize_test.go +30 -0
| @@ -33,3 +33,33 @@ func TestExtractHostForCompletion(t *testing.T) { | |||
| 33 | 33 | t.Fatalf("expected normalized host, got %q", got) | |
| 34 | 34 | } | |
| 35 | 35 | } | |
| 36 | + | ||
| 37 | + | func TestGhostCompletionInput(t *testing.T) { | |
| 38 | + | tests := []struct { | |
| 39 | + | name string | |
| 40 | + | entryText string | |
| 41 | + | wantPrefix string | |
| 42 | + | wantTrimmed string | |
| 43 | + | wantOK bool | |
| 44 | + | }{ | |
| 45 | + | {name: "no leading whitespace", entryText: "goo", wantPrefix: "", wantTrimmed: "goo", wantOK: true}, | |
| 46 | + | {name: "leading space", entryText: " goo", wantPrefix: " ", wantTrimmed: "goo", wantOK: true}, | |
| 47 | + | {name: "leading tab", entryText: "\tgoo", wantPrefix: "\t", wantTrimmed: "goo", wantOK: true}, | |
| 48 | + | {name: "only whitespace", entryText: " \t", wantPrefix: " \t", wantTrimmed: "", wantOK: false}, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | for _, tt := range tests { | |
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |
| 53 | + | gotPrefix, gotTrimmed, gotOK := ghostCompletionInput(tt.entryText) | |
| 54 | + | if gotPrefix != tt.wantPrefix { | |
| 55 | + | t.Fatalf("ghostCompletionInput(%q) prefix = %q, want %q", tt.entryText, gotPrefix, tt.wantPrefix) | |
| 56 | + | } | |
| 57 | + | if gotTrimmed != tt.wantTrimmed { | |
| 58 | + | t.Fatalf("ghostCompletionInput(%q) trimmed = %q, want %q", tt.entryText, gotTrimmed, tt.wantTrimmed) | |
| 59 | + | } | |
| 60 | + | if gotOK != tt.wantOK { | |
| 61 | + | t.Fatalf("ghostCompletionInput(%q) ok = %v, want %v", tt.entryText, gotOK, tt.wantOK) | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | } | |
| @@ -33,3 +33,33 @@ func TestExtractHostForCompletion(t *testing.T) { | |||||
| 33 | t.Fatalf("expected normalized host, got %q", got) | 33 | t.Fatalf("expected normalized host, got %q", got) | ||
| 34 | } | 34 | } | ||
| 35 | } | 35 | } | ||
| 36 | + | ||||
| 37 | + | func TestGhostCompletionInput(t *testing.T) { | |||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | entryText string | |||
| 41 | + | wantPrefix string | |||
| 42 | + | wantTrimmed string | |||
| 43 | + | wantOK bool | |||
| 44 | + | }{ | |||
| 45 | + | {name: "no leading whitespace", entryText: "goo", wantPrefix: "", wantTrimmed: "goo", wantOK: true}, | |||
| 46 | + | {name: "leading space", entryText: " goo", wantPrefix: " ", wantTrimmed: "goo", wantOK: true}, | |||
| 47 | + | {name: "leading tab", entryText: "\tgoo", wantPrefix: "\t", wantTrimmed: "goo", wantOK: true}, | |||
| 48 | + | {name: "only whitespace", entryText: " \t", wantPrefix: " \t", wantTrimmed: "", wantOK: false}, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, tt := range tests { | |||
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | + | gotPrefix, gotTrimmed, gotOK := ghostCompletionInput(tt.entryText) | |||
| 54 | + | if gotPrefix != tt.wantPrefix { | |||
| 55 | + | t.Fatalf("ghostCompletionInput(%q) prefix = %q, want %q", tt.entryText, gotPrefix, tt.wantPrefix) | |||
| 56 | + | } | |||
| 57 | + | if gotTrimmed != tt.wantTrimmed { | |||
| 58 | + | t.Fatalf("ghostCompletionInput(%q) trimmed = %q, want %q", tt.entryText, gotTrimmed, tt.wantTrimmed) | |||
| 59 | + | } | |||
| 60 | + | if gotOK != tt.wantOK { | |||
| 61 | + | t.Fatalf("ghostCompletionInput(%q) ok = %v, want %v", tt.entryText, gotOK, tt.wantOK) | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
A internal/ui/component/omnibox_navigation_state_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestShouldPreferTypedURLNavigation(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | input string | |
| 13 | + | want bool | |
| 14 | + | }{ | |
| 15 | + | {name: "domain", input: "x.com", want: true}, | |
| 16 | + | {name: "domain with path", input: "github.com/bnema", want: true}, | |
| 17 | + | {name: "http url", input: "http://example.com", want: true}, | |
| 18 | + | {name: "search query", input: "hello world", want: false}, | |
| 19 | + | {name: "empty", input: "", want: false}, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | for _, tt := range tests { | |
| 23 | + | t.Run(tt.name, func(t *testing.T) { | |
| 24 | + | if got := shouldPreferTypedURLNavigation(tt.input); got != tt.want { | |
| 25 | + | t.Fatalf("shouldPreferTypedURLNavigation(%q) = %v, want %v", tt.input, got, tt.want) | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestResetSearchSessionState(t *testing.T) { | |
| 32 | + | o := &Omnibox{} | |
| 33 | + | o.lastQuery = "github" | |
| 34 | + | ||
| 35 | + | o.resetSearchSessionState() | |
| 36 | + | ||
| 37 | + | if o.lastQuery != "" { | |
| 38 | + | t.Fatalf("lastQuery should be reset, got %q", o.lastQuery) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestShouldUpdateGhostImmediately(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | name string | |
| 45 | + | previousInput string | |
| 46 | + | entryText string | |
| 47 | + | want bool | |
| 48 | + | }{ | |
| 49 | + | {name: "typing forward", previousInput: "goo", entryText: "goog", want: true}, | |
| 50 | + | {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true}, | |
| 51 | + | {name: "backspace", previousInput: "goog", entryText: "goo", want: false}, | |
| 52 | + | {name: "clear input", previousInput: "goo", entryText: "", want: false}, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | for _, tt := range tests { | |
| 56 | + | t.Run(tt.name, func(t *testing.T) { | |
| 57 | + | if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want { | |
| 58 | + | t.Fatalf( | |
| 59 | + | "shouldUpdateGhostImmediately(%q, %q) = %v, want %v", | |
| 60 | + | tt.previousInput, | |
| 61 | + | tt.entryText, | |
| 62 | + | got, | |
| 63 | + | tt.want, | |
| 64 | + | ) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestNextGhostSuppressionState(t *testing.T) { | |
| 71 | + | tests := []struct { | |
| 72 | + | name string | |
| 73 | + | current bool | |
| 74 | + | key uint | |
| 75 | + | want bool | |
| 76 | + | }{ | |
| 77 | + | {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true}, | |
| 78 | + | {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true}, | |
| 79 | + | {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false}, | |
| 80 | + | {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false}, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | for _, tt := range tests { | |
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |
| 85 | + | if got := nextGhostSuppressionState(tt.current, tt.key); got != tt.want { | |
| 86 | + | t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want) | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestShouldPreferTypedURLNavigation(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | input string | |||
| 13 | + | want bool | |||
| 14 | + | }{ | |||
| 15 | + | {name: "domain", input: "x.com", want: true}, | |||
| 16 | + | {name: "domain with path", input: "github.com/bnema", want: true}, | |||
| 17 | + | {name: "http url", input: "http://example.com", want: true}, | |||
| 18 | + | {name: "search query", input: "hello world", want: false}, | |||
| 19 | + | {name: "empty", input: "", want: false}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | for _, tt := range tests { | |||
| 23 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 24 | + | if got := shouldPreferTypedURLNavigation(tt.input); got != tt.want { | |||
| 25 | + | t.Fatalf("shouldPreferTypedURLNavigation(%q) = %v, want %v", tt.input, got, tt.want) | |||
| 26 | + | } | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestResetSearchSessionState(t *testing.T) { | |||
| 32 | + | o := &Omnibox{} | |||
| 33 | + | o.lastQuery = "github" | |||
| 34 | + | ||||
| 35 | + | o.resetSearchSessionState() | |||
| 36 | + | ||||
| 37 | + | if o.lastQuery != "" { | |||
| 38 | + | t.Fatalf("lastQuery should be reset, got %q", o.lastQuery) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestShouldUpdateGhostImmediately(t *testing.T) { | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | previousInput string | |||
| 46 | + | entryText string | |||
| 47 | + | want bool | |||
| 48 | + | }{ | |||
| 49 | + | {name: "typing forward", previousInput: "goo", entryText: "goog", want: true}, | |||
| 50 | + | {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true}, | |||
| 51 | + | {name: "backspace", previousInput: "goog", entryText: "goo", want: false}, | |||
| 52 | + | {name: "clear input", previousInput: "goo", entryText: "", want: false}, | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | for _, tt := range tests { | |||
| 56 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 57 | + | if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want { | |||
| 58 | + | t.Fatalf( | |||
| 59 | + | "shouldUpdateGhostImmediately(%q, %q) = %v, want %v", | |||
| 60 | + | tt.previousInput, | |||
| 61 | + | tt.entryText, | |||
| 62 | + | got, | |||
| 63 | + | tt.want, | |||
| 64 | + | ) | |||
| 65 | + | } | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestNextGhostSuppressionState(t *testing.T) { | |||
| 71 | + | tests := []struct { | |||
| 72 | + | name string | |||
| 73 | + | current bool | |||
| 74 | + | key uint | |||
| 75 | + | want bool | |||
| 76 | + | }{ | |||
| 77 | + | {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true}, | |||
| 78 | + | {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true}, | |||
| 79 | + | {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false}, | |||
| 80 | + | {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false}, | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | for _, tt := range tests { | |||
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 85 | + | if got := nextGhostSuppressionState(tt.current, tt.key); got != tt.want { | |||
| 86 | + | t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want) | |||
| 87 | + | } | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | } | |||