Merge pull request #55 from bnema/feature/omnibox-bangs
feat(omnibox): bang shortcuts UI
6 files · +661 -34
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. | ||
A internal/application/usecase/search_shortcuts.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/url" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SearchShortcut represents a search shortcut configuration. | |
| 13 | + | // This is a domain-level type to avoid depending on infrastructure config. | |
| 14 | + | type SearchShortcut struct { | |
| 15 | + | URL string | |
| 16 | + | Description string | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // BangSuggestion represents a configured bang shortcut for display. | |
| 20 | + | type BangSuggestion struct { | |
| 21 | + | Key string | |
| 22 | + | Description string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // SearchShortcutsUseCase handles bang shortcut filtering and resolution. | |
| 26 | + | type SearchShortcutsUseCase struct { | |
| 27 | + | shortcuts map[string]SearchShortcut | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // NewSearchShortcutsUseCase creates a new search shortcuts use case. | |
| 31 | + | func NewSearchShortcutsUseCase(shortcuts map[string]SearchShortcut) *SearchShortcutsUseCase { | |
| 32 | + | return &SearchShortcutsUseCase{ | |
| 33 | + | shortcuts: shortcuts, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // FilterBangsInput contains parameters for filtering bang suggestions. | |
| 38 | + | type FilterBangsInput struct { | |
| 39 | + | Query string // e.g., "!" or "!g" or "!g query" | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // FilterBangsOutput contains filtered bang suggestions. | |
| 43 | + | type FilterBangsOutput struct { | |
| 44 | + | Suggestions []BangSuggestion | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // FilterBangs returns bang shortcuts matching the query prefix. | |
| 48 | + | // Query should start with "!". The prefix is extracted before any space. | |
| 49 | + | func (uc *SearchShortcutsUseCase) FilterBangs(ctx context.Context, input FilterBangsInput) *FilterBangsOutput { | |
| 50 | + | log := logging.FromContext(ctx) | |
| 51 | + | ||
| 52 | + | suggestions := uc.buildBangSuggestions(input.Query) | |
| 53 | + | ||
| 54 | + | log.Debug(). | |
| 55 | + | Str("query", input.Query). | |
| 56 | + | Int("matches", len(suggestions)). | |
| 57 | + | Msg("filtered bang suggestions") | |
| 58 | + | ||
| 59 | + | return &FilterBangsOutput{Suggestions: suggestions} | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // DetectBangKeyInput contains parameters for detecting a completed bang key. | |
| 63 | + | type DetectBangKeyInput struct { | |
| 64 | + | Query string // e.g., "!gh query" | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // DetectBangKeyOutput contains the detected bang key if found. | |
| 68 | + | type DetectBangKeyOutput struct { | |
| 69 | + | Key string // The matched key (empty if not found) | |
| 70 | + | Description string // Description of the matched shortcut | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // DetectBangKey checks if the query contains a valid, completed bang key. | |
| 74 | + | // A completed bang key requires "!<key> " with a space after the key. | |
| 75 | + | func (uc *SearchShortcutsUseCase) DetectBangKey(ctx context.Context, input DetectBangKeyInput) *DetectBangKeyOutput { | |
| 76 | + | log := logging.FromContext(ctx) | |
| 77 | + | ||
| 78 | + | key := uc.detectBangKey(input.Query) | |
| 79 | + | if key == "" { | |
| 80 | + | return &DetectBangKeyOutput{} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | shortcut, ok := uc.shortcuts[key] | |
| 84 | + | if !ok { | |
| 85 | + | return &DetectBangKeyOutput{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | description := shortcut.Description | |
| 89 | + | if description == "" { | |
| 90 | + | description = shortcut.URL | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | log.Debug(). | |
| 94 | + | Str("query", input.Query). | |
| 95 | + | Str("detected_key", key). | |
| 96 | + | Msg("detected bang key") | |
| 97 | + | ||
| 98 | + | return &DetectBangKeyOutput{ | |
| 99 | + | Key: key, | |
| 100 | + | Description: description, | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // BuildNavigationTextInput contains parameters for building navigation text. | |
| 105 | + | type BuildNavigationTextInput struct { | |
| 106 | + | EntryText string // e.g., "!GH dumber" | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // BuildNavigationTextOutput contains the normalized navigation text. | |
| 110 | + | type BuildNavigationTextOutput struct { | |
| 111 | + | Text string // Normalized text (e.g., "!gh dumber") or empty if invalid | |
| 112 | + | Valid bool // Whether the entry text represents a valid bang navigation | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // BuildNavigationText normalizes a bang query for navigation. | |
| 116 | + | // Returns the normalized text with the canonical key case. | |
| 117 | + | func (uc *SearchShortcutsUseCase) BuildNavigationText(ctx context.Context, input BuildNavigationTextInput) *BuildNavigationTextOutput { | |
| 118 | + | log := logging.FromContext(ctx) | |
| 119 | + | ||
| 120 | + | text := uc.buildBangNavigationText(input.EntryText) | |
| 121 | + | if text == "" { | |
| 122 | + | return &BuildNavigationTextOutput{} | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | log.Debug(). | |
| 126 | + | Str("entry_text", input.EntryText). | |
| 127 | + | Str("navigation_text", text). | |
| 128 | + | Msg("built bang navigation text") | |
| 129 | + | ||
| 130 | + | return &BuildNavigationTextOutput{ | |
| 131 | + | Text: text, | |
| 132 | + | Valid: true, | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // GetShortcut returns a shortcut by key (case-insensitive). | |
| 137 | + | func (uc *SearchShortcutsUseCase) GetShortcut(key string) (SearchShortcut, bool) { | |
| 138 | + | normalizedKey, ok := uc.normalizeBangKey(key) | |
| 139 | + | if !ok { | |
| 140 | + | return SearchShortcut{}, false | |
| 141 | + | } | |
| 142 | + | shortcut, ok := uc.shortcuts[normalizedKey] | |
| 143 | + | return shortcut, ok | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // ShortcutURLs returns a map of shortcut keys to URL templates. | |
| 147 | + | // This is useful for passing to url.BuildSearchURL. | |
| 148 | + | func (uc *SearchShortcutsUseCase) ShortcutURLs() map[string]string { | |
| 149 | + | result := make(map[string]string, len(uc.shortcuts)) | |
| 150 | + | for key, shortcut := range uc.shortcuts { | |
| 151 | + | result[key] = shortcut.URL | |
| 152 | + | } | |
| 153 | + | return result | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // buildBangSuggestions filters and sorts shortcuts matching the query prefix. | |
| 157 | + | func (uc *SearchShortcutsUseCase) buildBangSuggestions(query string) []BangSuggestion { | |
| 158 | + | prefix := strings.TrimPrefix(query, "!") | |
| 159 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |
| 160 | + | prefix = prefix[:idx] | |
| 161 | + | } | |
| 162 | + | prefix = strings.ToLower(prefix) | |
| 163 | + | ||
| 164 | + | suggestions := make([]BangSuggestion, 0, len(uc.shortcuts)) | |
| 165 | + | for key, shortcut := range uc.shortcuts { | |
| 166 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |
| 167 | + | continue | |
| 168 | + | } | |
| 169 | + | description := shortcut.Description | |
| 170 | + | if description == "" { | |
| 171 | + | description = shortcut.URL | |
| 172 | + | } | |
| 173 | + | suggestions = append(suggestions, BangSuggestion{ | |
| 174 | + | Key: key, | |
| 175 | + | Description: description, | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | sort.Slice(suggestions, func(i, j int) bool { | |
| 180 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |
| 181 | + | }) | |
| 182 | + | ||
| 183 | + | return suggestions | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // detectBangKey returns the matched shortcut key if the query has a valid bang prefix. | |
| 187 | + | func (uc *SearchShortcutsUseCase) detectBangKey(query string) string { | |
| 188 | + | spaceIdx := strings.Index(query, " ") | |
| 189 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |
| 190 | + | return "" | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | candidate := query[1:spaceIdx] | |
| 194 | + | for key := range uc.shortcuts { | |
| 195 | + | if strings.EqualFold(key, candidate) { | |
| 196 | + | return key | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return "" | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // normalizeBangKey returns the canonical key for a shortcut (case-insensitive lookup). | |
| 204 | + | func (uc *SearchShortcutsUseCase) normalizeBangKey(shortcutKey string) (string, bool) { | |
| 205 | + | for key := range uc.shortcuts { | |
| 206 | + | if strings.EqualFold(key, shortcutKey) { | |
| 207 | + | return key, true | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return "", false | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // buildBangNavigationText normalizes a bang query for navigation. | |
| 214 | + | func (uc *SearchShortcutsUseCase) buildBangNavigationText(entryText string) string { | |
| 215 | + | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |
| 216 | + | if !found || query == "" { | |
| 217 | + | return "" | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | resolvedKey, ok := uc.normalizeBangKey(shortcutKey) | |
| 221 | + | if !ok { | |
| 222 | + | return "" | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return "!" + resolvedKey + " " + query | |
| 226 | + | } | |
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/url" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SearchShortcut represents a search shortcut configuration. | |||
| 13 | + | // This is a domain-level type to avoid depending on infrastructure config. | |||
| 14 | + | type SearchShortcut struct { | |||
| 15 | + | URL string | |||
| 16 | + | Description string | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // BangSuggestion represents a configured bang shortcut for display. | |||
| 20 | + | type BangSuggestion struct { | |||
| 21 | + | Key string | |||
| 22 | + | Description string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // SearchShortcutsUseCase handles bang shortcut filtering and resolution. | |||
| 26 | + | type SearchShortcutsUseCase struct { | |||
| 27 | + | shortcuts map[string]SearchShortcut | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // NewSearchShortcutsUseCase creates a new search shortcuts use case. | |||
| 31 | + | func NewSearchShortcutsUseCase(shortcuts map[string]SearchShortcut) *SearchShortcutsUseCase { | |||
| 32 | + | return &SearchShortcutsUseCase{ | |||
| 33 | + | shortcuts: shortcuts, | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // FilterBangsInput contains parameters for filtering bang suggestions. | |||
| 38 | + | type FilterBangsInput struct { | |||
| 39 | + | Query string // e.g., "!" or "!g" or "!g query" | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // FilterBangsOutput contains filtered bang suggestions. | |||
| 43 | + | type FilterBangsOutput struct { | |||
| 44 | + | Suggestions []BangSuggestion | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // FilterBangs returns bang shortcuts matching the query prefix. | |||
| 48 | + | // Query should start with "!". The prefix is extracted before any space. | |||
| 49 | + | func (uc *SearchShortcutsUseCase) FilterBangs(ctx context.Context, input FilterBangsInput) *FilterBangsOutput { | |||
| 50 | + | log := logging.FromContext(ctx) | |||
| 51 | + | ||||
| 52 | + | suggestions := uc.buildBangSuggestions(input.Query) | |||
| 53 | + | ||||
| 54 | + | log.Debug(). | |||
| 55 | + | Str("query", input.Query). | |||
| 56 | + | Int("matches", len(suggestions)). | |||
| 57 | + | Msg("filtered bang suggestions") | |||
| 58 | + | ||||
| 59 | + | return &FilterBangsOutput{Suggestions: suggestions} | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // DetectBangKeyInput contains parameters for detecting a completed bang key. | |||
| 63 | + | type DetectBangKeyInput struct { | |||
| 64 | + | Query string // e.g., "!gh query" | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // DetectBangKeyOutput contains the detected bang key if found. | |||
| 68 | + | type DetectBangKeyOutput struct { | |||
| 69 | + | Key string // The matched key (empty if not found) | |||
| 70 | + | Description string // Description of the matched shortcut | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // DetectBangKey checks if the query contains a valid, completed bang key. | |||
| 74 | + | // A completed bang key requires "!<key> " with a space after the key. | |||
| 75 | + | func (uc *SearchShortcutsUseCase) DetectBangKey(ctx context.Context, input DetectBangKeyInput) *DetectBangKeyOutput { | |||
| 76 | + | log := logging.FromContext(ctx) | |||
| 77 | + | ||||
| 78 | + | key := uc.detectBangKey(input.Query) | |||
| 79 | + | if key == "" { | |||
| 80 | + | return &DetectBangKeyOutput{} | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | shortcut, ok := uc.shortcuts[key] | |||
| 84 | + | if !ok { | |||
| 85 | + | return &DetectBangKeyOutput{} | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | description := shortcut.Description | |||
| 89 | + | if description == "" { | |||
| 90 | + | description = shortcut.URL | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | log.Debug(). | |||
| 94 | + | Str("query", input.Query). | |||
| 95 | + | Str("detected_key", key). | |||
| 96 | + | Msg("detected bang key") | |||
| 97 | + | ||||
| 98 | + | return &DetectBangKeyOutput{ | |||
| 99 | + | Key: key, | |||
| 100 | + | Description: description, | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // BuildNavigationTextInput contains parameters for building navigation text. | |||
| 105 | + | type BuildNavigationTextInput struct { | |||
| 106 | + | EntryText string // e.g., "!GH dumber" | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // BuildNavigationTextOutput contains the normalized navigation text. | |||
| 110 | + | type BuildNavigationTextOutput struct { | |||
| 111 | + | Text string // Normalized text (e.g., "!gh dumber") or empty if invalid | |||
| 112 | + | Valid bool // Whether the entry text represents a valid bang navigation | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // BuildNavigationText normalizes a bang query for navigation. | |||
| 116 | + | // Returns the normalized text with the canonical key case. | |||
| 117 | + | func (uc *SearchShortcutsUseCase) BuildNavigationText(ctx context.Context, input BuildNavigationTextInput) *BuildNavigationTextOutput { | |||
| 118 | + | log := logging.FromContext(ctx) | |||
| 119 | + | ||||
| 120 | + | text := uc.buildBangNavigationText(input.EntryText) | |||
| 121 | + | if text == "" { | |||
| 122 | + | return &BuildNavigationTextOutput{} | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | log.Debug(). | |||
| 126 | + | Str("entry_text", input.EntryText). | |||
| 127 | + | Str("navigation_text", text). | |||
| 128 | + | Msg("built bang navigation text") | |||
| 129 | + | ||||
| 130 | + | return &BuildNavigationTextOutput{ | |||
| 131 | + | Text: text, | |||
| 132 | + | Valid: true, | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // GetShortcut returns a shortcut by key (case-insensitive). | |||
| 137 | + | func (uc *SearchShortcutsUseCase) GetShortcut(key string) (SearchShortcut, bool) { | |||
| 138 | + | normalizedKey, ok := uc.normalizeBangKey(key) | |||
| 139 | + | if !ok { | |||
| 140 | + | return SearchShortcut{}, false | |||
| 141 | + | } | |||
| 142 | + | shortcut, ok := uc.shortcuts[normalizedKey] | |||
| 143 | + | return shortcut, ok | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // ShortcutURLs returns a map of shortcut keys to URL templates. | |||
| 147 | + | // This is useful for passing to url.BuildSearchURL. | |||
| 148 | + | func (uc *SearchShortcutsUseCase) ShortcutURLs() map[string]string { | |||
| 149 | + | result := make(map[string]string, len(uc.shortcuts)) | |||
| 150 | + | for key, shortcut := range uc.shortcuts { | |||
| 151 | + | result[key] = shortcut.URL | |||
| 152 | + | } | |||
| 153 | + | return result | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // buildBangSuggestions filters and sorts shortcuts matching the query prefix. | |||
| 157 | + | func (uc *SearchShortcutsUseCase) buildBangSuggestions(query string) []BangSuggestion { | |||
| 158 | + | prefix := strings.TrimPrefix(query, "!") | |||
| 159 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |||
| 160 | + | prefix = prefix[:idx] | |||
| 161 | + | } | |||
| 162 | + | prefix = strings.ToLower(prefix) | |||
| 163 | + | ||||
| 164 | + | suggestions := make([]BangSuggestion, 0, len(uc.shortcuts)) | |||
| 165 | + | for key, shortcut := range uc.shortcuts { | |||
| 166 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |||
| 167 | + | continue | |||
| 168 | + | } | |||
| 169 | + | description := shortcut.Description | |||
| 170 | + | if description == "" { | |||
| 171 | + | description = shortcut.URL | |||
| 172 | + | } | |||
| 173 | + | suggestions = append(suggestions, BangSuggestion{ | |||
| 174 | + | Key: key, | |||
| 175 | + | Description: description, | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | sort.Slice(suggestions, func(i, j int) bool { | |||
| 180 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |||
| 181 | + | }) | |||
| 182 | + | ||||
| 183 | + | return suggestions | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // detectBangKey returns the matched shortcut key if the query has a valid bang prefix. | |||
| 187 | + | func (uc *SearchShortcutsUseCase) detectBangKey(query string) string { | |||
| 188 | + | spaceIdx := strings.Index(query, " ") | |||
| 189 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |||
| 190 | + | return "" | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | candidate := query[1:spaceIdx] | |||
| 194 | + | for key := range uc.shortcuts { | |||
| 195 | + | if strings.EqualFold(key, candidate) { | |||
| 196 | + | return key | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | return "" | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // normalizeBangKey returns the canonical key for a shortcut (case-insensitive lookup). | |||
| 204 | + | func (uc *SearchShortcutsUseCase) normalizeBangKey(shortcutKey string) (string, bool) { | |||
| 205 | + | for key := range uc.shortcuts { | |||
| 206 | + | if strings.EqualFold(key, shortcutKey) { | |||
| 207 | + | return key, true | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | return "", false | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // buildBangNavigationText normalizes a bang query for navigation. | |||
| 214 | + | func (uc *SearchShortcutsUseCase) buildBangNavigationText(entryText string) string { | |||
| 215 | + | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |||
| 216 | + | if !found || query == "" { | |||
| 217 | + | return "" | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | resolvedKey, ok := uc.normalizeBangKey(shortcutKey) | |||
| 221 | + | if !ok { | |||
| 222 | + | return "" | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | return "!" + resolvedKey + " " + query | |||
| 226 | + | } | |||
A internal/application/usecase/search_shortcuts_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestFilterBangs(t *testing.T) { | |
| 9 | + | shortcuts := map[string]SearchShortcut{ | |
| 10 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | |
| 11 | + | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | |
| 12 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 13 | + | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | |
| 14 | + | } | |
| 15 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 16 | + | ctx := context.Background() | |
| 17 | + | ||
| 18 | + | cases := []struct { | |
| 19 | + | name string | |
| 20 | + | query string | |
| 21 | + | wantKeys []string | |
| 22 | + | wantDescr map[string]string | |
| 23 | + | }{ | |
| 24 | + | { | |
| 25 | + | name: "bang only returns all sorted", | |
| 26 | + | query: "!", | |
| 27 | + | wantKeys: []string{"ddg", "g", "gh", "n"}, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "filters by prefix", | |
| 31 | + | query: "!g", | |
| 32 | + | wantKeys: []string{"g", "gh"}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "filters by prefix case-insensitive", | |
| 36 | + | query: "!DD", | |
| 37 | + | wantKeys: []string{"ddg"}, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | name: "stops prefix at space", | |
| 41 | + | query: "!g query", | |
| 42 | + | wantKeys: []string{"g", "gh"}, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "falls back to url when description empty", | |
| 46 | + | query: "!n", | |
| 47 | + | wantKeys: []string{"n"}, | |
| 48 | + | wantDescr: map[string]string{ | |
| 49 | + | "n": "https://news.ycombinator.com/", | |
| 50 | + | }, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | for _, tc := range cases { | |
| 55 | + | t.Run(tc.name, func(t *testing.T) { | |
| 56 | + | output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query}) | |
| 57 | + | got := output.Suggestions | |
| 58 | + | if len(got) != len(tc.wantKeys) { | |
| 59 | + | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | |
| 60 | + | } | |
| 61 | + | for i, wantKey := range tc.wantKeys { | |
| 62 | + | if got[i].Key != wantKey { | |
| 63 | + | t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey) | |
| 64 | + | } | |
| 65 | + | if tc.wantDescr != nil { | |
| 66 | + | if wantD, ok := tc.wantDescr[wantKey]; ok { | |
| 67 | + | if got[i].Description != wantD { | |
| 68 | + | t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestDetectBangKey(t *testing.T) { | |
| 78 | + | shortcuts := map[string]SearchShortcut{ | |
| 79 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 80 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: ""}, | |
| 81 | + | } | |
| 82 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 83 | + | ctx := context.Background() | |
| 84 | + | ||
| 85 | + | cases := []struct { | |
| 86 | + | name string | |
| 87 | + | query string | |
| 88 | + | wantKey string | |
| 89 | + | wantDescr string | |
| 90 | + | }{ | |
| 91 | + | {name: "no bang prefix", query: "gh test", wantKey: "", wantDescr: ""}, | |
| 92 | + | {name: "bang only has no space", query: "!gh", wantKey: "", wantDescr: ""}, | |
| 93 | + | {name: "space at position 1", query: "! test", wantKey: "", wantDescr: ""}, | |
| 94 | + | {name: "unknown bang", query: "!nope test", wantKey: "", wantDescr: ""}, | |
| 95 | + | {name: "case-insensitive match", query: "!GH test", wantKey: "gh", wantDescr: "GitHub search"}, | |
| 96 | + | {name: "valid bang key", query: "!ddg test", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"}, | |
| 97 | + | {name: "description fallback to URL", query: "!DDG query", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"}, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for _, tc := range cases { | |
| 101 | + | t.Run(tc.name, func(t *testing.T) { | |
| 102 | + | output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query}) | |
| 103 | + | if output.Key != tc.wantKey { | |
| 104 | + | t.Fatalf("DetectBangKey(%q).Key=%q want=%q", tc.query, output.Key, tc.wantKey) | |
| 105 | + | } | |
| 106 | + | if output.Description != tc.wantDescr { | |
| 107 | + | t.Fatalf("DetectBangKey(%q).Description=%q want=%q", tc.query, output.Description, tc.wantDescr) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestBuildNavigationText(t *testing.T) { | |
| 114 | + | shortcuts := map[string]SearchShortcut{ | |
| 115 | + | "gh": {URL: "https://github.com/search?q=%s"}, | |
| 116 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | |
| 117 | + | } | |
| 118 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 119 | + | ctx := context.Background() | |
| 120 | + | ||
| 121 | + | cases := []struct { | |
| 122 | + | name string | |
| 123 | + | entryText string | |
| 124 | + | want string | |
| 125 | + | wantValid bool | |
| 126 | + | }{ | |
| 127 | + | {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false}, | |
| 128 | + | {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false}, | |
| 129 | + | {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false}, | |
| 130 | + | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true}, | |
| 131 | + | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true}, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | for _, tc := range cases { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText}) | |
| 137 | + | if output.Text != tc.want { | |
| 138 | + | t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want) | |
| 139 | + | } | |
| 140 | + | if output.Valid != tc.wantValid { | |
| 141 | + | t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid) | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestGetShortcut(t *testing.T) { | |
| 148 | + | shortcuts := map[string]SearchShortcut{ | |
| 149 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |
| 150 | + | } | |
| 151 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 152 | + | ||
| 153 | + | cases := []struct { | |
| 154 | + | name string | |
| 155 | + | key string | |
| 156 | + | wantOK bool | |
| 157 | + | wantURL string | |
| 158 | + | }{ | |
| 159 | + | {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |
| 160 | + | {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |
| 161 | + | {name: "not found", key: "unknown", wantOK: false, wantURL: ""}, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | for _, tc := range cases { | |
| 165 | + | t.Run(tc.name, func(t *testing.T) { | |
| 166 | + | shortcut, ok := uc.GetShortcut(tc.key) | |
| 167 | + | if ok != tc.wantOK { | |
| 168 | + | t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK) | |
| 169 | + | } | |
| 170 | + | if shortcut.URL != tc.wantURL { | |
| 171 | + | t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL) | |
| 172 | + | } | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | } | |
| @@ -0,0 +1,175 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestFilterBangs(t *testing.T) { | |||
| 9 | + | shortcuts := map[string]SearchShortcut{ | |||
| 10 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | |||
| 11 | + | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | |||
| 12 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |||
| 13 | + | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | |||
| 14 | + | } | |||
| 15 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 16 | + | ctx := context.Background() | |||
| 17 | + | ||||
| 18 | + | cases := []struct { | |||
| 19 | + | name string | |||
| 20 | + | query string | |||
| 21 | + | wantKeys []string | |||
| 22 | + | wantDescr map[string]string | |||
| 23 | + | }{ | |||
| 24 | + | { | |||
| 25 | + | name: "bang only returns all sorted", | |||
| 26 | + | query: "!", | |||
| 27 | + | wantKeys: []string{"ddg", "g", "gh", "n"}, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "filters by prefix", | |||
| 31 | + | query: "!g", | |||
| 32 | + | wantKeys: []string{"g", "gh"}, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "filters by prefix case-insensitive", | |||
| 36 | + | query: "!DD", | |||
| 37 | + | wantKeys: []string{"ddg"}, | |||
| 38 | + | }, | |||
| 39 | + | { | |||
| 40 | + | name: "stops prefix at space", | |||
| 41 | + | query: "!g query", | |||
| 42 | + | wantKeys: []string{"g", "gh"}, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "falls back to url when description empty", | |||
| 46 | + | query: "!n", | |||
| 47 | + | wantKeys: []string{"n"}, | |||
| 48 | + | wantDescr: map[string]string{ | |||
| 49 | + | "n": "https://news.ycombinator.com/", | |||
| 50 | + | }, | |||
| 51 | + | }, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | for _, tc := range cases { | |||
| 55 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 56 | + | output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query}) | |||
| 57 | + | got := output.Suggestions | |||
| 58 | + | if len(got) != len(tc.wantKeys) { | |||
| 59 | + | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | |||
| 60 | + | } | |||
| 61 | + | for i, wantKey := range tc.wantKeys { | |||
| 62 | + | if got[i].Key != wantKey { | |||
| 63 | + | t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey) | |||
| 64 | + | } | |||
| 65 | + | if tc.wantDescr != nil { | |||
| 66 | + | if wantD, ok := tc.wantDescr[wantKey]; ok { | |||
| 67 | + | if got[i].Description != wantD { | |||
| 68 | + | t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestDetectBangKey(t *testing.T) { | |||
| 78 | + | shortcuts := map[string]SearchShortcut{ | |||
| 79 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |||
| 80 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: ""}, | |||
| 81 | + | } | |||
| 82 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 83 | + | ctx := context.Background() | |||
| 84 | + | ||||
| 85 | + | cases := []struct { | |||
| 86 | + | name string | |||
| 87 | + | query string | |||
| 88 | + | wantKey string | |||
| 89 | + | wantDescr string | |||
| 90 | + | }{ | |||
| 91 | + | {name: "no bang prefix", query: "gh test", wantKey: "", wantDescr: ""}, | |||
| 92 | + | {name: "bang only has no space", query: "!gh", wantKey: "", wantDescr: ""}, | |||
| 93 | + | {name: "space at position 1", query: "! test", wantKey: "", wantDescr: ""}, | |||
| 94 | + | {name: "unknown bang", query: "!nope test", wantKey: "", wantDescr: ""}, | |||
| 95 | + | {name: "case-insensitive match", query: "!GH test", wantKey: "gh", wantDescr: "GitHub search"}, | |||
| 96 | + | {name: "valid bang key", query: "!ddg test", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"}, | |||
| 97 | + | {name: "description fallback to URL", query: "!DDG query", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"}, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, tc := range cases { | |||
| 101 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 102 | + | output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query}) | |||
| 103 | + | if output.Key != tc.wantKey { | |||
| 104 | + | t.Fatalf("DetectBangKey(%q).Key=%q want=%q", tc.query, output.Key, tc.wantKey) | |||
| 105 | + | } | |||
| 106 | + | if output.Description != tc.wantDescr { | |||
| 107 | + | t.Fatalf("DetectBangKey(%q).Description=%q want=%q", tc.query, output.Description, tc.wantDescr) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestBuildNavigationText(t *testing.T) { | |||
| 114 | + | shortcuts := map[string]SearchShortcut{ | |||
| 115 | + | "gh": {URL: "https://github.com/search?q=%s"}, | |||
| 116 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | |||
| 117 | + | } | |||
| 118 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 119 | + | ctx := context.Background() | |||
| 120 | + | ||||
| 121 | + | cases := []struct { | |||
| 122 | + | name string | |||
| 123 | + | entryText string | |||
| 124 | + | want string | |||
| 125 | + | wantValid bool | |||
| 126 | + | }{ | |||
| 127 | + | {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false}, | |||
| 128 | + | {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false}, | |||
| 129 | + | {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false}, | |||
| 130 | + | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true}, | |||
| 131 | + | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true}, | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | for _, tc := range cases { | |||
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 136 | + | output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText}) | |||
| 137 | + | if output.Text != tc.want { | |||
| 138 | + | t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want) | |||
| 139 | + | } | |||
| 140 | + | if output.Valid != tc.wantValid { | |||
| 141 | + | t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid) | |||
| 142 | + | } | |||
| 143 | + | }) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestGetShortcut(t *testing.T) { | |||
| 148 | + | shortcuts := map[string]SearchShortcut{ | |||
| 149 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |||
| 150 | + | } | |||
| 151 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |||
| 152 | + | ||||
| 153 | + | cases := []struct { | |||
| 154 | + | name string | |||
| 155 | + | key string | |||
| 156 | + | wantOK bool | |||
| 157 | + | wantURL string | |||
| 158 | + | }{ | |||
| 159 | + | {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |||
| 160 | + | {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |||
| 161 | + | {name: "not found", key: "unknown", wantOK: false, wantURL: ""}, | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | for _, tc := range cases { | |||
| 165 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 166 | + | shortcut, ok := uc.GetShortcut(tc.key) | |||
| 167 | + | if ok != tc.wantOK { | |||
| 168 | + | t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK) | |||
| 169 | + | } | |||
| 170 | + | if shortcut.URL != tc.wantURL { | |||
| 171 | + | t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL) | |||
| 172 | + | } | |||
| 173 | + | }) | |||
| 174 | + | } | |||
| 175 | + | } | |||
M internal/ui/app.go +11 -1
| @@ -389,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 389 | 389 | ||
| 390 | 390 | log := logging.FromContext(ctx) | |
| 391 | 391 | ||
| 392 | + | // Convert config shortcuts to use case type | |
| 393 | + | shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts)) | |
| 394 | + | for key, shortcut := range a.deps.Config.SearchShortcuts { | |
| 395 | + | shortcuts[key] = usecase.SearchShortcut{ | |
| 396 | + | URL: shortcut.URL, | |
| 397 | + | Description: shortcut.Description, | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |
| 401 | + | ||
| 392 | 402 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | |
| 393 | 403 | a.omniboxCfg = component.OmniboxConfig{ | |
| 394 | 404 | HistoryUC: a.deps.HistoryUC, | |
| 395 | 405 | FavoritesUC: a.deps.FavoritesUC, | |
| 396 | 406 | FaviconAdapter: a.faviconAdapter, | |
| 397 | 407 | CopyURLUC: a.deps.CopyURLUC, | |
| 398 | - | Shortcuts: a.deps.Config.SearchShortcuts, | |
| 408 | + | ShortcutsUC: shortcutsUC, | |
| 399 | 409 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | |
| 400 | 410 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | |
| 401 | 411 | UIScale: a.deps.Config.DefaultUIScale, | |
| @@ -389,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 389 | 389 | ||||
| 390 | log := logging.FromContext(ctx) | 390 | log := logging.FromContext(ctx) | ||
| 391 | 391 | ||||
| 392 | + | // Convert config shortcuts to use case type | |||
| 393 | + | shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts)) | |||
| 394 | + | for key, shortcut := range a.deps.Config.SearchShortcuts { | |||
| 395 | + | shortcuts[key] = usecase.SearchShortcut{ | |||
| 396 | + | URL: shortcut.URL, | |||
| 397 | + | Description: shortcut.Description, | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts) | |||
| 401 | + | ||||
| 392 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | 402 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | ||
| 393 | a.omniboxCfg = component.OmniboxConfig{ | 403 | a.omniboxCfg = component.OmniboxConfig{ | ||
| 394 | HistoryUC: a.deps.HistoryUC, | 404 | HistoryUC: a.deps.HistoryUC, | ||
| 395 | FavoritesUC: a.deps.FavoritesUC, | 405 | FavoritesUC: a.deps.FavoritesUC, | ||
| 396 | FaviconAdapter: a.faviconAdapter, | 406 | FaviconAdapter: a.faviconAdapter, | ||
| 397 | CopyURLUC: a.deps.CopyURLUC, | 407 | CopyURLUC: a.deps.CopyURLUC, | ||
| 398 | - | Shortcuts: a.deps.Config.SearchShortcuts, | 408 | + | ShortcutsUC: shortcutsUC, |
| 399 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | 409 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | ||
| 400 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | 410 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | ||
| 401 | UIScale: a.deps.Config.DefaultUIScale, | 411 | UIScale: a.deps.Config.DefaultUIScale, | ||
M internal/ui/component/omnibox.go +225 -33
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 12 | "github.com/bnema/dumber/internal/domain/url" | |
| 13 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 14 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 16 | 15 | "github.com/bnema/dumber/internal/ui/input" | |
| @@ -22,6 +21,7 @@ import ( | |||
| 22 | 21 | ||
| 23 | 22 | const ( | |
| 24 | 23 | debounceDelayMs = 150 | |
| 24 | + | endBoxSpacing = 6 | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | // ViewMode distinguishes history search from favorites display. | |
| @@ -40,6 +40,13 @@ type Suggestion struct { | |||
| 40 | 40 | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | + | // BangSuggestion represents a configured bang shortcut. | |
| 44 | + | // A bang is invoked by typing "!<key>". | |
| 45 | + | type BangSuggestion struct { | |
| 46 | + | Key string | |
| 47 | + | Description string | |
| 48 | + | } | |
| 49 | + | ||
| 43 | 50 | // Favorite represents a bookmarked URL. | |
| 44 | 51 | type Favorite struct { | |
| 45 | 52 | ID int64 | |
| @@ -58,6 +65,7 @@ type Omnibox struct { | |||
| 58 | 65 | historyBtn *gtk.Button | |
| 59 | 66 | favoritesBtn *gtk.Button | |
| 60 | 67 | zoomLabel *gtk.Label | |
| 68 | + | bangBadge *gtk.Label | |
| 61 | 69 | entry *gtk.SearchEntry | |
| 62 | 70 | scrolledWin *gtk.ScrolledWindow | |
| 63 | 71 | listBox *gtk.ListBox | |
| @@ -66,20 +74,23 @@ type Omnibox struct { | |||
| 66 | 74 | parentOverlay layout.OverlayWidget | |
| 67 | 75 | ||
| 68 | 76 | // 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) | |
| 77 | + | mu sync.RWMutex | |
| 78 | + | visible bool | |
| 79 | + | viewMode ViewMode | |
| 80 | + | selectedIndex int | |
| 81 | + | suggestions []Suggestion | |
| 82 | + | favorites []Favorite | |
| 83 | + | bangSuggestions []BangSuggestion | |
| 84 | + | bangMode bool | |
| 85 | + | detectedBang string | |
| 86 | + | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |
| 76 | 87 | ||
| 77 | 88 | // Dependencies | |
| 78 | 89 | historyUC *usecase.SearchHistoryUseCase | |
| 79 | 90 | favoritesUC *usecase.ManageFavoritesUseCase | |
| 80 | 91 | faviconAdapter *adapter.FaviconAdapter | |
| 81 | 92 | copyURLUC *usecase.CopyURLUseCase | |
| 82 | - | shortcuts map[string]config.SearchShortcut | |
| 93 | + | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 83 | 94 | defaultSearch string | |
| 84 | 95 | initialBehavior string | |
| 85 | 96 | ctx context.Context | |
| @@ -115,7 +126,7 @@ type OmniboxConfig struct { | |||
| 115 | 126 | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 116 | 127 | FaviconAdapter *adapter.FaviconAdapter | |
| 117 | 128 | CopyURLUC *usecase.CopyURLUseCase | |
| 118 | - | Shortcuts map[string]config.SearchShortcut | |
| 129 | + | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 119 | 130 | DefaultSearch string | |
| 120 | 131 | InitialBehavior string | |
| 121 | 132 | UIScale float64 // UI scale for favicon sizing | |
| @@ -140,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 140 | 151 | favoritesUC: cfg.FavoritesUC, | |
| 141 | 152 | faviconAdapter: cfg.FaviconAdapter, | |
| 142 | 153 | copyURLUC: cfg.CopyURLUC, | |
| 143 | - | shortcuts: cfg.Shortcuts, | |
| 154 | + | shortcutsUC: cfg.ShortcutsUC, | |
| 144 | 155 | defaultSearch: cfg.DefaultSearch, | |
| 145 | 156 | initialBehavior: cfg.InitialBehavior, | |
| 146 | 157 | onToast: cfg.OnToast, | |
| @@ -351,12 +362,16 @@ func (o *Omnibox) initHeader() error { | |||
| 351 | 362 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | |
| 352 | 363 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | |
| 353 | 364 | ||
| 365 | + | o.bangBadge = gtk.NewLabel(nil) | |
| 366 | + | if o.bangBadge != nil { | |
| 367 | + | o.bangBadge.AddCssClass("omnibox-bang-badge") | |
| 368 | + | o.bangBadge.SetVisible(false) | |
| 369 | + | } | |
| 370 | + | ||
| 354 | 371 | o.zoomLabel = gtk.NewLabel(nil) | |
| 355 | 372 | if o.zoomLabel != nil { | |
| 356 | 373 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | |
| 357 | 374 | o.zoomLabel.SetVisible(false) | |
| 358 | - | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |
| 359 | - | o.zoomLabel.SetHexpand(true) | |
| 360 | 375 | } | |
| 361 | 376 | return nil | |
| 362 | 377 | } | |
| @@ -369,7 +384,7 @@ func (o *Omnibox) initEntry() error { | |||
| 369 | 384 | o.entry.AddCssClass("omnibox-entry") | |
| 370 | 385 | o.entry.SetHexpand(true) | |
| 371 | 386 | ||
| 372 | - | placeholder := "Search history or enter URL..." | |
| 387 | + | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 373 | 388 | o.entry.SetPlaceholderText(&placeholder) | |
| 374 | 389 | return nil | |
| 375 | 390 | } | |
| @@ -413,8 +428,18 @@ func (o *Omnibox) initList() error { | |||
| 413 | 428 | func (o *Omnibox) assembleWidgets() { | |
| 414 | 429 | o.headerBox.Append(&o.historyBtn.Widget) | |
| 415 | 430 | o.headerBox.Append(&o.favoritesBtn.Widget) | |
| 416 | - | if o.zoomLabel != nil { | |
| 417 | - | o.headerBox.Append(&o.zoomLabel.Widget) | |
| 431 | + | ||
| 432 | + | endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing) | |
| 433 | + | if endBox != nil { | |
| 434 | + | endBox.SetHexpand(true) | |
| 435 | + | endBox.SetHalign(gtk.AlignEndValue) | |
| 436 | + | if o.bangBadge != nil { | |
| 437 | + | endBox.Append(&o.bangBadge.Widget) | |
| 438 | + | } | |
| 439 | + | if o.zoomLabel != nil { | |
| 440 | + | endBox.Append(&o.zoomLabel.Widget) | |
| 441 | + | } | |
| 442 | + | o.headerBox.Append(&endBox.Widget) | |
| 418 | 443 | } | |
| 419 | 444 | ||
| 420 | 445 | o.scrolledWin.SetChild(&o.listBox.Widget) | |
| @@ -578,6 +603,14 @@ func (o *Omnibox) performSearch() { | |||
| 578 | 603 | o.lastQuery = query | |
| 579 | 604 | o.debounceMu.Unlock() | |
| 580 | 605 | ||
| 606 | + | if strings.HasPrefix(query, "!") { | |
| 607 | + | o.updateBangDetection(query) | |
| 608 | + | o.loadBangSuggestions(query) | |
| 609 | + | return | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | o.clearBangState() | |
| 613 | + | ||
| 581 | 614 | if mode == ViewModeFavorites { | |
| 582 | 615 | o.loadFavorites(query) | |
| 583 | 616 | return | |
| @@ -745,9 +778,108 @@ func (o *Omnibox) loadFavorites(query string) { | |||
| 745 | 778 | }() | |
| 746 | 779 | } | |
| 747 | 780 | ||
| 781 | + | func (o *Omnibox) loadBangSuggestions(query string) { | |
| 782 | + | if o.shortcutsUC == nil { | |
| 783 | + | return | |
| 784 | + | } | |
| 785 | + | output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query}) | |
| 786 | + | suggestions := make([]BangSuggestion, len(output.Suggestions)) | |
| 787 | + | for i, s := range output.Suggestions { | |
| 788 | + | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} | |
| 789 | + | } | |
| 790 | + | o.idleAddUpdateBangSuggestions(suggestions) | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | func (o *Omnibox) updateBangDetection(query string) { | |
| 794 | + | if o.shortcutsUC == nil { | |
| 795 | + | o.clearDetectedBang() | |
| 796 | + | return | |
| 797 | + | } | |
| 798 | + | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | |
| 799 | + | if output.Key == "" { | |
| 800 | + | o.clearDetectedBang() | |
| 801 | + | return | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | o.setDetectedBang(output.Key, output.Description) | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | func (o *Omnibox) clearBangState() { | |
| 808 | + | o.mu.Lock() | |
| 809 | + | o.bangMode = false | |
| 810 | + | o.bangSuggestions = nil | |
| 811 | + | o.mu.Unlock() | |
| 812 | + | ||
| 813 | + | o.clearDetectedBang() | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | func (o *Omnibox) setDetectedBang(key, description string) { | |
| 817 | + | o.mu.Lock() | |
| 818 | + | o.detectedBang = key | |
| 819 | + | o.mu.Unlock() | |
| 820 | + | ||
| 821 | + | if o.bangBadge == nil { | |
| 822 | + | return | |
| 823 | + | } | |
| 824 | + | label := description | |
| 825 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 826 | + | o.entry.AddCssClass("omnibox-entry-bang-active") | |
| 827 | + | o.bangBadge.SetText(label) | |
| 828 | + | o.bangBadge.SetVisible(true) | |
| 829 | + | return false | |
| 830 | + | } | |
| 831 | + | glib.IdleAdd(&cb, 0) | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | func (o *Omnibox) clearDetectedBang() { | |
| 835 | + | o.mu.Lock() | |
| 836 | + | o.detectedBang = "" | |
| 837 | + | o.mu.Unlock() | |
| 838 | + | ||
| 839 | + | if o.bangBadge == nil { | |
| 840 | + | return | |
| 841 | + | } | |
| 842 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 843 | + | o.entry.RemoveCssClass("omnibox-entry-bang-active") | |
| 844 | + | o.bangBadge.SetVisible(false) | |
| 845 | + | return false | |
| 846 | + | } | |
| 847 | + | glib.IdleAdd(&cb, 0) | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) { | |
| 851 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 852 | + | o.updateBangSuggestions(suggestions) | |
| 853 | + | return false | |
| 854 | + | } | |
| 855 | + | glib.IdleAdd(&cb, 0) | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |
| 859 | + | o.mu.Lock() | |
| 860 | + | o.bangMode = true | |
| 861 | + | o.bangSuggestions = suggestions | |
| 862 | + | o.selectedIndex = -1 | |
| 863 | + | o.mu.Unlock() | |
| 864 | + | ||
| 865 | + | o.rebuildList() | |
| 866 | + | ||
| 867 | + | rowCount := len(suggestions) | |
| 868 | + | if o.scrolledWin != nil { | |
| 869 | + | o.scrolledWin.SetVisible(rowCount > 0) | |
| 870 | + | } | |
| 871 | + | o.resizeAndCenter(rowCount) | |
| 872 | + | ||
| 873 | + | if rowCount > 0 { | |
| 874 | + | o.selectIndex(0) | |
| 875 | + | } | |
| 876 | + | } | |
| 877 | + | ||
| 748 | 878 | // updateSuggestions updates the list with history suggestions. | |
| 749 | 879 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |
| 750 | 880 | o.mu.Lock() | |
| 881 | + | o.bangMode = false | |
| 882 | + | o.bangSuggestions = nil | |
| 751 | 883 | o.suggestions = suggestions | |
| 752 | 884 | o.selectedIndex = -1 | |
| 753 | 885 | o.mu.Unlock() | |
| @@ -770,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||
| 770 | 902 | // updateFavorites updates the list with favorites. | |
| 771 | 903 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | |
| 772 | 904 | o.mu.Lock() | |
| 905 | + | o.bangMode = false | |
| 906 | + | o.bangSuggestions = nil | |
| 773 | 907 | o.favorites = favorites | |
| 774 | 908 | o.selectedIndex = -1 | |
| 775 | 909 | o.mu.Unlock() | |
| @@ -798,9 +932,18 @@ func (o *Omnibox) rebuildList() { | |||
| 798 | 932 | mode := o.viewMode | |
| 799 | 933 | suggestions := o.suggestions | |
| 800 | 934 | favorites := o.favorites | |
| 935 | + | bangMode := o.bangMode | |
| 936 | + | bangSuggestions := o.bangSuggestions | |
| 801 | 937 | o.mu.RUnlock() | |
| 802 | 938 | ||
| 803 | - | if mode == ViewModeHistory { | |
| 939 | + | if bangMode { | |
| 940 | + | for i, b := range bangSuggestions { | |
| 941 | + | row := o.createBangRow(b, i) | |
| 942 | + | if row != nil { | |
| 943 | + | o.listBox.Append(&row.Widget) | |
| 944 | + | } | |
| 945 | + | } | |
| 946 | + | } else if mode == ViewModeHistory { | |
| 804 | 947 | for i, s := range suggestions { | |
| 805 | 948 | row := o.createSuggestionRow(s, i) | |
| 806 | 949 | if row != nil { | |
| @@ -831,7 +974,9 @@ func (o *Omnibox) rebuildList() { | |||
| 831 | 974 | // Re-trigger resize with accurate measurements | |
| 832 | 975 | o.mu.RLock() | |
| 833 | 976 | var count int | |
| 834 | - | if o.viewMode == ViewModeHistory { | |
| 977 | + | if o.bangMode { | |
| 978 | + | count = len(o.bangSuggestions) | |
| 979 | + | } else if o.viewMode == ViewModeHistory { | |
| 835 | 980 | count = len(o.suggestions) | |
| 836 | 981 | } else { | |
| 837 | 982 | count = len(o.favorites) | |
| @@ -872,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||
| 872 | 1017 | } | |
| 873 | 1018 | ||
| 874 | 1019 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | |
| 1020 | + | // Uses rawURL for both favicon fetching and display. | |
| 875 | 1021 | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 1022 | + | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index) | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | |
| 1026 | + | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | |
| 1027 | + | func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 876 | 1028 | row := gtk.NewListBoxRow() | |
| 877 | 1029 | if row == nil { | |
| 878 | 1030 | return nil | |
| @@ -887,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 887 | 1039 | hbox.SetHexpand(true) | |
| 888 | 1040 | ||
| 889 | 1041 | // Favicon image (vertically centered) | |
| 890 | - | if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil { | |
| 1042 | + | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { | |
| 891 | 1043 | favicon.SetValign(gtk.AlignCenterValue) | |
| 892 | 1044 | hbox.Append(&favicon.Widget) | |
| 893 | 1045 | } | |
| @@ -903,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 903 | 1055 | // Title label (or URL if no title) | |
| 904 | 1056 | displayTitle := title | |
| 905 | 1057 | if displayTitle == "" { | |
| 906 | - | displayTitle = rawURL | |
| 1058 | + | displayTitle = displayURL | |
| 907 | 1059 | } | |
| 908 | 1060 | titleLabel := gtk.NewLabel(nil) | |
| 909 | 1061 | if titleLabel != nil { | |
| @@ -915,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 915 | 1067 | } | |
| 916 | 1068 | ||
| 917 | 1069 | // URL label (only if title exists and differs from URL) | |
| 918 | - | if title != "" && title != rawURL { | |
| 1070 | + | if title != "" && title != displayURL { | |
| 919 | 1071 | urlLabel := gtk.NewLabel(nil) | |
| 920 | 1072 | if urlLabel != nil { | |
| 921 | - | urlLabel.SetText(rawURL) | |
| 1073 | + | urlLabel.SetText(displayURL) | |
| 922 | 1074 | urlLabel.AddCssClass("omnibox-suggestion-url") | |
| 923 | 1075 | urlLabel.SetHalign(gtk.AlignStartValue) | |
| 924 | 1076 | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| @@ -962,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 962 | 1114 | return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index) | |
| 963 | 1115 | } | |
| 964 | 1116 | ||
| 1117 | + | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |
| 1118 | + | // Pass description as URL param (displayed as secondary label) and empty | |
| 1119 | + | // faviconURL to skip async favicon fetching - bang rows use static icon only | |
| 1120 | + | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index) | |
| 1121 | + | if row != nil { | |
| 1122 | + | row.AddCssClass("omnibox-row-bang") | |
| 1123 | + | } | |
| 1124 | + | return row | |
| 1125 | + | } | |
| 1126 | + | ||
| 965 | 1127 | // selectIndex selects a row by index. | |
| 966 | 1128 | func (o *Omnibox) selectIndex(index int) { | |
| 967 | 1129 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | |
| @@ -978,8 +1140,11 @@ func (o *Omnibox) selectNext() { | |||
| 978 | 1140 | o.mu.Lock() | |
| 979 | 1141 | current := o.selectedIndex | |
| 980 | 1142 | mode := o.viewMode | |
| 1143 | + | bangMode := o.bangMode | |
| 981 | 1144 | var maxIndex int | |
| 982 | - | if mode == ViewModeHistory { | |
| 1145 | + | if bangMode { | |
| 1146 | + | maxIndex = len(o.bangSuggestions) - 1 | |
| 1147 | + | } else if mode == ViewModeHistory { | |
| 983 | 1148 | maxIndex = len(o.suggestions) - 1 | |
| 984 | 1149 | } else { | |
| 985 | 1150 | maxIndex = len(o.favorites) - 1 | |
| @@ -1003,8 +1168,11 @@ func (o *Omnibox) selectPrevious() { | |||
| 1003 | 1168 | o.mu.Lock() | |
| 1004 | 1169 | current := o.selectedIndex | |
| 1005 | 1170 | mode := o.viewMode | |
| 1171 | + | bangMode := o.bangMode | |
| 1006 | 1172 | var maxIndex int | |
| 1007 | - | if mode == ViewModeHistory { | |
| 1173 | + | if bangMode { | |
| 1174 | + | maxIndex = len(o.bangSuggestions) - 1 | |
| 1175 | + | } else if mode == ViewModeHistory { | |
| 1008 | 1176 | maxIndex = len(o.suggestions) - 1 | |
| 1009 | 1177 | } else { | |
| 1010 | 1178 | maxIndex = len(o.favorites) - 1 | |
| @@ -1037,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() { | |||
| 1037 | 1205 | idx := o.selectedIndex | |
| 1038 | 1206 | suggestions := o.suggestions | |
| 1039 | 1207 | favorites := o.favorites | |
| 1208 | + | bangMode := o.bangMode | |
| 1209 | + | bangSuggestions := o.bangSuggestions | |
| 1040 | 1210 | o.mu.RUnlock() | |
| 1041 | 1211 | ||
| 1042 | 1212 | entryText := o.entry.GetText() | |
| 1213 | + | ||
| 1214 | + | if bangMode { | |
| 1215 | + | // If user typed a full bang query, navigate using the bang URL. | |
| 1216 | + | if o.shortcutsUC != nil { | |
| 1217 | + | navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText}) | |
| 1218 | + | if navOutput.Valid { | |
| 1219 | + | targetURL := o.buildURL(navOutput.Text) | |
| 1220 | + | if targetURL == "" { | |
| 1221 | + | return | |
| 1222 | + | } | |
| 1223 | + | o.Hide(o.ctx) | |
| 1224 | + | if o.onNavigate != nil { | |
| 1225 | + | o.onNavigate(targetURL) | |
| 1226 | + | } | |
| 1227 | + | return | |
| 1228 | + | } | |
| 1229 | + | } | |
| 1230 | + | ||
| 1231 | + | // Otherwise, Enter autocompletes the selected bang. | |
| 1232 | + | if idx >= 0 && idx < len(bangSuggestions) { | |
| 1233 | + | o.entry.SetText("!" + bangSuggestions[idx].Key + " ") | |
| 1234 | + | o.entry.SetPosition(-1) | |
| 1235 | + | return | |
| 1236 | + | } | |
| 1237 | + | } | |
| 1238 | + | ||
| 1043 | 1239 | var targetURL string | |
| 1044 | 1240 | ||
| 1045 | 1241 | // Check if user typed a URL-like string (contains . and no spaces) | |
| @@ -1214,16 +1410,11 @@ func (o *Omnibox) yankSelectedURL() { | |||
| 1214 | 1410 | ||
| 1215 | 1411 | // buildURL constructs a URL from text, handling search shortcuts. | |
| 1216 | 1412 | func (o *Omnibox) buildURL(text string) string { | |
| 1217 | - | return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch) | |
| 1218 | - | } | |
| 1219 | - | ||
| 1220 | - | // shortcutURLs returns a map of shortcut keys to URL templates. | |
| 1221 | - | func (o *Omnibox) shortcutURLs() map[string]string { | |
| 1222 | - | result := make(map[string]string, len(o.shortcuts)) | |
| 1223 | - | for key, shortcut := range o.shortcuts { | |
| 1224 | - | result[key] = shortcut.URL | |
| 1413 | + | var shortcutURLs map[string]string | |
| 1414 | + | if o.shortcutsUC != nil { | |
| 1415 | + | shortcutURLs = o.shortcutsUC.ShortcutURLs() | |
| 1225 | 1416 | } | |
| 1226 | - | return result | |
| 1417 | + | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) | |
| 1227 | 1418 | } | |
| 1228 | 1419 | ||
| 1229 | 1420 | // toggleViewMode switches between history and favorites. | |
| @@ -1330,6 +1521,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 1330 | 1521 | o.outerBox.SetVisible(false) | |
| 1331 | 1522 | ||
| 1332 | 1523 | // Clear state | |
| 1524 | + | o.clearBangState() | |
| 1333 | 1525 | o.entry.SetText("") | |
| 1334 | 1526 | o.listBox.RemoveAll() | |
| 1335 | 1527 | ||
| @@ -10,7 +10,6 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | "github.com/bnema/dumber/internal/domain/url" | 12 | "github.com/bnema/dumber/internal/domain/url" | ||
| 13 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 14 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| 15 | "github.com/bnema/dumber/internal/ui/adapter" | 14 | "github.com/bnema/dumber/internal/ui/adapter" | ||
| 16 | "github.com/bnema/dumber/internal/ui/input" | 15 | "github.com/bnema/dumber/internal/ui/input" | ||
| @@ -22,6 +21,7 @@ import ( | |||||
| 22 | 21 | ||||
| 23 | const ( | 22 | const ( | ||
| 24 | debounceDelayMs = 150 | 23 | debounceDelayMs = 150 | ||
| 24 | + | endBoxSpacing = 6 | |||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | // ViewMode distinguishes history search from favorites display. | 27 | // ViewMode distinguishes history search from favorites display. | ||
| @@ -40,6 +40,13 @@ type Suggestion struct { | |||||
| 40 | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | 40 | IsFavorite bool // Indicates if this URL is also bookmarked as a favorite | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | + | // BangSuggestion represents a configured bang shortcut. | |||
| 44 | + | // A bang is invoked by typing "!<key>". | |||
| 45 | + | type BangSuggestion struct { | |||
| 46 | + | Key string | |||
| 47 | + | Description string | |||
| 48 | + | } | |||
| 49 | + | ||||
| 43 | // Favorite represents a bookmarked URL. | 50 | // Favorite represents a bookmarked URL. | ||
| 44 | type Favorite struct { | 51 | type Favorite struct { | ||
| 45 | ID int64 | 52 | ID int64 | ||
| @@ -58,6 +65,7 @@ type Omnibox struct { | |||||
| 58 | historyBtn *gtk.Button | 65 | historyBtn *gtk.Button | ||
| 59 | favoritesBtn *gtk.Button | 66 | favoritesBtn *gtk.Button | ||
| 60 | zoomLabel *gtk.Label | 67 | zoomLabel *gtk.Label | ||
| 68 | + | bangBadge *gtk.Label | |||
| 61 | entry *gtk.SearchEntry | 69 | entry *gtk.SearchEntry | ||
| 62 | scrolledWin *gtk.ScrolledWindow | 70 | scrolledWin *gtk.ScrolledWindow | ||
| 63 | listBox *gtk.ListBox | 71 | listBox *gtk.ListBox | ||
| @@ -66,20 +74,23 @@ type Omnibox struct { | |||||
| 66 | parentOverlay layout.OverlayWidget | 74 | parentOverlay layout.OverlayWidget | ||
| 67 | 75 | ||||
| 68 | // State | 76 | // State | ||
| 69 | - | mu sync.RWMutex | 77 | + | mu sync.RWMutex |
| 70 | - | visible bool | 78 | + | visible bool |
| 71 | - | viewMode ViewMode | 79 | + | viewMode ViewMode |
| 72 | - | selectedIndex int | 80 | + | selectedIndex int |
| 73 | - | suggestions []Suggestion | 81 | + | suggestions []Suggestion |
| 74 | - | favorites []Favorite | 82 | + | favorites []Favorite |
| 75 | - | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | 83 | + | bangSuggestions []BangSuggestion |
| 84 | + | bangMode bool | |||
| 85 | + | detectedBang string | |||
| 86 | + | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |||
| 76 | 87 | ||||
| 77 | // Dependencies | 88 | // Dependencies | ||
| 78 | historyUC *usecase.SearchHistoryUseCase | 89 | historyUC *usecase.SearchHistoryUseCase | ||
| 79 | favoritesUC *usecase.ManageFavoritesUseCase | 90 | favoritesUC *usecase.ManageFavoritesUseCase | ||
| 80 | faviconAdapter *adapter.FaviconAdapter | 91 | faviconAdapter *adapter.FaviconAdapter | ||
| 81 | copyURLUC *usecase.CopyURLUseCase | 92 | copyURLUC *usecase.CopyURLUseCase | ||
| 82 | - | shortcuts map[string]config.SearchShortcut | 93 | + | shortcutsUC *usecase.SearchShortcutsUseCase |
| 83 | defaultSearch string | 94 | defaultSearch string | ||
| 84 | initialBehavior string | 95 | initialBehavior string | ||
| 85 | ctx context.Context | 96 | ctx context.Context | ||
| @@ -115,7 +126,7 @@ type OmniboxConfig struct { | |||||
| 115 | FavoritesUC *usecase.ManageFavoritesUseCase | 126 | FavoritesUC *usecase.ManageFavoritesUseCase | ||
| 116 | FaviconAdapter *adapter.FaviconAdapter | 127 | FaviconAdapter *adapter.FaviconAdapter | ||
| 117 | CopyURLUC *usecase.CopyURLUseCase | 128 | CopyURLUC *usecase.CopyURLUseCase | ||
| 118 | - | Shortcuts map[string]config.SearchShortcut | 129 | + | ShortcutsUC *usecase.SearchShortcutsUseCase |
| 119 | DefaultSearch string | 130 | DefaultSearch string | ||
| 120 | InitialBehavior string | 131 | InitialBehavior string | ||
| 121 | UIScale float64 // UI scale for favicon sizing | 132 | UIScale float64 // UI scale for favicon sizing | ||
| @@ -140,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 140 | favoritesUC: cfg.FavoritesUC, | 151 | favoritesUC: cfg.FavoritesUC, | ||
| 141 | faviconAdapter: cfg.FaviconAdapter, | 152 | faviconAdapter: cfg.FaviconAdapter, | ||
| 142 | copyURLUC: cfg.CopyURLUC, | 153 | copyURLUC: cfg.CopyURLUC, | ||
| 143 | - | shortcuts: cfg.Shortcuts, | 154 | + | shortcutsUC: cfg.ShortcutsUC, |
| 144 | defaultSearch: cfg.DefaultSearch, | 155 | defaultSearch: cfg.DefaultSearch, | ||
| 145 | initialBehavior: cfg.InitialBehavior, | 156 | initialBehavior: cfg.InitialBehavior, | ||
| 146 | onToast: cfg.OnToast, | 157 | onToast: cfg.OnToast, | ||
| @@ -351,12 +362,16 @@ func (o *Omnibox) initHeader() error { | |||||
| 351 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | 362 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | ||
| 352 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | 363 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | ||
| 353 | 364 | ||||
| 365 | + | o.bangBadge = gtk.NewLabel(nil) | |||
| 366 | + | if o.bangBadge != nil { | |||
| 367 | + | o.bangBadge.AddCssClass("omnibox-bang-badge") | |||
| 368 | + | o.bangBadge.SetVisible(false) | |||
| 369 | + | } | |||
| 370 | + | ||||
| 354 | o.zoomLabel = gtk.NewLabel(nil) | 371 | o.zoomLabel = gtk.NewLabel(nil) | ||
| 355 | if o.zoomLabel != nil { | 372 | if o.zoomLabel != nil { | ||
| 356 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | 373 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | ||
| 357 | o.zoomLabel.SetVisible(false) | 374 | o.zoomLabel.SetVisible(false) | ||
| 358 | - | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |||
| 359 | - | o.zoomLabel.SetHexpand(true) | |||
| 360 | } | 375 | } | ||
| 361 | return nil | 376 | return nil | ||
| 362 | } | 377 | } | ||
| @@ -369,7 +384,7 @@ func (o *Omnibox) initEntry() error { | |||||
| 369 | o.entry.AddCssClass("omnibox-entry") | 384 | o.entry.AddCssClass("omnibox-entry") | ||
| 370 | o.entry.SetHexpand(true) | 385 | o.entry.SetHexpand(true) | ||
| 371 | 386 | ||||
| 372 | - | placeholder := "Search history or enter URL..." | 387 | + | placeholder := "Search history or enter URL… (! lists bangs)" |
| 373 | o.entry.SetPlaceholderText(&placeholder) | 388 | o.entry.SetPlaceholderText(&placeholder) | ||
| 374 | return nil | 389 | return nil | ||
| 375 | } | 390 | } | ||
| @@ -413,8 +428,18 @@ func (o *Omnibox) initList() error { | |||||
| 413 | func (o *Omnibox) assembleWidgets() { | 428 | func (o *Omnibox) assembleWidgets() { | ||
| 414 | o.headerBox.Append(&o.historyBtn.Widget) | 429 | o.headerBox.Append(&o.historyBtn.Widget) | ||
| 415 | o.headerBox.Append(&o.favoritesBtn.Widget) | 430 | o.headerBox.Append(&o.favoritesBtn.Widget) | ||
| 416 | - | if o.zoomLabel != nil { | 431 | + | |
| 417 | - | o.headerBox.Append(&o.zoomLabel.Widget) | 432 | + | endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing) |
| 433 | + | if endBox != nil { | |||
| 434 | + | endBox.SetHexpand(true) | |||
| 435 | + | endBox.SetHalign(gtk.AlignEndValue) | |||
| 436 | + | if o.bangBadge != nil { | |||
| 437 | + | endBox.Append(&o.bangBadge.Widget) | |||
| 438 | + | } | |||
| 439 | + | if o.zoomLabel != nil { | |||
| 440 | + | endBox.Append(&o.zoomLabel.Widget) | |||
| 441 | + | } | |||
| 442 | + | o.headerBox.Append(&endBox.Widget) | |||
| 418 | } | 443 | } | ||
| 419 | 444 | ||||
| 420 | o.scrolledWin.SetChild(&o.listBox.Widget) | 445 | o.scrolledWin.SetChild(&o.listBox.Widget) | ||
| @@ -578,6 +603,14 @@ func (o *Omnibox) performSearch() { | |||||
| 578 | o.lastQuery = query | 603 | o.lastQuery = query | ||
| 579 | o.debounceMu.Unlock() | 604 | o.debounceMu.Unlock() | ||
| 580 | 605 | ||||
| 606 | + | if strings.HasPrefix(query, "!") { | |||
| 607 | + | o.updateBangDetection(query) | |||
| 608 | + | o.loadBangSuggestions(query) | |||
| 609 | + | return | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | o.clearBangState() | |||
| 613 | + | ||||
| 581 | if mode == ViewModeFavorites { | 614 | if mode == ViewModeFavorites { | ||
| 582 | o.loadFavorites(query) | 615 | o.loadFavorites(query) | ||
| 583 | return | 616 | return | ||
| @@ -745,9 +778,108 @@ func (o *Omnibox) loadFavorites(query string) { | |||||
| 745 | }() | 778 | }() | ||
| 746 | } | 779 | } | ||
| 747 | 780 | ||||
| 781 | + | func (o *Omnibox) loadBangSuggestions(query string) { | |||
| 782 | + | if o.shortcutsUC == nil { | |||
| 783 | + | return | |||
| 784 | + | } | |||
| 785 | + | output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query}) | |||
| 786 | + | suggestions := make([]BangSuggestion, len(output.Suggestions)) | |||
| 787 | + | for i, s := range output.Suggestions { | |||
| 788 | + | suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description} | |||
| 789 | + | } | |||
| 790 | + | o.idleAddUpdateBangSuggestions(suggestions) | |||
| 791 | + | } | |||
| 792 | + | ||||
| 793 | + | func (o *Omnibox) updateBangDetection(query string) { | |||
| 794 | + | if o.shortcutsUC == nil { | |||
| 795 | + | o.clearDetectedBang() | |||
| 796 | + | return | |||
| 797 | + | } | |||
| 798 | + | output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query}) | |||
| 799 | + | if output.Key == "" { | |||
| 800 | + | o.clearDetectedBang() | |||
| 801 | + | return | |||
| 802 | + | } | |||
| 803 | + | ||||
| 804 | + | o.setDetectedBang(output.Key, output.Description) | |||
| 805 | + | } | |||
| 806 | + | ||||
| 807 | + | func (o *Omnibox) clearBangState() { | |||
| 808 | + | o.mu.Lock() | |||
| 809 | + | o.bangMode = false | |||
| 810 | + | o.bangSuggestions = nil | |||
| 811 | + | o.mu.Unlock() | |||
| 812 | + | ||||
| 813 | + | o.clearDetectedBang() | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | func (o *Omnibox) setDetectedBang(key, description string) { | |||
| 817 | + | o.mu.Lock() | |||
| 818 | + | o.detectedBang = key | |||
| 819 | + | o.mu.Unlock() | |||
| 820 | + | ||||
| 821 | + | if o.bangBadge == nil { | |||
| 822 | + | return | |||
| 823 | + | } | |||
| 824 | + | label := description | |||
| 825 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 826 | + | o.entry.AddCssClass("omnibox-entry-bang-active") | |||
| 827 | + | o.bangBadge.SetText(label) | |||
| 828 | + | o.bangBadge.SetVisible(true) | |||
| 829 | + | return false | |||
| 830 | + | } | |||
| 831 | + | glib.IdleAdd(&cb, 0) | |||
| 832 | + | } | |||
| 833 | + | ||||
| 834 | + | func (o *Omnibox) clearDetectedBang() { | |||
| 835 | + | o.mu.Lock() | |||
| 836 | + | o.detectedBang = "" | |||
| 837 | + | o.mu.Unlock() | |||
| 838 | + | ||||
| 839 | + | if o.bangBadge == nil { | |||
| 840 | + | return | |||
| 841 | + | } | |||
| 842 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 843 | + | o.entry.RemoveCssClass("omnibox-entry-bang-active") | |||
| 844 | + | o.bangBadge.SetVisible(false) | |||
| 845 | + | return false | |||
| 846 | + | } | |||
| 847 | + | glib.IdleAdd(&cb, 0) | |||
| 848 | + | } | |||
| 849 | + | ||||
| 850 | + | func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) { | |||
| 851 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 852 | + | o.updateBangSuggestions(suggestions) | |||
| 853 | + | return false | |||
| 854 | + | } | |||
| 855 | + | glib.IdleAdd(&cb, 0) | |||
| 856 | + | } | |||
| 857 | + | ||||
| 858 | + | func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 859 | + | o.mu.Lock() | |||
| 860 | + | o.bangMode = true | |||
| 861 | + | o.bangSuggestions = suggestions | |||
| 862 | + | o.selectedIndex = -1 | |||
| 863 | + | o.mu.Unlock() | |||
| 864 | + | ||||
| 865 | + | o.rebuildList() | |||
| 866 | + | ||||
| 867 | + | rowCount := len(suggestions) | |||
| 868 | + | if o.scrolledWin != nil { | |||
| 869 | + | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 870 | + | } | |||
| 871 | + | o.resizeAndCenter(rowCount) | |||
| 872 | + | ||||
| 873 | + | if rowCount > 0 { | |||
| 874 | + | o.selectIndex(0) | |||
| 875 | + | } | |||
| 876 | + | } | |||
| 877 | + | ||||
| 748 | // updateSuggestions updates the list with history suggestions. | 878 | // updateSuggestions updates the list with history suggestions. | ||
| 749 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | 879 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | ||
| 750 | o.mu.Lock() | 880 | o.mu.Lock() | ||
| 881 | + | o.bangMode = false | |||
| 882 | + | o.bangSuggestions = nil | |||
| 751 | o.suggestions = suggestions | 883 | o.suggestions = suggestions | ||
| 752 | o.selectedIndex = -1 | 884 | o.selectedIndex = -1 | ||
| 753 | o.mu.Unlock() | 885 | o.mu.Unlock() | ||
| @@ -770,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||||
| 770 | // updateFavorites updates the list with favorites. | 902 | // updateFavorites updates the list with favorites. | ||
| 771 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | 903 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | ||
| 772 | o.mu.Lock() | 904 | o.mu.Lock() | ||
| 905 | + | o.bangMode = false | |||
| 906 | + | o.bangSuggestions = nil | |||
| 773 | o.favorites = favorites | 907 | o.favorites = favorites | ||
| 774 | o.selectedIndex = -1 | 908 | o.selectedIndex = -1 | ||
| 775 | o.mu.Unlock() | 909 | o.mu.Unlock() | ||
| @@ -798,9 +932,18 @@ func (o *Omnibox) rebuildList() { | |||||
| 798 | mode := o.viewMode | 932 | mode := o.viewMode | ||
| 799 | suggestions := o.suggestions | 933 | suggestions := o.suggestions | ||
| 800 | favorites := o.favorites | 934 | favorites := o.favorites | ||
| 935 | + | bangMode := o.bangMode | |||
| 936 | + | bangSuggestions := o.bangSuggestions | |||
| 801 | o.mu.RUnlock() | 937 | o.mu.RUnlock() | ||
| 802 | 938 | ||||
| 803 | - | if mode == ViewModeHistory { | 939 | + | if bangMode { |
| 940 | + | for i, b := range bangSuggestions { | |||
| 941 | + | row := o.createBangRow(b, i) | |||
| 942 | + | if row != nil { | |||
| 943 | + | o.listBox.Append(&row.Widget) | |||
| 944 | + | } | |||
| 945 | + | } | |||
| 946 | + | } else if mode == ViewModeHistory { | |||
| 804 | for i, s := range suggestions { | 947 | for i, s := range suggestions { | ||
| 805 | row := o.createSuggestionRow(s, i) | 948 | row := o.createSuggestionRow(s, i) | ||
| 806 | if row != nil { | 949 | if row != nil { | ||
| @@ -831,7 +974,9 @@ func (o *Omnibox) rebuildList() { | |||||
| 831 | // Re-trigger resize with accurate measurements | 974 | // Re-trigger resize with accurate measurements | ||
| 832 | o.mu.RLock() | 975 | o.mu.RLock() | ||
| 833 | var count int | 976 | var count int | ||
| 834 | - | if o.viewMode == ViewModeHistory { | 977 | + | if o.bangMode { |
| 978 | + | count = len(o.bangSuggestions) | |||
| 979 | + | } else if o.viewMode == ViewModeHistory { | |||
| 835 | count = len(o.suggestions) | 980 | count = len(o.suggestions) | ||
| 836 | } else { | 981 | } else { | ||
| 837 | count = len(o.favorites) | 982 | count = len(o.favorites) | ||
| @@ -872,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||||
| 872 | } | 1017 | } | ||
| 873 | 1018 | ||||
| 874 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | 1019 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | ||
| 1020 | + | // Uses rawURL for both favicon fetching and display. | |||
| 875 | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | 1021 | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | ||
| 1022 | + | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index) | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 1025 | + | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | |||
| 1026 | + | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | |||
| 1027 | + | func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow { | |||
| 876 | row := gtk.NewListBoxRow() | 1028 | row := gtk.NewListBoxRow() | ||
| 877 | if row == nil { | 1029 | if row == nil { | ||
| 878 | return nil | 1030 | return nil | ||
| @@ -887,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||||
| 887 | hbox.SetHexpand(true) | 1039 | hbox.SetHexpand(true) | ||
| 888 | 1040 | ||||
| 889 | // Favicon image (vertically centered) | 1041 | // Favicon image (vertically centered) | ||
| 890 | - | if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil { | 1042 | + | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { |
| 891 | favicon.SetValign(gtk.AlignCenterValue) | 1043 | favicon.SetValign(gtk.AlignCenterValue) | ||
| 892 | hbox.Append(&favicon.Widget) | 1044 | hbox.Append(&favicon.Widget) | ||
| 893 | } | 1045 | } | ||
| @@ -903,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||||
| 903 | // Title label (or URL if no title) | 1055 | // Title label (or URL if no title) | ||
| 904 | displayTitle := title | 1056 | displayTitle := title | ||
| 905 | if displayTitle == "" { | 1057 | if displayTitle == "" { | ||
| 906 | - | displayTitle = rawURL | 1058 | + | displayTitle = displayURL |
| 907 | } | 1059 | } | ||
| 908 | titleLabel := gtk.NewLabel(nil) | 1060 | titleLabel := gtk.NewLabel(nil) | ||
| 909 | if titleLabel != nil { | 1061 | if titleLabel != nil { | ||
| @@ -915,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||||
| 915 | } | 1067 | } | ||
| 916 | 1068 | ||||
| 917 | // URL label (only if title exists and differs from URL) | 1069 | // URL label (only if title exists and differs from URL) | ||
| 918 | - | if title != "" && title != rawURL { | 1070 | + | if title != "" && title != displayURL { |
| 919 | urlLabel := gtk.NewLabel(nil) | 1071 | urlLabel := gtk.NewLabel(nil) | ||
| 920 | if urlLabel != nil { | 1072 | if urlLabel != nil { | ||
| 921 | - | urlLabel.SetText(rawURL) | 1073 | + | urlLabel.SetText(displayURL) |
| 922 | urlLabel.AddCssClass("omnibox-suggestion-url") | 1074 | urlLabel.AddCssClass("omnibox-suggestion-url") | ||
| 923 | urlLabel.SetHalign(gtk.AlignStartValue) | 1075 | urlLabel.SetHalign(gtk.AlignStartValue) | ||
| 924 | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | 1076 | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | ||
| @@ -962,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||||
| 962 | return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index) | 1114 | return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index) | ||
| 963 | } | 1115 | } | ||
| 964 | 1116 | ||||
| 1117 | + | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |||
| 1118 | + | // Pass description as URL param (displayed as secondary label) and empty | |||
| 1119 | + | // faviconURL to skip async favicon fetching - bang rows use static icon only | |||
| 1120 | + | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index) | |||
| 1121 | + | if row != nil { | |||
| 1122 | + | row.AddCssClass("omnibox-row-bang") | |||
| 1123 | + | } | |||
| 1124 | + | return row | |||
| 1125 | + | } | |||
| 1126 | + | ||||
| 965 | // selectIndex selects a row by index. | 1127 | // selectIndex selects a row by index. | ||
| 966 | func (o *Omnibox) selectIndex(index int) { | 1128 | func (o *Omnibox) selectIndex(index int) { | ||
| 967 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | 1129 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | ||
| @@ -978,8 +1140,11 @@ func (o *Omnibox) selectNext() { | |||||
| 978 | o.mu.Lock() | 1140 | o.mu.Lock() | ||
| 979 | current := o.selectedIndex | 1141 | current := o.selectedIndex | ||
| 980 | mode := o.viewMode | 1142 | mode := o.viewMode | ||
| 1143 | + | bangMode := o.bangMode | |||
| 981 | var maxIndex int | 1144 | var maxIndex int | ||
| 982 | - | if mode == ViewModeHistory { | 1145 | + | if bangMode { |
| 1146 | + | maxIndex = len(o.bangSuggestions) - 1 | |||
| 1147 | + | } else if mode == ViewModeHistory { | |||
| 983 | maxIndex = len(o.suggestions) - 1 | 1148 | maxIndex = len(o.suggestions) - 1 | ||
| 984 | } else { | 1149 | } else { | ||
| 985 | maxIndex = len(o.favorites) - 1 | 1150 | maxIndex = len(o.favorites) - 1 | ||
| @@ -1003,8 +1168,11 @@ func (o *Omnibox) selectPrevious() { | |||||
| 1003 | o.mu.Lock() | 1168 | o.mu.Lock() | ||
| 1004 | current := o.selectedIndex | 1169 | current := o.selectedIndex | ||
| 1005 | mode := o.viewMode | 1170 | mode := o.viewMode | ||
| 1171 | + | bangMode := o.bangMode | |||
| 1006 | var maxIndex int | 1172 | var maxIndex int | ||
| 1007 | - | if mode == ViewModeHistory { | 1173 | + | if bangMode { |
| 1174 | + | maxIndex = len(o.bangSuggestions) - 1 | |||
| 1175 | + | } else if mode == ViewModeHistory { | |||
| 1008 | maxIndex = len(o.suggestions) - 1 | 1176 | maxIndex = len(o.suggestions) - 1 | ||
| 1009 | } else { | 1177 | } else { | ||
| 1010 | maxIndex = len(o.favorites) - 1 | 1178 | maxIndex = len(o.favorites) - 1 | ||
| @@ -1037,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 1037 | idx := o.selectedIndex | 1205 | idx := o.selectedIndex | ||
| 1038 | suggestions := o.suggestions | 1206 | suggestions := o.suggestions | ||
| 1039 | favorites := o.favorites | 1207 | favorites := o.favorites | ||
| 1208 | + | bangMode := o.bangMode | |||
| 1209 | + | bangSuggestions := o.bangSuggestions | |||
| 1040 | o.mu.RUnlock() | 1210 | o.mu.RUnlock() | ||
| 1041 | 1211 | ||||
| 1042 | entryText := o.entry.GetText() | 1212 | entryText := o.entry.GetText() | ||
| 1213 | + | ||||
| 1214 | + | if bangMode { | |||
| 1215 | + | // If user typed a full bang query, navigate using the bang URL. | |||
| 1216 | + | if o.shortcutsUC != nil { | |||
| 1217 | + | navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText}) | |||
| 1218 | + | if navOutput.Valid { | |||
| 1219 | + | targetURL := o.buildURL(navOutput.Text) | |||
| 1220 | + | if targetURL == "" { | |||
| 1221 | + | return | |||
| 1222 | + | } | |||
| 1223 | + | o.Hide(o.ctx) | |||
| 1224 | + | if o.onNavigate != nil { | |||
| 1225 | + | o.onNavigate(targetURL) | |||
| 1226 | + | } | |||
| 1227 | + | return | |||
| 1228 | + | } | |||
| 1229 | + | } | |||
| 1230 | + | ||||
| 1231 | + | // Otherwise, Enter autocompletes the selected bang. | |||
| 1232 | + | if idx >= 0 && idx < len(bangSuggestions) { | |||
| 1233 | + | o.entry.SetText("!" + bangSuggestions[idx].Key + " ") | |||
| 1234 | + | o.entry.SetPosition(-1) | |||
| 1235 | + | return | |||
| 1236 | + | } | |||
| 1237 | + | } | |||
| 1238 | + | ||||
| 1043 | var targetURL string | 1239 | var targetURL string | ||
| 1044 | 1240 | ||||
| 1045 | // Check if user typed a URL-like string (contains . and no spaces) | 1241 | // Check if user typed a URL-like string (contains . and no spaces) | ||
| @@ -1214,16 +1410,11 @@ func (o *Omnibox) yankSelectedURL() { | |||||
| 1214 | 1410 | ||||
| 1215 | // buildURL constructs a URL from text, handling search shortcuts. | 1411 | // buildURL constructs a URL from text, handling search shortcuts. | ||
| 1216 | func (o *Omnibox) buildURL(text string) string { | 1412 | func (o *Omnibox) buildURL(text string) string { | ||
| 1217 | - | return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch) | 1413 | + | var shortcutURLs map[string]string |
| 1218 | - | } | 1414 | + | if o.shortcutsUC != nil { |
| 1219 | - | 1415 | + | shortcutURLs = o.shortcutsUC.ShortcutURLs() | |
| 1220 | - | // shortcutURLs returns a map of shortcut keys to URL templates. | |||
| 1221 | - | func (o *Omnibox) shortcutURLs() map[string]string { | |||
| 1222 | - | result := make(map[string]string, len(o.shortcuts)) | |||
| 1223 | - | for key, shortcut := range o.shortcuts { | |||
| 1224 | - | result[key] = shortcut.URL | |||
| 1225 | } | 1416 | } | ||
| 1226 | - | return result | 1417 | + | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) |
| 1227 | } | 1418 | } | ||
| 1228 | 1419 | ||||
| 1229 | // toggleViewMode switches between history and favorites. | 1420 | // toggleViewMode switches between history and favorites. | ||
| @@ -1330,6 +1521,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 1330 | o.outerBox.SetVisible(false) | 1521 | o.outerBox.SetVisible(false) | ||
| 1331 | 1522 | ||||
| 1332 | // Clear state | 1523 | // Clear state | ||
| 1524 | + | o.clearBangState() | |||
| 1333 | o.entry.SetText("") | 1525 | o.entry.SetText("") | ||
| 1334 | o.listBox.RemoveAll() | 1526 | o.listBox.RemoveAll() | ||
| 1335 | 1527 | ||||
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 | ||||