feat(omnibox): add bang shortcuts UI and filtering
- commit
- 0515f1591228bf6c15ddcfcf3db88ddc996c5e92
- author
- brice · · 2025-12-28 14:11 UTC
- parent
- 56bd04d
- signature
- unsigned
4 files · +333 -16
M CHANGELOG.md +1 -0
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized. | |
| 8 | 9 | - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup. | |
| 9 | 10 | - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true). | |
| 10 | 11 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | |
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | + | - **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized. | |||
| 8 | - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup. | 9 | - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup. | ||
| 9 | - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true). | 10 | - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true). | ||
| 10 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | 11 | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | ||
M internal/ui/component/omnibox.go +236 -16
| @@ -3,6 +3,7 @@ package component | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "sort" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| @@ -22,6 +23,7 @@ import ( | |||
| 22 | 23 | ||
| 23 | 24 | const ( | |
| 24 | 25 | debounceDelayMs = 150 | |
| 26 | + | endBoxSpacing = 6 | |
| 25 | 27 | ) | |
| 26 | 28 | ||
| 27 | 29 | // ViewMode distinguishes history search from favorites display. | |
| @@ -40,6 +42,13 @@ type Suggestion struct { | |||
| 40 | 42 | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 45 | + | // BangSuggestion represents a configured bang shortcut. | |
| 46 | + | // A bang is invoked by typing "!<key>". | |
| 47 | + | type BangSuggestion struct { | |
| 48 | + | Key string | |
| 49 | + | Description string | |
| 50 | + | } | |
| 51 | + | ||
| 43 | 52 | // Favorite represents a bookmarked URL. | |
| 44 | 53 | type Favorite struct { | |
| 45 | 54 | ID int64 | |
| @@ -58,6 +67,7 @@ type Omnibox struct { | |||
| 58 | 67 | historyBtn *gtk.Button | |
| 59 | 68 | favoritesBtn *gtk.Button | |
| 60 | 69 | zoomLabel *gtk.Label | |
| 70 | + | bangBadge *gtk.Label | |
| 61 | 71 | entry *gtk.SearchEntry | |
| 62 | 72 | scrolledWin *gtk.ScrolledWindow | |
| 63 | 73 | listBox *gtk.ListBox | |
| @@ -66,13 +76,16 @@ type Omnibox struct { | |||
| 66 | 76 | parentOverlay layout.OverlayWidget | |
| 67 | 77 | ||
| 68 | 78 | // State | |
| 69 | - | mu sync.RWMutex | |
| 70 | - | visible bool | |
| 71 | - | viewMode ViewMode | |
| 72 | - | selectedIndex int | |
| 73 | - | suggestions []Suggestion | |
| 74 | - | favorites []Favorite | |
| 75 | - | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |
| 79 | + | mu sync.RWMutex | |
| 80 | + | visible bool | |
| 81 | + | viewMode ViewMode | |
| 82 | + | selectedIndex int | |
| 83 | + | suggestions []Suggestion | |
| 84 | + | favorites []Favorite | |
| 85 | + | bangSuggestions []BangSuggestion | |
| 86 | + | bangMode bool | |
| 87 | + | detectedBang string | |
| 88 | + | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |
| 76 | 89 | ||
| 77 | 90 | // Dependencies | |
| 78 | 91 | historyUC *usecase.SearchHistoryUseCase | |
| @@ -351,12 +364,16 @@ func (o *Omnibox) initHeader() error { | |||
| 351 | 364 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | |
| 352 | 365 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | |
| 353 | 366 | ||
| 367 | + | o.bangBadge = gtk.NewLabel(nil) | |
| 368 | + | if o.bangBadge != nil { | |
| 369 | + | o.bangBadge.AddCssClass("omnibox-bang-badge") | |
| 370 | + | o.bangBadge.SetVisible(false) | |
| 371 | + | } | |
| 372 | + | ||
| 354 | 373 | o.zoomLabel = gtk.NewLabel(nil) | |
| 355 | 374 | if o.zoomLabel != nil { | |
| 356 | 375 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | |
| 357 | 376 | o.zoomLabel.SetVisible(false) | |
| 358 | - | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |
| 359 | - | o.zoomLabel.SetHexpand(true) | |
| 360 | 377 | } | |
| 361 | 378 | return nil | |
| 362 | 379 | } | |
| @@ -369,7 +386,7 @@ func (o *Omnibox) initEntry() error { | |||
| 369 | 386 | o.entry.AddCssClass("omnibox-entry") | |
| 370 | 387 | o.entry.SetHexpand(true) | |
| 371 | 388 | ||
| 372 | - | placeholder := "Search history or enter URL..." | |
| 389 | + | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 373 | 390 | o.entry.SetPlaceholderText(&placeholder) | |
| 374 | 391 | return nil | |
| 375 | 392 | } | |
| @@ -413,8 +430,18 @@ func (o *Omnibox) initList() error { | |||
| 413 | 430 | func (o *Omnibox) assembleWidgets() { | |
| 414 | 431 | o.headerBox.Append(&o.historyBtn.Widget) | |
| 415 | 432 | o.headerBox.Append(&o.favoritesBtn.Widget) | |
| 416 | - | if o.zoomLabel != nil { | |
| 417 | - | o.headerBox.Append(&o.zoomLabel.Widget) | |
| 433 | + | ||
| 434 | + | endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing) | |
| 435 | + | if endBox != nil { | |
| 436 | + | endBox.SetHexpand(true) | |
| 437 | + | endBox.SetHalign(gtk.AlignEndValue) | |
| 438 | + | if o.bangBadge != nil { | |
| 439 | + | endBox.Append(&o.bangBadge.Widget) | |
| 440 | + | } | |
| 441 | + | if o.zoomLabel != nil { | |
| 442 | + | endBox.Append(&o.zoomLabel.Widget) | |
| 443 | + | } | |
| 444 | + | o.headerBox.Append(&endBox.Widget) | |
| 418 | 445 | } | |
| 419 | 446 | ||
| 420 | 447 | o.scrolledWin.SetChild(&o.listBox.Widget) | |
| @@ -578,6 +605,14 @@ func (o *Omnibox) performSearch() { | |||
| 578 | 605 | o.lastQuery = query | |
| 579 | 606 | o.debounceMu.Unlock() | |
| 580 | 607 | ||
| 608 | + | if strings.HasPrefix(query, "!") { | |
| 609 | + | o.updateBangDetection(query) | |
| 610 | + | o.loadBangSuggestions(query) | |
| 611 | + | return | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | o.clearBangState() | |
| 615 | + | ||
| 581 | 616 | if mode == ViewModeFavorites { | |
| 582 | 617 | o.loadFavorites(query) | |
| 583 | 618 | return | |
| @@ -745,9 +780,142 @@ func (o *Omnibox) loadFavorites(query string) { | |||
| 745 | 780 | }() | |
| 746 | 781 | } | |
| 747 | 782 | ||
| 783 | + | func buildBangSuggestions(shortcuts map[string]config.SearchShortcut, query string) []BangSuggestion { | |
| 784 | + | prefix := strings.TrimPrefix(query, "!") | |
| 785 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |
| 786 | + | prefix = prefix[:idx] | |
| 787 | + | } | |
| 788 | + | prefix = strings.ToLower(prefix) | |
| 789 | + | ||
| 790 | + | suggestions := make([]BangSuggestion, 0, len(shortcuts)) | |
| 791 | + | for key, shortcut := range shortcuts { | |
| 792 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |
| 793 | + | continue | |
| 794 | + | } | |
| 795 | + | description := shortcut.Description | |
| 796 | + | if description == "" { | |
| 797 | + | description = shortcut.URL | |
| 798 | + | } | |
| 799 | + | suggestions = append(suggestions, BangSuggestion{ | |
| 800 | + | Key: key, | |
| 801 | + | Description: description, | |
| 802 | + | }) | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | sort.Slice(suggestions, func(i, j int) bool { | |
| 806 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |
| 807 | + | }) | |
| 808 | + | ||
| 809 | + | return suggestions | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | func (o *Omnibox) loadBangSuggestions(query string) { | |
| 813 | + | suggestions := buildBangSuggestions(o.shortcuts, query) | |
| 814 | + | o.idleAddUpdateBangSuggestions(suggestions) | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | func (o *Omnibox) updateBangDetection(query string) { | |
| 818 | + | spaceIdx := strings.Index(query, " ") | |
| 819 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |
| 820 | + | o.clearDetectedBang() | |
| 821 | + | return | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | candidate := query[1:spaceIdx] | |
| 825 | + | for key := range o.shortcuts { | |
| 826 | + | if strings.EqualFold(key, candidate) { | |
| 827 | + | o.setDetectedBang(key) | |
| 828 | + | return | |
| 829 | + | } | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | o.clearDetectedBang() | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | func (o *Omnibox) clearBangState() { | |
| 836 | + | o.mu.Lock() | |
| 837 | + | o.bangMode = false | |
| 838 | + | o.bangSuggestions = nil | |
| 839 | + | o.mu.Unlock() | |
| 840 | + | ||
| 841 | + | o.clearDetectedBang() | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | func (o *Omnibox) setDetectedBang(key string) { | |
| 845 | + | o.mu.Lock() | |
| 846 | + | o.detectedBang = key | |
| 847 | + | o.mu.Unlock() | |
| 848 | + | ||
| 849 | + | if o.bangBadge == nil { | |
| 850 | + | return | |
| 851 | + | } | |
| 852 | + | shortcut, ok := o.shortcuts[key] | |
| 853 | + | if !ok { | |
| 854 | + | return | |
| 855 | + | } | |
| 856 | + | description := shortcut.Description | |
| 857 | + | if description == "" { | |
| 858 | + | description = shortcut.URL | |
| 859 | + | } | |
| 860 | + | label := description | |
| 861 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 862 | + | o.entry.AddCssClass("omnibox-entry-bang-active") | |
| 863 | + | o.bangBadge.SetText(label) | |
| 864 | + | o.bangBadge.SetVisible(true) | |
| 865 | + | return false | |
| 866 | + | } | |
| 867 | + | glib.IdleAdd(&cb, 0) | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | func (o *Omnibox) clearDetectedBang() { | |
| 871 | + | o.mu.Lock() | |
| 872 | + | o.detectedBang = "" | |
| 873 | + | o.mu.Unlock() | |
| 874 | + | ||
| 875 | + | if o.bangBadge == nil { | |
| 876 | + | return | |
| 877 | + | } | |
| 878 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 879 | + | o.entry.RemoveCssClass("omnibox-entry-bang-active") | |
| 880 | + | o.bangBadge.SetVisible(false) | |
| 881 | + | return false | |
| 882 | + | } | |
| 883 | + | glib.IdleAdd(&cb, 0) | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) { | |
| 887 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 888 | + | o.updateBangSuggestions(suggestions) | |
| 889 | + | return false | |
| 890 | + | } | |
| 891 | + | glib.IdleAdd(&cb, 0) | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |
| 895 | + | o.mu.Lock() | |
| 896 | + | o.bangMode = true | |
| 897 | + | o.bangSuggestions = suggestions | |
| 898 | + | o.selectedIndex = -1 | |
| 899 | + | o.mu.Unlock() | |
| 900 | + | ||
| 901 | + | o.rebuildList() | |
| 902 | + | ||
| 903 | + | rowCount := len(suggestions) | |
| 904 | + | if o.scrolledWin != nil { | |
| 905 | + | o.scrolledWin.SetVisible(rowCount > 0) | |
| 906 | + | } | |
| 907 | + | o.resizeAndCenter(rowCount) | |
| 908 | + | ||
| 909 | + | if rowCount > 0 { | |
| 910 | + | o.selectIndex(0) | |
| 911 | + | } | |
| 912 | + | } | |
| 913 | + | ||
| 748 | 914 | // updateSuggestions updates the list with history suggestions. | |
| 749 | 915 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |
| 750 | 916 | o.mu.Lock() | |
| 917 | + | o.bangMode = false | |
| 918 | + | o.bangSuggestions = nil | |
| 751 | 919 | o.suggestions = suggestions | |
| 752 | 920 | o.selectedIndex = -1 | |
| 753 | 921 | o.mu.Unlock() | |
| @@ -770,6 +938,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||
| 770 | 938 | // updateFavorites updates the list with favorites. | |
| 771 | 939 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | |
| 772 | 940 | o.mu.Lock() | |
| 941 | + | o.bangMode = false | |
| 942 | + | o.bangSuggestions = nil | |
| 773 | 943 | o.favorites = favorites | |
| 774 | 944 | o.selectedIndex = -1 | |
| 775 | 945 | o.mu.Unlock() | |
| @@ -798,9 +968,18 @@ func (o *Omnibox) rebuildList() { | |||
| 798 | 968 | mode := o.viewMode | |
| 799 | 969 | suggestions := o.suggestions | |
| 800 | 970 | favorites := o.favorites | |
| 971 | + | bangMode := o.bangMode | |
| 972 | + | bangSuggestions := o.bangSuggestions | |
| 801 | 973 | o.mu.RUnlock() | |
| 802 | 974 | ||
| 803 | - | if mode == ViewModeHistory { | |
| 975 | + | if bangMode { | |
| 976 | + | for i, b := range bangSuggestions { | |
| 977 | + | row := o.createBangRow(b, i) | |
| 978 | + | if row != nil { | |
| 979 | + | o.listBox.Append(&row.Widget) | |
| 980 | + | } | |
| 981 | + | } | |
| 982 | + | } else if mode == ViewModeHistory { | |
| 804 | 983 | for i, s := range suggestions { | |
| 805 | 984 | row := o.createSuggestionRow(s, i) | |
| 806 | 985 | if row != nil { | |
| @@ -831,7 +1010,9 @@ func (o *Omnibox) rebuildList() { | |||
| 831 | 1010 | // Re-trigger resize with accurate measurements | |
| 832 | 1011 | o.mu.RLock() | |
| 833 | 1012 | var count int | |
| 834 | - | if o.viewMode == ViewModeHistory { | |
| 1013 | + | if o.bangMode { | |
| 1014 | + | count = len(o.bangSuggestions) | |
| 1015 | + | } else if o.viewMode == ViewModeHistory { | |
| 835 | 1016 | count = len(o.suggestions) | |
| 836 | 1017 | } else { | |
| 837 | 1018 | count = len(o.favorites) | |
| @@ -962,6 +1143,14 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 962 | 1143 | return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index) | |
| 963 | 1144 | } | |
| 964 | 1145 | ||
| 1146 | + | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |
| 1147 | + | row := o.createRowWithFavicon(b.Description, "!"+b.Key, "system-search-symbolic", index) | |
| 1148 | + | if row != nil { | |
| 1149 | + | row.AddCssClass("omnibox-row-bang") | |
| 1150 | + | } | |
| 1151 | + | return row | |
| 1152 | + | } | |
| 1153 | + | ||
| 965 | 1154 | // selectIndex selects a row by index. | |
| 966 | 1155 | func (o *Omnibox) selectIndex(index int) { | |
| 967 | 1156 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | |
| @@ -978,8 +1167,11 @@ func (o *Omnibox) selectNext() { | |||
| 978 | 1167 | o.mu.Lock() | |
| 979 | 1168 | current := o.selectedIndex | |
| 980 | 1169 | mode := o.viewMode | |
| 1170 | + | bangMode := o.bangMode | |
| 981 | 1171 | var maxIndex int | |
| 982 | - | if mode == ViewModeHistory { | |
| 1172 | + | if bangMode { | |
| 1173 | + | maxIndex = len(o.bangSuggestions) - 1 | |
| 1174 | + | } else if mode == ViewModeHistory { | |
| 983 | 1175 | maxIndex = len(o.suggestions) - 1 | |
| 984 | 1176 | } else { | |
| 985 | 1177 | maxIndex = len(o.favorites) - 1 | |
| @@ -1003,8 +1195,11 @@ func (o *Omnibox) selectPrevious() { | |||
| 1003 | 1195 | o.mu.Lock() | |
| 1004 | 1196 | current := o.selectedIndex | |
| 1005 | 1197 | mode := o.viewMode | |
| 1198 | + | bangMode := o.bangMode | |
| 1006 | 1199 | var maxIndex int | |
| 1007 | - | if mode == ViewModeHistory { | |
| 1200 | + | if bangMode { | |
| 1201 | + | maxIndex = len(o.bangSuggestions) - 1 | |
| 1202 | + | } else if mode == ViewModeHistory { | |
| 1008 | 1203 | maxIndex = len(o.suggestions) - 1 | |
| 1009 | 1204 | } else { | |
| 1010 | 1205 | maxIndex = len(o.favorites) - 1 | |
| @@ -1037,9 +1232,33 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1037 | 1232 | idx := o.selectedIndex | |
| 1038 | 1233 | suggestions := o.suggestions | |
| 1039 | 1234 | favorites := o.favorites | |
| 1235 | + | bangMode := o.bangMode | |
| 1236 | + | bangSuggestions := o.bangSuggestions | |
| 1040 | 1237 | o.mu.RUnlock() | |
| 1041 | 1238 | ||
| 1042 | 1239 | entryText := o.entry.GetText() | |
| 1240 | + | if shortcutKey, query, found := url.ParseBangShortcut(entryText); found { | |
| 1241 | + | resolvedKey := shortcutKey | |
| 1242 | + | for key := range o.shortcuts { | |
| 1243 | + | if strings.EqualFold(key, shortcutKey) { | |
| 1244 | + | resolvedKey = key | |
| 1245 | + | break | |
| 1246 | + | } | |
| 1247 | + | } | |
| 1248 | + | entryText = "!" + resolvedKey + " " + query | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | if bangMode { | |
| 1252 | + | // If the user already typed a bang query ("!key something"), Enter should navigate. | |
| 1253 | + | if _, _, found := url.ParseBangShortcut(entryText); !found { | |
| 1254 | + | if idx >= 0 && idx < len(bangSuggestions) { | |
| 1255 | + | o.entry.SetText("!" + bangSuggestions[idx].Key + " ") | |
| 1256 | + | o.entry.SetPosition(-1) | |
| 1257 | + | return | |
| 1258 | + | } | |
| 1259 | + | } | |
| 1260 | + | } | |
| 1261 | + | ||
| 1043 | 1262 | var targetURL string | |
| 1044 | 1263 | ||
| 1045 | 1264 | // Check if user typed a URL-like string (contains . and no spaces) | |
| @@ -1330,6 +1549,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 1330 | 1549 | o.outerBox.SetVisible(false) | |
| 1331 | 1550 | ||
| 1332 | 1551 | // Clear state | |
| 1552 | + | o.clearBangState() | |
| 1333 | 1553 | o.entry.SetText("") | |
| 1334 | 1554 | o.listBox.RemoveAll() | |
| 1335 | 1555 | ||
| @@ -3,6 +3,7 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "sort" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -22,6 +23,7 @@ import ( | |||||
| 22 | 23 | ||||
| 23 | const ( | 24 | const ( | ||
| 24 | debounceDelayMs = 150 | 25 | debounceDelayMs = 150 | ||
| 26 | + | endBoxSpacing = 6 | |||
| 25 | ) | 27 | ) | ||
| 26 | 28 | ||||
| 27 | // ViewMode distinguishes history search from favorites display. | 29 | // ViewMode distinguishes history search from favorites display. | ||
| @@ -40,6 +42,13 @@ type Suggestion struct { | |||||
| 40 | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | 42 | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | ||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| 45 | + | // BangSuggestion represents a configured bang shortcut. | |||
| 46 | + | // A bang is invoked by typing "!<key>". | |||
| 47 | + | type BangSuggestion struct { | |||
| 48 | + | Key string | |||
| 49 | + | Description string | |||
| 50 | + | } | |||
| 51 | + | ||||
| 43 | // Favorite represents a bookmarked URL. | 52 | // Favorite represents a bookmarked URL. | ||
| 44 | type Favorite struct { | 53 | type Favorite struct { | ||
| 45 | ID int64 | 54 | ID int64 | ||
| @@ -58,6 +67,7 @@ type Omnibox struct { | |||||
| 58 | historyBtn *gtk.Button | 67 | historyBtn *gtk.Button | ||
| 59 | favoritesBtn *gtk.Button | 68 | favoritesBtn *gtk.Button | ||
| 60 | zoomLabel *gtk.Label | 69 | zoomLabel *gtk.Label | ||
| 70 | + | bangBadge *gtk.Label | |||
| 61 | entry *gtk.SearchEntry | 71 | entry *gtk.SearchEntry | ||
| 62 | scrolledWin *gtk.ScrolledWindow | 72 | scrolledWin *gtk.ScrolledWindow | ||
| 63 | listBox *gtk.ListBox | 73 | listBox *gtk.ListBox | ||
| @@ -66,13 +76,16 @@ type Omnibox struct { | |||||
| 66 | parentOverlay layout.OverlayWidget | 76 | parentOverlay layout.OverlayWidget | ||
| 67 | 77 | ||||
| 68 | // State | 78 | // State | ||
| 69 | - | mu sync.RWMutex | 79 | + | mu sync.RWMutex |
| 70 | - | visible bool | 80 | + | visible bool |
| 71 | - | viewMode ViewMode | 81 | + | viewMode ViewMode |
| 72 | - | selectedIndex int | 82 | + | selectedIndex int |
| 73 | - | suggestions []Suggestion | 83 | + | suggestions []Suggestion |
| 74 | - | favorites []Favorite | 84 | + | favorites []Favorite |
| 75 | - | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | 85 | + | bangSuggestions []BangSuggestion |
| 86 | + | bangMode bool | |||
| 87 | + | detectedBang string | |||
| 88 | + | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |||
| 76 | 89 | ||||
| 77 | // Dependencies | 90 | // Dependencies | ||
| 78 | historyUC *usecase.SearchHistoryUseCase | 91 | historyUC *usecase.SearchHistoryUseCase | ||
| @@ -351,12 +364,16 @@ func (o *Omnibox) initHeader() error { | |||||
| 351 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | 364 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | ||
| 352 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | 365 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | ||
| 353 | 366 | ||||
| 367 | + | o.bangBadge = gtk.NewLabel(nil) | |||
| 368 | + | if o.bangBadge != nil { | |||
| 369 | + | o.bangBadge.AddCssClass("omnibox-bang-badge") | |||
| 370 | + | o.bangBadge.SetVisible(false) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 354 | o.zoomLabel = gtk.NewLabel(nil) | 373 | o.zoomLabel = gtk.NewLabel(nil) | ||
| 355 | if o.zoomLabel != nil { | 374 | if o.zoomLabel != nil { | ||
| 356 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | 375 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | ||
| 357 | o.zoomLabel.SetVisible(false) | 376 | o.zoomLabel.SetVisible(false) | ||
| 358 | - | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |||
| 359 | - | o.zoomLabel.SetHexpand(true) | |||
| 360 | } | 377 | } | ||
| 361 | return nil | 378 | return nil | ||
| 362 | } | 379 | } | ||
| @@ -369,7 +386,7 @@ func (o *Omnibox) initEntry() error { | |||||
| 369 | o.entry.AddCssClass("omnibox-entry") | 386 | o.entry.AddCssClass("omnibox-entry") | ||
| 370 | o.entry.SetHexpand(true) | 387 | o.entry.SetHexpand(true) | ||
| 371 | 388 | ||||
| 372 | - | placeholder := "Search history or enter URL..." | 389 | + | placeholder := "Search history or enter URL… (! lists bangs)" |
| 373 | o.entry.SetPlaceholderText(&placeholder) | 390 | o.entry.SetPlaceholderText(&placeholder) | ||
| 374 | return nil | 391 | return nil | ||
| 375 | } | 392 | } | ||
| @@ -413,8 +430,18 @@ func (o *Omnibox) initList() error { | |||||
| 413 | func (o *Omnibox) assembleWidgets() { | 430 | func (o *Omnibox) assembleWidgets() { | ||
| 414 | o.headerBox.Append(&o.historyBtn.Widget) | 431 | o.headerBox.Append(&o.historyBtn.Widget) | ||
| 415 | o.headerBox.Append(&o.favoritesBtn.Widget) | 432 | o.headerBox.Append(&o.favoritesBtn.Widget) | ||
| 416 | - | if o.zoomLabel != nil { | 433 | + | |
| 417 | - | o.headerBox.Append(&o.zoomLabel.Widget) | 434 | + | endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing) |
| 435 | + | if endBox != nil { | |||
| 436 | + | endBox.SetHexpand(true) | |||
| 437 | + | endBox.SetHalign(gtk.AlignEndValue) | |||
| 438 | + | if o.bangBadge != nil { | |||
| 439 | + | endBox.Append(&o.bangBadge.Widget) | |||
| 440 | + | } | |||
| 441 | + | if o.zoomLabel != nil { | |||
| 442 | + | endBox.Append(&o.zoomLabel.Widget) | |||
| 443 | + | } | |||
| 444 | + | o.headerBox.Append(&endBox.Widget) | |||
| 418 | } | 445 | } | ||
| 419 | 446 | ||||
| 420 | o.scrolledWin.SetChild(&o.listBox.Widget) | 447 | o.scrolledWin.SetChild(&o.listBox.Widget) | ||
| @@ -578,6 +605,14 @@ func (o *Omnibox) performSearch() { | |||||
| 578 | o.lastQuery = query | 605 | o.lastQuery = query | ||
| 579 | o.debounceMu.Unlock() | 606 | o.debounceMu.Unlock() | ||
| 580 | 607 | ||||
| 608 | + | if strings.HasPrefix(query, "!") { | |||
| 609 | + | o.updateBangDetection(query) | |||
| 610 | + | o.loadBangSuggestions(query) | |||
| 611 | + | return | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | o.clearBangState() | |||
| 615 | + | ||||
| 581 | if mode == ViewModeFavorites { | 616 | if mode == ViewModeFavorites { | ||
| 582 | o.loadFavorites(query) | 617 | o.loadFavorites(query) | ||
| 583 | return | 618 | return | ||
| @@ -745,9 +780,142 @@ func (o *Omnibox) loadFavorites(query string) { | |||||
| 745 | }() | 780 | }() | ||
| 746 | } | 781 | } | ||
| 747 | 782 | ||||
| 783 | + | func buildBangSuggestions(shortcuts map[string]config.SearchShortcut, query string) []BangSuggestion { | |||
| 784 | + | prefix := strings.TrimPrefix(query, "!") | |||
| 785 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |||
| 786 | + | prefix = prefix[:idx] | |||
| 787 | + | } | |||
| 788 | + | prefix = strings.ToLower(prefix) | |||
| 789 | + | ||||
| 790 | + | suggestions := make([]BangSuggestion, 0, len(shortcuts)) | |||
| 791 | + | for key, shortcut := range shortcuts { | |||
| 792 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |||
| 793 | + | continue | |||
| 794 | + | } | |||
| 795 | + | description := shortcut.Description | |||
| 796 | + | if description == "" { | |||
| 797 | + | description = shortcut.URL | |||
| 798 | + | } | |||
| 799 | + | suggestions = append(suggestions, BangSuggestion{ | |||
| 800 | + | Key: key, | |||
| 801 | + | Description: description, | |||
| 802 | + | }) | |||
| 803 | + | } | |||
| 804 | + | ||||
| 805 | + | sort.Slice(suggestions, func(i, j int) bool { | |||
| 806 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |||
| 807 | + | }) | |||
| 808 | + | ||||
| 809 | + | return suggestions | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | func (o *Omnibox) loadBangSuggestions(query string) { | |||
| 813 | + | suggestions := buildBangSuggestions(o.shortcuts, query) | |||
| 814 | + | o.idleAddUpdateBangSuggestions(suggestions) | |||
| 815 | + | } | |||
| 816 | + | ||||
| 817 | + | func (o *Omnibox) updateBangDetection(query string) { | |||
| 818 | + | spaceIdx := strings.Index(query, " ") | |||
| 819 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |||
| 820 | + | o.clearDetectedBang() | |||
| 821 | + | return | |||
| 822 | + | } | |||
| 823 | + | ||||
| 824 | + | candidate := query[1:spaceIdx] | |||
| 825 | + | for key := range o.shortcuts { | |||
| 826 | + | if strings.EqualFold(key, candidate) { | |||
| 827 | + | o.setDetectedBang(key) | |||
| 828 | + | return | |||
| 829 | + | } | |||
| 830 | + | } | |||
| 831 | + | ||||
| 832 | + | o.clearDetectedBang() | |||
| 833 | + | } | |||
| 834 | + | ||||
| 835 | + | func (o *Omnibox) clearBangState() { | |||
| 836 | + | o.mu.Lock() | |||
| 837 | + | o.bangMode = false | |||
| 838 | + | o.bangSuggestions = nil | |||
| 839 | + | o.mu.Unlock() | |||
| 840 | + | ||||
| 841 | + | o.clearDetectedBang() | |||
| 842 | + | } | |||
| 843 | + | ||||
| 844 | + | func (o *Omnibox) setDetectedBang(key string) { | |||
| 845 | + | o.mu.Lock() | |||
| 846 | + | o.detectedBang = key | |||
| 847 | + | o.mu.Unlock() | |||
| 848 | + | ||||
| 849 | + | if o.bangBadge == nil { | |||
| 850 | + | return | |||
| 851 | + | } | |||
| 852 | + | shortcut, ok := o.shortcuts[key] | |||
| 853 | + | if !ok { | |||
| 854 | + | return | |||
| 855 | + | } | |||
| 856 | + | description := shortcut.Description | |||
| 857 | + | if description == "" { | |||
| 858 | + | description = shortcut.URL | |||
| 859 | + | } | |||
| 860 | + | label := description | |||
| 861 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 862 | + | o.entry.AddCssClass("omnibox-entry-bang-active") | |||
| 863 | + | o.bangBadge.SetText(label) | |||
| 864 | + | o.bangBadge.SetVisible(true) | |||
| 865 | + | return false | |||
| 866 | + | } | |||
| 867 | + | glib.IdleAdd(&cb, 0) | |||
| 868 | + | } | |||
| 869 | + | ||||
| 870 | + | func (o *Omnibox) clearDetectedBang() { | |||
| 871 | + | o.mu.Lock() | |||
| 872 | + | o.detectedBang = "" | |||
| 873 | + | o.mu.Unlock() | |||
| 874 | + | ||||
| 875 | + | if o.bangBadge == nil { | |||
| 876 | + | return | |||
| 877 | + | } | |||
| 878 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 879 | + | o.entry.RemoveCssClass("omnibox-entry-bang-active") | |||
| 880 | + | o.bangBadge.SetVisible(false) | |||
| 881 | + | return false | |||
| 882 | + | } | |||
| 883 | + | glib.IdleAdd(&cb, 0) | |||
| 884 | + | } | |||
| 885 | + | ||||
| 886 | + | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) { | |||
| 887 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 888 | + | o.updateBangSuggestions(suggestions) | |||
| 889 | + | return false | |||
| 890 | + | } | |||
| 891 | + | glib.IdleAdd(&cb, 0) | |||
| 892 | + | } | |||
| 893 | + | ||||
| 894 | + | func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 895 | + | o.mu.Lock() | |||
| 896 | + | o.bangMode = true | |||
| 897 | + | o.bangSuggestions = suggestions | |||
| 898 | + | o.selectedIndex = -1 | |||
| 899 | + | o.mu.Unlock() | |||
| 900 | + | ||||
| 901 | + | o.rebuildList() | |||
| 902 | + | ||||
| 903 | + | rowCount := len(suggestions) | |||
| 904 | + | if o.scrolledWin != nil { | |||
| 905 | + | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 906 | + | } | |||
| 907 | + | o.resizeAndCenter(rowCount) | |||
| 908 | + | ||||
| 909 | + | if rowCount > 0 { | |||
| 910 | + | o.selectIndex(0) | |||
| 911 | + | } | |||
| 912 | + | } | |||
| 913 | + | ||||
| 748 | // updateSuggestions updates the list with history suggestions. | 914 | // updateSuggestions updates the list with history suggestions. | ||
| 749 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | 915 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | ||
| 750 | o.mu.Lock() | 916 | o.mu.Lock() | ||
| 917 | + | o.bangMode = false | |||
| 918 | + | o.bangSuggestions = nil | |||
| 751 | o.suggestions = suggestions | 919 | o.suggestions = suggestions | ||
| 752 | o.selectedIndex = -1 | 920 | o.selectedIndex = -1 | ||
| 753 | o.mu.Unlock() | 921 | o.mu.Unlock() | ||
| @@ -770,6 +938,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||||
| 770 | // updateFavorites updates the list with favorites. | 938 | // updateFavorites updates the list with favorites. | ||
| 771 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | 939 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | ||
| 772 | o.mu.Lock() | 940 | o.mu.Lock() | ||
| 941 | + | o.bangMode = false | |||
| 942 | + | o.bangSuggestions = nil | |||
| 773 | o.favorites = favorites | 943 | o.favorites = favorites | ||
| 774 | o.selectedIndex = -1 | 944 | o.selectedIndex = -1 | ||
| 775 | o.mu.Unlock() | 945 | o.mu.Unlock() | ||
| @@ -798,9 +968,18 @@ func (o *Omnibox) rebuildList() { | |||||
| 798 | mode := o.viewMode | 968 | mode := o.viewMode | ||
| 799 | suggestions := o.suggestions | 969 | suggestions := o.suggestions | ||
| 800 | favorites := o.favorites | 970 | favorites := o.favorites | ||
| 971 | + | bangMode := o.bangMode | |||
| 972 | + | bangSuggestions := o.bangSuggestions | |||
| 801 | o.mu.RUnlock() | 973 | o.mu.RUnlock() | ||
| 802 | 974 | ||||
| 803 | - | if mode == ViewModeHistory { | 975 | + | if bangMode { |
| 976 | + | for i, b := range bangSuggestions { | |||
| 977 | + | row := o.createBangRow(b, i) | |||
| 978 | + | if row != nil { | |||
| 979 | + | o.listBox.Append(&row.Widget) | |||
| 980 | + | } | |||
| 981 | + | } | |||
| 982 | + | } else if mode == ViewModeHistory { | |||
| 804 | for i, s := range suggestions { | 983 | for i, s := range suggestions { | ||
| 805 | row := o.createSuggestionRow(s, i) | 984 | row := o.createSuggestionRow(s, i) | ||
| 806 | if row != nil { | 985 | if row != nil { | ||
| @@ -831,7 +1010,9 @@ func (o *Omnibox) rebuildList() { | |||||
| 831 | // Re-trigger resize with accurate measurements | 1010 | // Re-trigger resize with accurate measurements | ||
| 832 | o.mu.RLock() | 1011 | o.mu.RLock() | ||
| 833 | var count int | 1012 | var count int | ||
| 834 | - | if o.viewMode == ViewModeHistory { | 1013 | + | if o.bangMode { |
| 1014 | + | count = len(o.bangSuggestions) | |||
| 1015 | + | } else if o.viewMode == ViewModeHistory { | |||
| 835 | count = len(o.suggestions) | 1016 | count = len(o.suggestions) | ||
| 836 | } else { | 1017 | } else { | ||
| 837 | count = len(o.favorites) | 1018 | count = len(o.favorites) | ||
| @@ -962,6 +1143,14 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||||
| 962 | return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index) | 1143 | return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index) | ||
| 963 | } | 1144 | } | ||
| 964 | 1145 | ||||
| 1146 | + | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |||
| 1147 | + | row := o.createRowWithFavicon(b.Description, "!"+b.Key, "system-search-symbolic", index) | |||
| 1148 | + | if row != nil { | |||
| 1149 | + | row.AddCssClass("omnibox-row-bang") | |||
| 1150 | + | } | |||
| 1151 | + | return row | |||
| 1152 | + | } | |||
| 1153 | + | ||||
| 965 | // selectIndex selects a row by index. | 1154 | // selectIndex selects a row by index. | ||
| 966 | func (o *Omnibox) selectIndex(index int) { | 1155 | func (o *Omnibox) selectIndex(index int) { | ||
| 967 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | 1156 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | ||
| @@ -978,8 +1167,11 @@ func (o *Omnibox) selectNext() { | |||||
| 978 | o.mu.Lock() | 1167 | o.mu.Lock() | ||
| 979 | current := o.selectedIndex | 1168 | current := o.selectedIndex | ||
| 980 | mode := o.viewMode | 1169 | mode := o.viewMode | ||
| 1170 | + | bangMode := o.bangMode | |||
| 981 | var maxIndex int | 1171 | var maxIndex int | ||
| 982 | - | if mode == ViewModeHistory { | 1172 | + | if bangMode { |
| 1173 | + | maxIndex = len(o.bangSuggestions) - 1 | |||
| 1174 | + | } else if mode == ViewModeHistory { | |||
| 983 | maxIndex = len(o.suggestions) - 1 | 1175 | maxIndex = len(o.suggestions) - 1 | ||
| 984 | } else { | 1176 | } else { | ||
| 985 | maxIndex = len(o.favorites) - 1 | 1177 | maxIndex = len(o.favorites) - 1 | ||
| @@ -1003,8 +1195,11 @@ func (o *Omnibox) selectPrevious() { | |||||
| 1003 | o.mu.Lock() | 1195 | o.mu.Lock() | ||
| 1004 | current := o.selectedIndex | 1196 | current := o.selectedIndex | ||
| 1005 | mode := o.viewMode | 1197 | mode := o.viewMode | ||
| 1198 | + | bangMode := o.bangMode | |||
| 1006 | var maxIndex int | 1199 | var maxIndex int | ||
| 1007 | - | if mode == ViewModeHistory { | 1200 | + | if bangMode { |
| 1201 | + | maxIndex = len(o.bangSuggestions) - 1 | |||
| 1202 | + | } else if mode == ViewModeHistory { | |||
| 1008 | maxIndex = len(o.suggestions) - 1 | 1203 | maxIndex = len(o.suggestions) - 1 | ||
| 1009 | } else { | 1204 | } else { | ||
| 1010 | maxIndex = len(o.favorites) - 1 | 1205 | maxIndex = len(o.favorites) - 1 | ||
| @@ -1037,9 +1232,33 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1037 | idx := o.selectedIndex | 1232 | idx := o.selectedIndex | ||
| 1038 | suggestions := o.suggestions | 1233 | suggestions := o.suggestions | ||
| 1039 | favorites := o.favorites | 1234 | favorites := o.favorites | ||
| 1235 | + | bangMode := o.bangMode | |||
| 1236 | + | bangSuggestions := o.bangSuggestions | |||
| 1040 | o.mu.RUnlock() | 1237 | o.mu.RUnlock() | ||
| 1041 | 1238 | ||||
| 1042 | entryText := o.entry.GetText() | 1239 | entryText := o.entry.GetText() | ||
| 1240 | + | if shortcutKey, query, found := url.ParseBangShortcut(entryText); found { | |||
| 1241 | + | resolvedKey := shortcutKey | |||
| 1242 | + | for key := range o.shortcuts { | |||
| 1243 | + | if strings.EqualFold(key, shortcutKey) { | |||
| 1244 | + | resolvedKey = key | |||
| 1245 | + | break | |||
| 1246 | + | } | |||
| 1247 | + | } | |||
| 1248 | + | entryText = "!" + resolvedKey + " " + query | |||
| 1249 | + | } | |||
| 1250 | + | ||||
| 1251 | + | if bangMode { | |||
| 1252 | + | // If the user already typed a bang query ("!key something"), Enter should navigate. | |||
| 1253 | + | if _, _, found := url.ParseBangShortcut(entryText); !found { | |||
| 1254 | + | if idx >= 0 && idx < len(bangSuggestions) { | |||
| 1255 | + | o.entry.SetText("!" + bangSuggestions[idx].Key + " ") | |||
| 1256 | + | o.entry.SetPosition(-1) | |||
| 1257 | + | return | |||
| 1258 | + | } | |||
| 1259 | + | } | |||
| 1260 | + | } | |||
| 1261 | + | ||||
| 1043 | var targetURL string | 1262 | var targetURL string | ||
| 1044 | 1263 | ||||
| 1045 | // Check if user typed a URL-like string (contains . and no spaces) | 1264 | // Check if user typed a URL-like string (contains . and no spaces) | ||
| @@ -1330,6 +1549,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 1330 | o.outerBox.SetVisible(false) | 1549 | o.outerBox.SetVisible(false) | ||
| 1331 | 1550 | ||||
| 1332 | // Clear state | 1551 | // Clear state | ||
| 1552 | + | o.clearBangState() | |||
| 1333 | o.entry.SetText("") | 1553 | o.entry.SetText("") | ||
| 1334 | o.listBox.RemoveAll() | 1554 | o.listBox.RemoveAll() | ||
| 1335 | 1555 | ||||
A internal/ui/component/omnibox_bang_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestBuildBangSuggestions(t *testing.T) { | |
| 10 | + | shortcuts := map[string]config.SearchShortcut{ | |
| 11 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | |
| 12 | + | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | |
| 13 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 14 | + | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | cases := []struct { | |
| 18 | + | name string | |
| 19 | + | query string | |
| 20 | + | wantKeys []string | |
| 21 | + | wantDescr map[string]string | |
| 22 | + | }{ | |
| 23 | + | { | |
| 24 | + | name: "bang only returns all sorted", | |
| 25 | + | query: "!", | |
| 26 | + | wantKeys: []string{"ddg", "g", "gh", "n"}, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "filters by prefix", | |
| 30 | + | query: "!g", | |
| 31 | + | wantKeys: []string{"g", "gh"}, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "filters by prefix case-insensitive", | |
| 35 | + | query: "!DD", | |
| 36 | + | wantKeys: []string{"ddg"}, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "stops prefix at space", | |
| 40 | + | query: "!g query", | |
| 41 | + | wantKeys: []string{"g", "gh"}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "falls back to url when description empty", | |
| 45 | + | query: "!n", | |
| 46 | + | wantKeys: []string{"n"}, | |
| 47 | + | wantDescr: map[string]string{ | |
| 48 | + | "n": "https://news.ycombinator.com/", | |
| 49 | + | }, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tc := range cases { | |
| 54 | + | t.Run(tc.name, func(t *testing.T) { | |
| 55 | + | got := buildBangSuggestions(shortcuts, tc.query) | |
| 56 | + | if len(got) != len(tc.wantKeys) { | |
| 57 | + | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | |
| 58 | + | } | |
| 59 | + | for i, wantKey := range tc.wantKeys { | |
| 60 | + | if got[i].Key != wantKey { | |
| 61 | + | t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey) | |
| 62 | + | } | |
| 63 | + | if tc.wantDescr != nil { | |
| 64 | + | if wantD, ok := tc.wantDescr[wantKey]; ok { | |
| 65 | + | if got[i].Description != wantD { | |
| 66 | + | t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestBuildBangSuggestions(t *testing.T) { | |||
| 10 | + | shortcuts := map[string]config.SearchShortcut{ | |||
| 11 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | |||
| 12 | + | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | |||
| 13 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |||
| 14 | + | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | cases := []struct { | |||
| 18 | + | name string | |||
| 19 | + | query string | |||
| 20 | + | wantKeys []string | |||
| 21 | + | wantDescr map[string]string | |||
| 22 | + | }{ | |||
| 23 | + | { | |||
| 24 | + | name: "bang only returns all sorted", | |||
| 25 | + | query: "!", | |||
| 26 | + | wantKeys: []string{"ddg", "g", "gh", "n"}, | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "filters by prefix", | |||
| 30 | + | query: "!g", | |||
| 31 | + | wantKeys: []string{"g", "gh"}, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "filters by prefix case-insensitive", | |||
| 35 | + | query: "!DD", | |||
| 36 | + | wantKeys: []string{"ddg"}, | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "stops prefix at space", | |||
| 40 | + | query: "!g query", | |||
| 41 | + | wantKeys: []string{"g", "gh"}, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "falls back to url when description empty", | |||
| 45 | + | query: "!n", | |||
| 46 | + | wantKeys: []string{"n"}, | |||
| 47 | + | wantDescr: map[string]string{ | |||
| 48 | + | "n": "https://news.ycombinator.com/", | |||
| 49 | + | }, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tc := range cases { | |||
| 54 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 55 | + | got := buildBangSuggestions(shortcuts, tc.query) | |||
| 56 | + | if len(got) != len(tc.wantKeys) { | |||
| 57 | + | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | |||
| 58 | + | } | |||
| 59 | + | for i, wantKey := range tc.wantKeys { | |||
| 60 | + | if got[i].Key != wantKey { | |||
| 61 | + | t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey) | |||
| 62 | + | } | |||
| 63 | + | if tc.wantDescr != nil { | |||
| 64 | + | if wantD, ok := tc.wantDescr[wantKey]; ok { | |||
| 65 | + | if got[i].Description != wantD { | |||
| 66 | + | t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/ui/theme/css.go +23 -0
| @@ -372,6 +372,16 @@ entry.omnibox-entry > *:focus-visible { | |||
| 372 | 372 | color: var(--accent); | |
| 373 | 373 | } | |
| 374 | 374 | ||
| 375 | + | /* Bang indicator badge in omnibox header */ | |
| 376 | + | .omnibox-bang-badge { | |
| 377 | + | background-color: alpha(var(--accent), 0.18); | |
| 378 | + | color: var(--accent); | |
| 379 | + | border-radius: 0.25em; | |
| 380 | + | padding: 0.125em 0.5em; | |
| 381 | + | font-size: 0.6875em; | |
| 382 | + | font-weight: 600; | |
| 383 | + | } | |
| 384 | + | ||
| 375 | 385 | /* Zoom indicator in omnibox header */ | |
| 376 | 386 | .omnibox-zoom-indicator { | |
| 377 | 387 | background-color: alpha(var(--accent), 0.2); | |
| @@ -382,6 +392,19 @@ entry.omnibox-entry > *:focus-visible { | |||
| 382 | 392 | font-weight: 600; | |
| 383 | 393 | /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */ | |
| 384 | 394 | } | |
| 395 | + | ||
| 396 | + | entry.omnibox-entry.omnibox-entry-bang-active, | |
| 397 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus, | |
| 398 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-within, | |
| 399 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |
| 400 | + | border-color: var(--accent); | |
| 401 | + | background-color: shade(var(--bg), 1.05); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | .omnibox-row.omnibox-row-bang .omnibox-suggestion-title { | |
| 405 | + | color: var(--accent); | |
| 406 | + | font-weight: 600; | |
| 407 | + | } | |
| 385 | 408 | ` | |
| 386 | 409 | } | |
| 387 | 410 | ||
| @@ -372,6 +372,16 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 372 | color: var(--accent); | 372 | color: var(--accent); | ||
| 373 | } | 373 | } | ||
| 374 | 374 | ||||
| 375 | + | /* Bang indicator badge in omnibox header */ | |||
| 376 | + | .omnibox-bang-badge { | |||
| 377 | + | background-color: alpha(var(--accent), 0.18); | |||
| 378 | + | color: var(--accent); | |||
| 379 | + | border-radius: 0.25em; | |||
| 380 | + | padding: 0.125em 0.5em; | |||
| 381 | + | font-size: 0.6875em; | |||
| 382 | + | font-weight: 600; | |||
| 383 | + | } | |||
| 384 | + | ||||
| 375 | /* Zoom indicator in omnibox header */ | 385 | /* Zoom indicator in omnibox header */ | ||
| 376 | .omnibox-zoom-indicator { | 386 | .omnibox-zoom-indicator { | ||
| 377 | background-color: alpha(var(--accent), 0.2); | 387 | background-color: alpha(var(--accent), 0.2); | ||
| @@ -382,6 +392,19 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 382 | font-weight: 600; | 392 | font-weight: 600; | ||
| 383 | /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */ | 393 | /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */ | ||
| 384 | } | 394 | } | ||
| 395 | + | ||||
| 396 | + | entry.omnibox-entry.omnibox-entry-bang-active, | |||
| 397 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus, | |||
| 398 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-within, | |||
| 399 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |||
| 400 | + | border-color: var(--accent); | |||
| 401 | + | background-color: shade(var(--bg), 1.05); | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | .omnibox-row.omnibox-row-bang .omnibox-suggestion-title { | |||
| 405 | + | color: var(--accent); | |||
| 406 | + | font-weight: 600; | |||
| 407 | + | } | |||
| 385 | ` | 408 | ` | ||
| 386 | } | 409 | } | ||
| 387 | 410 | ||||