detached e6ce1fe

feat(omnibox): fix space key behavior and add 'y' to copy selected URL

commit
e6ce1fedf1c3b4970ced7700da3f06293f0d85f2
author
brice · · 2025-12-17 18:51 UTC
committer
brice · · 2025-12-17 20:35 UTC
parent
36d1371
signature
unsigned
2 files · +102 -9
M internal/ui/component/omnibox.go +98 -9
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/application/usecase"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/bnema/dumber/internal/domain/url"
@@ -74,11 +75,13 @@ type Omnibox struct {
7475 selectedIndex int
7576 suggestions []Suggestion
7677 favorites []Favorite
78+ hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
7779
7880 // Dependencies
7981 historyUC *usecase.SearchHistoryUseCase
8082 favoritesUC *usecase.ManageFavoritesUseCase
8183 faviconCache *cache.FaviconCache
84+ clipboard port.Clipboard
8285 shortcuts map[string]config.SearchShortcut
8386 defaultSearch string
8487 initialBehavior string
@@ -87,6 +90,7 @@ type Omnibox struct {
8790 // Callbacks
8891 onNavigate func(url string)
8992 onClose func()
93+ onToast func(message string)
9094
9195 // Debouncing
9296 debounceTimer *time.Timer
@@ -110,11 +114,13 @@ type OmniboxConfig struct {
110114 HistoryUC *usecase.SearchHistoryUseCase
111115 FavoritesUC *usecase.ManageFavoritesUseCase
112116 FaviconCache *cache.FaviconCache
117+ Clipboard port.Clipboard
113118 Shortcuts map[string]config.SearchShortcut
114119 DefaultSearch string
115120 InitialBehavior string
116- UIScale float64 // UI scale for favicon sizing
117- OnNavigate func(url string) // Callback when user navigates via omnibox
121+ UIScale float64 // UI scale for favicon sizing
122+ OnNavigate func(url string) // Callback when user navigates via omnibox
123+ OnToast func(message string) // Callback to show toast notification
118124 }
119125
120126 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -133,9 +139,11 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
133139 historyUC: cfg.HistoryUC,
134140 favoritesUC: cfg.FavoritesUC,
135141 faviconCache: cfg.FaviconCache,
142+ clipboard: cfg.Clipboard,
136143 shortcuts: cfg.Shortcuts,
137144 defaultSearch: cfg.DefaultSearch,
138145 initialBehavior: cfg.InitialBehavior,
146+ onToast: cfg.OnToast,
139147 ctx: ctx,
140148 uiScale: uiScale,
141149 }
@@ -452,9 +460,27 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
452460 return true
453461
454462 case uint(gdk.KEY_space):
455- // Space toggles favorite: add in history mode, remove in favorites mode
456- o.toggleFavorite()
457- return true
463+ // Space toggles favorite only if user has navigated with arrow keys
464+ // Otherwise, let space pass through to entry for typing
465+ o.mu.RLock()
466+ navigated := o.hasNavigated
467+ o.mu.RUnlock()
468+ if navigated {
469+ o.toggleFavorite()
470+ return true
471+ }
472+ return false // Let entry handle space for typing
473+
474+ case uint(gdk.KEY_y):
475+ // 'y' yanks (copies) the selected URL to clipboard when navigating
476+ o.mu.RLock()
477+ navigated := o.hasNavigated
478+ o.mu.RUnlock()
479+ if navigated {
480+ o.yankSelectedURL()
481+ return true
482+ }
483+ return false // Let entry handle 'y' for typing
458484
459485 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
460486 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
@@ -476,6 +502,11 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
476502
477503 // onEntryChanged handles text input changes with debouncing.
478504 func (o *Omnibox) onEntryChanged() {
505+ // Reset navigation state when user types - space should type, not toggle favorite
506+ o.mu.Lock()
507+ o.hasNavigated = false
508+ o.mu.Unlock()
509+
479510 o.debounceMu.Lock()
480511 if o.debounceTimer != nil {
481512 o.debounceTimer.Stop()
@@ -852,7 +883,7 @@ func (o *Omnibox) selectIndex(index int) {
852883
853884 // selectNext moves selection down.
854885 func (o *Omnibox) selectNext() {
855- o.mu.RLock()
886+ o.mu.Lock()
856887 current := o.selectedIndex
857888 mode := o.viewMode
858889 var maxIndex int
@@ -861,7 +892,8 @@ func (o *Omnibox) selectNext() {
861892 } else {
862893 maxIndex = len(o.favorites) - 1
863894 }
864- o.mu.RUnlock()
895+ o.hasNavigated = true // User is navigating with arrow keys
896+ o.mu.Unlock()
865897
866898 if maxIndex < 0 {
867899 return
@@ -876,7 +908,7 @@ func (o *Omnibox) selectNext() {
876908
877909 // selectPrevious moves selection up.
878910 func (o *Omnibox) selectPrevious() {
879- o.mu.RLock()
911+ o.mu.Lock()
880912 current := o.selectedIndex
881913 mode := o.viewMode
882914 var maxIndex int
@@ -885,7 +917,8 @@ func (o *Omnibox) selectPrevious() {
885917 } else {
886918 maxIndex = len(o.favorites) - 1
887919 }
888- o.mu.RUnlock()
920+ o.hasNavigated = true // User is navigating with arrow keys
921+ o.mu.Unlock()
889922
890923 if maxIndex < 0 {
891924 return
@@ -1034,6 +1067,62 @@ func (o *Omnibox) toggleFavorite() {
10341067 }
10351068 }
10361069
1070+// yankSelectedURL copies the URL of the selected item to clipboard.
1071+func (o *Omnibox) yankSelectedURL() {
1072+ log := logging.FromContext(o.ctx)
1073+
1074+ if o.clipboard == nil {
1075+ log.Warn().Msg("yank URL: clipboard is nil")
1076+ return
1077+ }
1078+
1079+ o.mu.RLock()
1080+ mode := o.viewMode
1081+ idx := o.selectedIndex
1082+ suggestions := o.suggestions
1083+ favorites := o.favorites
1084+ o.mu.RUnlock()
1085+
1086+ var url string
1087+ if mode == ViewModeHistory {
1088+ if idx < 0 || idx >= len(suggestions) {
1089+ log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1090+ return
1091+ }
1092+ url = suggestions[idx].URL
1093+ } else {
1094+ if idx < 0 || idx >= len(favorites) {
1095+ log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1096+ return
1097+ }
1098+ url = favorites[idx].URL
1099+ }
1100+
1101+ if url == "" {
1102+ log.Debug().Msg("yank URL: empty URL")
1103+ return
1104+ }
1105+
1106+ go func() {
1107+ ctx := o.ctx
1108+ if err := o.clipboard.WriteText(ctx, url); err != nil {
1109+ log.Error().Err(err).Str("url", url).Msg("failed to copy URL to clipboard")
1110+ return
1111+ }
1112+ log.Debug().Str("url", url).Msg("URL copied to clipboard")
1113+
1114+ // Show toast notification on success (must run on GTK main thread)
1115+ if o.onToast != nil {
1116+ var cb glib.SourceFunc
1117+ cb = func(_ uintptr) bool {
1118+ o.onToast("URL copied")
1119+ return false // Don't repeat
1120+ }
1121+ glib.IdleAdd(&cb, 0)
1122+ }
1123+ }()
1124+}
1125+
10371126 // buildURL constructs a URL from input, handling search shortcuts.
10381127 func (o *Omnibox) buildURL(input string) string {
10391128 if input == "" {
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10+ "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/url"13 "github.com/bnema/dumber/internal/domain/url"
@@ -74,11 +75,13 @@ type Omnibox struct {
74 selectedIndex int75 selectedIndex int
75 suggestions []Suggestion76 suggestions []Suggestion
76 favorites []Favorite77 favorites []Favorite
78+ hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
77 79
78 // Dependencies80 // Dependencies
79 historyUC *usecase.SearchHistoryUseCase81 historyUC *usecase.SearchHistoryUseCase
80 favoritesUC *usecase.ManageFavoritesUseCase82 favoritesUC *usecase.ManageFavoritesUseCase
81 faviconCache *cache.FaviconCache83 faviconCache *cache.FaviconCache
84+ clipboard port.Clipboard
82 shortcuts map[string]config.SearchShortcut85 shortcuts map[string]config.SearchShortcut
83 defaultSearch string86 defaultSearch string
84 initialBehavior string87 initialBehavior string
@@ -87,6 +90,7 @@ type Omnibox struct {
87 // Callbacks90 // Callbacks
88 onNavigate func(url string)91 onNavigate func(url string)
89 onClose func()92 onClose func()
93+ onToast func(message string)
90 94
91 // Debouncing95 // Debouncing
92 debounceTimer *time.Timer96 debounceTimer *time.Timer
@@ -110,11 +114,13 @@ type OmniboxConfig struct {
110 HistoryUC *usecase.SearchHistoryUseCase114 HistoryUC *usecase.SearchHistoryUseCase
111 FavoritesUC *usecase.ManageFavoritesUseCase115 FavoritesUC *usecase.ManageFavoritesUseCase
112 FaviconCache *cache.FaviconCache116 FaviconCache *cache.FaviconCache
117+ Clipboard port.Clipboard
113 Shortcuts map[string]config.SearchShortcut118 Shortcuts map[string]config.SearchShortcut
114 DefaultSearch string119 DefaultSearch string
115 InitialBehavior string120 InitialBehavior string
116- UIScale float64 // UI scale for favicon sizing121+ UIScale float64 // UI scale for favicon sizing
117- OnNavigate func(url string) // Callback when user navigates via omnibox122+ OnNavigate func(url string) // Callback when user navigates via omnibox
123+ OnToast func(message string) // Callback to show toast notification
118 }124 }
119 125
120 // NewOmnibox creates a new native GTK4 omnibox widget.126 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -133,9 +139,11 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
133 historyUC: cfg.HistoryUC,139 historyUC: cfg.HistoryUC,
134 favoritesUC: cfg.FavoritesUC,140 favoritesUC: cfg.FavoritesUC,
135 faviconCache: cfg.FaviconCache,141 faviconCache: cfg.FaviconCache,
142+ clipboard: cfg.Clipboard,
136 shortcuts: cfg.Shortcuts,143 shortcuts: cfg.Shortcuts,
137 defaultSearch: cfg.DefaultSearch,144 defaultSearch: cfg.DefaultSearch,
138 initialBehavior: cfg.InitialBehavior,145 initialBehavior: cfg.InitialBehavior,
146+ onToast: cfg.OnToast,
139 ctx: ctx,147 ctx: ctx,
140 uiScale: uiScale,148 uiScale: uiScale,
141 }149 }
@@ -452,9 +460,27 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
452 return true460 return true
453 461
454 case uint(gdk.KEY_space):462 case uint(gdk.KEY_space):
455- // Space toggles favorite: add in history mode, remove in favorites mode463+ // Space toggles favorite only if user has navigated with arrow keys
456- o.toggleFavorite()464+ // Otherwise, let space pass through to entry for typing
457- return true465+ o.mu.RLock()
466+ navigated := o.hasNavigated
467+ o.mu.RUnlock()
468+ if navigated {
469+ o.toggleFavorite()
470+ return true
471+ }
472+ return false // Let entry handle space for typing
473+
474+ case uint(gdk.KEY_y):
475+ // 'y' yanks (copies) the selected URL to clipboard when navigating
476+ o.mu.RLock()
477+ navigated := o.hasNavigated
478+ o.mu.RUnlock()
479+ if navigated {
480+ o.yankSelectedURL()
481+ return true
482+ }
483+ return false // Let entry handle 'y' for typing
458 484
459 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),485 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
460 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):486 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
@@ -476,6 +502,11 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
476 502
477 // onEntryChanged handles text input changes with debouncing.503 // onEntryChanged handles text input changes with debouncing.
478 func (o *Omnibox) onEntryChanged() {504 func (o *Omnibox) onEntryChanged() {
505+ // Reset navigation state when user types - space should type, not toggle favorite
506+ o.mu.Lock()
507+ o.hasNavigated = false
508+ o.mu.Unlock()
509+
479 o.debounceMu.Lock()510 o.debounceMu.Lock()
480 if o.debounceTimer != nil {511 if o.debounceTimer != nil {
481 o.debounceTimer.Stop()512 o.debounceTimer.Stop()
@@ -852,7 +883,7 @@ func (o *Omnibox) selectIndex(index int) {
852 883
853 // selectNext moves selection down.884 // selectNext moves selection down.
854 func (o *Omnibox) selectNext() {885 func (o *Omnibox) selectNext() {
855- o.mu.RLock()886+ o.mu.Lock()
856 current := o.selectedIndex887 current := o.selectedIndex
857 mode := o.viewMode888 mode := o.viewMode
858 var maxIndex int889 var maxIndex int
@@ -861,7 +892,8 @@ func (o *Omnibox) selectNext() {
861 } else {892 } else {
862 maxIndex = len(o.favorites) - 1893 maxIndex = len(o.favorites) - 1
863 }894 }
864- o.mu.RUnlock()895+ o.hasNavigated = true // User is navigating with arrow keys
896+ o.mu.Unlock()
865 897
866 if maxIndex < 0 {898 if maxIndex < 0 {
867 return899 return
@@ -876,7 +908,7 @@ func (o *Omnibox) selectNext() {
876 908
877 // selectPrevious moves selection up.909 // selectPrevious moves selection up.
878 func (o *Omnibox) selectPrevious() {910 func (o *Omnibox) selectPrevious() {
879- o.mu.RLock()911+ o.mu.Lock()
880 current := o.selectedIndex912 current := o.selectedIndex
881 mode := o.viewMode913 mode := o.viewMode
882 var maxIndex int914 var maxIndex int
@@ -885,7 +917,8 @@ func (o *Omnibox) selectPrevious() {
885 } else {917 } else {
886 maxIndex = len(o.favorites) - 1918 maxIndex = len(o.favorites) - 1
887 }919 }
888- o.mu.RUnlock()920+ o.hasNavigated = true // User is navigating with arrow keys
921+ o.mu.Unlock()
889 922
890 if maxIndex < 0 {923 if maxIndex < 0 {
891 return924 return
@@ -1034,6 +1067,62 @@ func (o *Omnibox) toggleFavorite() {
1034 }1067 }
1035 }1068 }
1036 1069
1070+// yankSelectedURL copies the URL of the selected item to clipboard.
1071+func (o *Omnibox) yankSelectedURL() {
1072+ log := logging.FromContext(o.ctx)
1073+
1074+ if o.clipboard == nil {
1075+ log.Warn().Msg("yank URL: clipboard is nil")
1076+ return
1077+ }
1078+
1079+ o.mu.RLock()
1080+ mode := o.viewMode
1081+ idx := o.selectedIndex
1082+ suggestions := o.suggestions
1083+ favorites := o.favorites
1084+ o.mu.RUnlock()
1085+
1086+ var url string
1087+ if mode == ViewModeHistory {
1088+ if idx < 0 || idx >= len(suggestions) {
1089+ log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1090+ return
1091+ }
1092+ url = suggestions[idx].URL
1093+ } else {
1094+ if idx < 0 || idx >= len(favorites) {
1095+ log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
1096+ return
1097+ }
1098+ url = favorites[idx].URL
1099+ }
1100+
1101+ if url == "" {
1102+ log.Debug().Msg("yank URL: empty URL")
1103+ return
1104+ }
1105+
1106+ go func() {
1107+ ctx := o.ctx
1108+ if err := o.clipboard.WriteText(ctx, url); err != nil {
1109+ log.Error().Err(err).Str("url", url).Msg("failed to copy URL to clipboard")
1110+ return
1111+ }
1112+ log.Debug().Str("url", url).Msg("URL copied to clipboard")
1113+
1114+ // Show toast notification on success (must run on GTK main thread)
1115+ if o.onToast != nil {
1116+ var cb glib.SourceFunc
1117+ cb = func(_ uintptr) bool {
1118+ o.onToast("URL copied")
1119+ return false // Don't repeat
1120+ }
1121+ glib.IdleAdd(&cb, 0)
1122+ }
1123+ }()
1124+}
1125+
1037 // buildURL constructs a URL from input, handling search shortcuts.1126 // buildURL constructs a URL from input, handling search shortcuts.
1038 func (o *Omnibox) buildURL(input string) string {1127 func (o *Omnibox) buildURL(input string) string {
1039 if input == "" {1128 if input == "" {
M internal/ui/dispatcher/keyboard.go +4 -0
@@ -199,6 +199,10 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
199199 // Notify omnibox to update zoom indicator
200200 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
201201
202+ // Show zoom toast on the active pane
203+ zoomPercent := int(newZoom.ZoomFactor * 100)
204+ d.wsCoord.ShowZoomToast(ctx, zoomPercent)
205+
202206 log.Debug().
203207 Str("domain", domain).
204208 Str("action", action).
@@ -199,6 +199,10 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
199 // Notify omnibox to update zoom indicator199 // Notify omnibox to update zoom indicator
200 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)200 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
201 201
202+ // Show zoom toast on the active pane
203+ zoomPercent := int(newZoom.ZoomFactor * 100)
204+ d.wsCoord.ShowZoomToast(ctx, zoomPercent)
205+
202 log.Debug().206 log.Debug().
203 Str("domain", domain).207 Str("domain", domain).
204 Str("action", action).208 Str("action", action).

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