detached 0515f15

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.
55 ## [Unreleased]
66
77 ### 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.
89 - **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.
910 - **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).
1011 - **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 ### Added7 ### 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
33 import (
44 "context"
55 "fmt"
6+ "sort"
67 "strings"
78 "sync"
89 "time"
@@ -22,6 +23,7 @@ import (
2223
2324 const (
2425 debounceDelayMs = 150
26+ endBoxSpacing = 6
2527 )
2628
2729 // ViewMode distinguishes history search from favorites display.
@@ -40,6 +42,13 @@ type Suggestion struct {
4042 IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
4143 }
4244
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+
4352 // Favorite represents a bookmarked URL.
4453 type Favorite struct {
4554 ID int64
@@ -58,6 +67,7 @@ type Omnibox struct {
5867 historyBtn *gtk.Button
5968 favoritesBtn *gtk.Button
6069 zoomLabel *gtk.Label
70+ bangBadge *gtk.Label
6171 entry *gtk.SearchEntry
6272 scrolledWin *gtk.ScrolledWindow
6373 listBox *gtk.ListBox
@@ -66,13 +76,16 @@ type Omnibox struct {
6676 parentOverlay layout.OverlayWidget
6777
6878 // 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)
7689
7790 // Dependencies
7891 historyUC *usecase.SearchHistoryUseCase
@@ -351,12 +364,16 @@ func (o *Omnibox) initHeader() error {
351364 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
352365 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
353366
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+
354373 o.zoomLabel = gtk.NewLabel(nil)
355374 if o.zoomLabel != nil {
356375 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
357376 o.zoomLabel.SetVisible(false)
358- o.zoomLabel.SetHalign(gtk.AlignEndValue)
359- o.zoomLabel.SetHexpand(true)
360377 }
361378 return nil
362379 }
@@ -369,7 +386,7 @@ func (o *Omnibox) initEntry() error {
369386 o.entry.AddCssClass("omnibox-entry")
370387 o.entry.SetHexpand(true)
371388
372- placeholder := "Search history or enter URL..."
389+ placeholder := "Search history or enter URL… (! lists bangs)"
373390 o.entry.SetPlaceholderText(&placeholder)
374391 return nil
375392 }
@@ -413,8 +430,18 @@ func (o *Omnibox) initList() error {
413430 func (o *Omnibox) assembleWidgets() {
414431 o.headerBox.Append(&o.historyBtn.Widget)
415432 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)
418445 }
419446
420447 o.scrolledWin.SetChild(&o.listBox.Widget)
@@ -578,6 +605,14 @@ func (o *Omnibox) performSearch() {
578605 o.lastQuery = query
579606 o.debounceMu.Unlock()
580607
608+ if strings.HasPrefix(query, "!") {
609+ o.updateBangDetection(query)
610+ o.loadBangSuggestions(query)
611+ return
612+ }
613+
614+ o.clearBangState()
615+
581616 if mode == ViewModeFavorites {
582617 o.loadFavorites(query)
583618 return
@@ -745,9 +780,142 @@ func (o *Omnibox) loadFavorites(query string) {
745780 }()
746781 }
747782
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+
748914 // updateSuggestions updates the list with history suggestions.
749915 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
750916 o.mu.Lock()
917+ o.bangMode = false
918+ o.bangSuggestions = nil
751919 o.suggestions = suggestions
752920 o.selectedIndex = -1
753921 o.mu.Unlock()
@@ -770,6 +938,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
770938 // updateFavorites updates the list with favorites.
771939 func (o *Omnibox) updateFavorites(favorites []Favorite) {
772940 o.mu.Lock()
941+ o.bangMode = false
942+ o.bangSuggestions = nil
773943 o.favorites = favorites
774944 o.selectedIndex = -1
775945 o.mu.Unlock()
@@ -798,9 +968,18 @@ func (o *Omnibox) rebuildList() {
798968 mode := o.viewMode
799969 suggestions := o.suggestions
800970 favorites := o.favorites
971+ bangMode := o.bangMode
972+ bangSuggestions := o.bangSuggestions
801973 o.mu.RUnlock()
802974
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 {
804983 for i, s := range suggestions {
805984 row := o.createSuggestionRow(s, i)
806985 if row != nil {
@@ -831,7 +1010,9 @@ func (o *Omnibox) rebuildList() {
8311010 // Re-trigger resize with accurate measurements
8321011 o.mu.RLock()
8331012 var count int
834- if o.viewMode == ViewModeHistory {
1013+ if o.bangMode {
1014+ count = len(o.bangSuggestions)
1015+ } else if o.viewMode == ViewModeHistory {
8351016 count = len(o.suggestions)
8361017 } else {
8371018 count = len(o.favorites)
@@ -962,6 +1143,14 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
9621143 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
9631144 }
9641145
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+
9651154 // selectIndex selects a row by index.
9661155 func (o *Omnibox) selectIndex(index int) {
9671156 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
@@ -978,8 +1167,11 @@ func (o *Omnibox) selectNext() {
9781167 o.mu.Lock()
9791168 current := o.selectedIndex
9801169 mode := o.viewMode
1170+ bangMode := o.bangMode
9811171 var maxIndex int
982- if mode == ViewModeHistory {
1172+ if bangMode {
1173+ maxIndex = len(o.bangSuggestions) - 1
1174+ } else if mode == ViewModeHistory {
9831175 maxIndex = len(o.suggestions) - 1
9841176 } else {
9851177 maxIndex = len(o.favorites) - 1
@@ -1003,8 +1195,11 @@ func (o *Omnibox) selectPrevious() {
10031195 o.mu.Lock()
10041196 current := o.selectedIndex
10051197 mode := o.viewMode
1198+ bangMode := o.bangMode
10061199 var maxIndex int
1007- if mode == ViewModeHistory {
1200+ if bangMode {
1201+ maxIndex = len(o.bangSuggestions) - 1
1202+ } else if mode == ViewModeHistory {
10081203 maxIndex = len(o.suggestions) - 1
10091204 } else {
10101205 maxIndex = len(o.favorites) - 1
@@ -1037,9 +1232,33 @@ func (o *Omnibox) navigateToSelected() {
10371232 idx := o.selectedIndex
10381233 suggestions := o.suggestions
10391234 favorites := o.favorites
1235+ bangMode := o.bangMode
1236+ bangSuggestions := o.bangSuggestions
10401237 o.mu.RUnlock()
10411238
10421239 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+
10431262 var targetURL string
10441263
10451264 // Check if user typed a URL-like string (contains . and no spaces)
@@ -1330,6 +1549,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
13301549 o.outerBox.SetVisible(false)
13311550
13321551 // Clear state
1552+ o.clearBangState()
13331553 o.entry.SetText("")
13341554 o.listBox.RemoveAll()
13351555
@@ -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 = 15025 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 favorite42 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 int6454 ID int64
@@ -58,6 +67,7 @@ type Omnibox struct {
58 historyBtn *gtk.Button67 historyBtn *gtk.Button
59 favoritesBtn *gtk.Button68 favoritesBtn *gtk.Button
60 zoomLabel *gtk.Label69 zoomLabel *gtk.Label
70+ bangBadge *gtk.Label
61 entry *gtk.SearchEntry71 entry *gtk.SearchEntry
62 scrolledWin *gtk.ScrolledWindow72 scrolledWin *gtk.ScrolledWindow
63 listBox *gtk.ListBox73 listBox *gtk.ListBox
@@ -66,13 +76,16 @@ type Omnibox struct {
66 parentOverlay layout.OverlayWidget76 parentOverlay layout.OverlayWidget
67 77
68 // State78 // State
69- mu sync.RWMutex79+ mu sync.RWMutex
70- visible bool80+ visible bool
71- viewMode ViewMode81+ viewMode ViewMode
72- selectedIndex int82+ selectedIndex int
73- suggestions []Suggestion83+ suggestions []Suggestion
74- favorites []Favorite84+ 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 // Dependencies90 // Dependencies
78 historyUC *usecase.SearchHistoryUseCase91 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 nil378 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 nil391 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 = query605 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 return618 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 = suggestions919 o.suggestions = suggestions
752 o.selectedIndex = -1920 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 = favorites943 o.favorites = favorites
774 o.selectedIndex = -1944 o.selectedIndex = -1
775 o.mu.Unlock()945 o.mu.Unlock()
@@ -798,9 +968,18 @@ func (o *Omnibox) rebuildList() {
798 mode := o.viewMode968 mode := o.viewMode
799 suggestions := o.suggestions969 suggestions := o.suggestions
800 favorites := o.favorites970 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 measurements1010 // Re-trigger resize with accurate measurements
832 o.mu.RLock()1011 o.mu.RLock()
833 var count int1012 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 rowSelectedCb1156 // 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.selectedIndex1168 current := o.selectedIndex
980 mode := o.viewMode1169 mode := o.viewMode
1170+ bangMode := o.bangMode
981 var maxIndex int1171 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) - 11175 maxIndex = len(o.suggestions) - 1
984 } else {1176 } else {
985 maxIndex = len(o.favorites) - 11177 maxIndex = len(o.favorites) - 1
@@ -1003,8 +1195,11 @@ func (o *Omnibox) selectPrevious() {
1003 o.mu.Lock()1195 o.mu.Lock()
1004 current := o.selectedIndex1196 current := o.selectedIndex
1005 mode := o.viewMode1197 mode := o.viewMode
1198+ bangMode := o.bangMode
1006 var maxIndex int1199 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) - 11203 maxIndex = len(o.suggestions) - 1
1009 } else {1204 } else {
1010 maxIndex = len(o.favorites) - 11205 maxIndex = len(o.favorites) - 1
@@ -1037,9 +1232,33 @@ func (o *Omnibox) navigateToSelected() {
1037 idx := o.selectedIndex1232 idx := o.selectedIndex
1038 suggestions := o.suggestions1233 suggestions := o.suggestions
1039 favorites := o.favorites1234 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 string1262 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 state1551 // 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 {
372372 color: var(--accent);
373373 }
374374
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+
375385 /* Zoom indicator in omnibox header */
376386 .omnibox-zoom-indicator {
377387 background-color: alpha(var(--accent), 0.2);
@@ -382,6 +392,19 @@ entry.omnibox-entry > *:focus-visible {
382392 font-weight: 600;
383393 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
384394 }
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+}
385408 `
386409 }
387410
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close