detached 89bee03

feat(controls): enhance keyboard shortcuts and webkit integration with unified event bridge

commit
89bee03bbc32228de39bb5be5139aceac15dfab1
author
bamen · · 2025-09-15 21:18 UTC
committer
bamen · · 2025-09-15 21:22 UTC
parent
1eff705
signature
unsigned
7 files · +64 -17
M gui/src/lib/keyboard/KeyboardService.ts +1 -0
@@ -21,6 +21,7 @@ export class KeyboardService {
2121 private globalShortcuts: ShortcutConfig[] = [];
2222 private eventListeners = new Map<KeyboardEventType, Set<(event: KeyboardEvent) => void>>();
2323 private debug: boolean = false;
24+ // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
2425
2526 constructor(config: KeyboardServiceConfig = {}) {
2627 this.debug = config.debug ?? false;
@@ -21,6 +21,7 @@ export class KeyboardService {
21 private globalShortcuts: ShortcutConfig[] = [];21 private globalShortcuts: ShortcutConfig[] = [];
22 private eventListeners = new Map<KeyboardEventType, Set<(event: KeyboardEvent) => void>>();22 private eventListeners = new Map<KeyboardEventType, Set<(event: KeyboardEvent) => void>>();
23 private debug: boolean = false;23 private debug: boolean = false;
24+ // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
24 25
25 constructor(config: KeyboardServiceConfig = {}) {26 constructor(config: KeyboardServiceConfig = {}) {
26 this.debug = config.debug ?? false;27 this.debug = config.debug ?? false;
M internal/app/browser/shortcuts.go +6 -6
@@ -31,20 +31,20 @@ func (s *ShortcutHandler) RegisterShortcuts() {
3131
3232 // Omnibox (Ctrl+L): use new keyboard service bridge
3333 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {
34- log.Printf("Shortcut: Omnibox toggle via keyboard service")
35- _ = s.webView.InjectScript("window.__dumber_keyboard && window.__dumber_keyboard.handleNativeShortcut('cmdorctrl+l')")
34+ log.Printf("Shortcut: Omnibox toggle")
35+ // Handled in GUI KeyboardService via unified 'dumber:key' event
3636 })
3737
3838 // Find in page (Ctrl+F): use keyboard service bridge
3939 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+f", func() {
40- log.Printf("Shortcut: Find in page via keyboard service")
41- _ = s.webView.InjectScript("window.__dumber_keyboard && window.__dumber_keyboard.handleNativeShortcut('cmdorctrl+f')")
40+ log.Printf("Shortcut: Find in page")
41+ // Handled in GUI KeyboardService via unified 'dumber:key' event
4242 })
4343
4444 // Copy URL (Ctrl+Shift+C): use keyboard service with native fallback
4545 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
46- log.Printf("Shortcut: Copy URL via keyboard service")
47- _ = s.webView.InjectScript("window.__dumber_keyboard && window.__dumber_keyboard.handleNativeShortcut('cmdorctrl+shift+c')")
46+ log.Printf("Shortcut: Copy URL")
47+ // Handled in GUI KeyboardService via unified 'dumber:key' event
4848 })
4949
5050 // Page refresh shortcuts
@@ -31,20 +31,20 @@ func (s *ShortcutHandler) RegisterShortcuts() {
31 31
32 // Omnibox (Ctrl+L): use new keyboard service bridge32 // Omnibox (Ctrl+L): use new keyboard service bridge
33 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {33 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {
34- log.Printf("Shortcut: Omnibox toggle via keyboard service")34+ log.Printf("Shortcut: Omnibox toggle")
35- _ = s.webView.InjectScript("window.__dumber_keyboard && window.__dumber_keyboard.handleNativeShortcut('cmdorctrl+l')")35+ // Handled in GUI KeyboardService via unified 'dumber:key' event
36 })36 })
37 37
38 // Find in page (Ctrl+F): use keyboard service bridge38 // Find in page (Ctrl+F): use keyboard service bridge
39 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+f", func() {39 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+f", func() {
40- log.Printf("Shortcut: Find in page via keyboard service")40+ log.Printf("Shortcut: Find in page")
41- _ = s.webView.InjectScript("window.__dumber_keyboard && window.__dumber_keyboard.handleNativeShortcut('cmdorctrl+f')")41+ // Handled in GUI KeyboardService via unified 'dumber:key' event
42 })42 })
43 43
44 // Copy URL (Ctrl+Shift+C): use keyboard service with native fallback44 // Copy URL (Ctrl+Shift+C): use keyboard service with native fallback
45 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {45 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
46- log.Printf("Shortcut: Copy URL via keyboard service")46+ log.Printf("Shortcut: Copy URL")
47- _ = s.webView.InjectScript("window.__dumber_keyboard && window.__dumber_keyboard.handleNativeShortcut('cmdorctrl+shift+c')")47+ // Handled in GUI KeyboardService via unified 'dumber:key' event
48 })48 })
49 49
50 // Page refresh shortcuts50 // Page refresh shortcuts
M internal/app/control/clipboard.go +3 -3
@@ -25,15 +25,15 @@ func (c *ClipboardController) CopyCurrentURL() {
2525 currentURL := c.webView.GetCurrentURL()
2626 if currentURL == "" {
2727 log.Printf("No URL to copy")
28- _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
28+ _ = c.webView.InjectScript(`(window.__dumber?.toast?.show ? window.__dumber.toast.show("No URL to copy", 2000) : (window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)))`)
2929 return
3030 }
3131
3232 if err := clipboard.CopyToClipboard(currentURL); err != nil {
3333 log.Printf("Failed to copy URL to clipboard: %v", err)
34- _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
34+ _ = c.webView.InjectScript(`(window.__dumber?.toast?.show ? window.__dumber.toast.show("Failed to copy URL", 2000) : (window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)))`)
3535 } else {
3636 log.Printf("URL copied to clipboard: %s", currentURL)
37- _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
37+ _ = c.webView.InjectScript(`(window.__dumber?.toast?.show ? window.__dumber.toast.show("URL copied to clipboard", 2000) : (window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)))`)
3838 }
3939 }
@@ -25,15 +25,15 @@ func (c *ClipboardController) CopyCurrentURL() {
25 currentURL := c.webView.GetCurrentURL()25 currentURL := c.webView.GetCurrentURL()
26 if currentURL == "" {26 if currentURL == "" {
27 log.Printf("No URL to copy")27 log.Printf("No URL to copy")
28- _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)28+ _ = c.webView.InjectScript(`(window.__dumber?.toast?.show ? window.__dumber.toast.show("No URL to copy", 2000) : (window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)))`)
29 return29 return
30 }30 }
31 31
32 if err := clipboard.CopyToClipboard(currentURL); err != nil {32 if err := clipboard.CopyToClipboard(currentURL); err != nil {
33 log.Printf("Failed to copy URL to clipboard: %v", err)33 log.Printf("Failed to copy URL to clipboard: %v", err)
34- _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)34+ _ = c.webView.InjectScript(`(window.__dumber?.toast?.show ? window.__dumber.toast.show("Failed to copy URL", 2000) : (window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)))`)
35 } else {35 } else {
36 log.Printf("URL copied to clipboard: %s", currentURL)36 log.Printf("URL copied to clipboard: %s", currentURL)
37- _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)37+ _ = c.webView.InjectScript(`(window.__dumber?.toast?.show ? window.__dumber.toast.show("URL copied to clipboard", 2000) : (window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)))`)
38 }38 }
39 }39 }
M internal/app/control/zoom.go +12 -4
@@ -100,13 +100,21 @@ func (z *ZoomController) showZoomToast(level float64) {
100100 const percentage = %d;
101101 let toastShown = false;
102102
103- // Strategy 1: Try the Svelte toast system first
104- if (typeof window.__dumber_showZoomToast === 'function') {
105- console.log('[dumber] Using Svelte zoom toast system');
103+ // Prefer unified page-world API
104+ if (window.__dumber && window.__dumber.toast && typeof window.__dumber.toast.zoom === 'function') {
105+ console.log('[dumber] Using unified toast.zoom');
106+ window.__dumber.toast.zoom(zoomLevel);
107+ toastShown = true;
108+ } else if (typeof window.__dumber_showZoomToast === 'function') {
109+ console.log('[dumber] Using legacy zoom toast');
106110 window.__dumber_showZoomToast(zoomLevel);
107111 toastShown = true;
112+ } else if (window.__dumber && window.__dumber.toast && typeof window.__dumber.toast.show === 'function') {
113+ console.log('[dumber] Using unified toast.show');
114+ window.__dumber.toast.show('Zoom: ' + percentage + '%%', 1500, 'info');
115+ toastShown = true;
108116 } else if (typeof window.__dumber_showToast === 'function') {
109- console.log('[dumber] Using Svelte general toast system');
117+ console.log('[dumber] Using legacy toast.show');
110118 window.__dumber_showToast('Zoom: ' + percentage + '%%', 1500, 'info');
111119 toastShown = true;
112120 }
@@ -100,13 +100,21 @@ func (z *ZoomController) showZoomToast(level float64) {
100 const percentage = %d;100 const percentage = %d;
101 let toastShown = false;101 let toastShown = false;
102 102
103- // Strategy 1: Try the Svelte toast system first103+ // Prefer unified page-world API
104- if (typeof window.__dumber_showZoomToast === 'function') {104+ if (window.__dumber && window.__dumber.toast && typeof window.__dumber.toast.zoom === 'function') {
105- console.log('[dumber] Using Svelte zoom toast system');105+ console.log('[dumber] Using unified toast.zoom');
106+ window.__dumber.toast.zoom(zoomLevel);
107+ toastShown = true;
108+ } else if (typeof window.__dumber_showZoomToast === 'function') {
109+ console.log('[dumber] Using legacy zoom toast');
106 window.__dumber_showZoomToast(zoomLevel);110 window.__dumber_showZoomToast(zoomLevel);
107 toastShown = true;111 toastShown = true;
112+ } else if (window.__dumber && window.__dumber.toast && typeof window.__dumber.toast.show === 'function') {
113+ console.log('[dumber] Using unified toast.show');
114+ window.__dumber.toast.show('Zoom: ' + percentage + '%%', 1500, 'info');
115+ toastShown = true;
108 } else if (typeof window.__dumber_showToast === 'function') {116 } else if (typeof window.__dumber_showToast === 'function') {
109- console.log('[dumber] Using Svelte general toast system');117+ console.log('[dumber] Using legacy toast.show');
110 window.__dumber_showToast('Zoom: ' + percentage + '%%', 1500, 'info');118 window.__dumber_showToast('Zoom: ' + percentage + '%%', 1500, 'info');
111 toastShown = true;119 toastShown = true;
112 }120 }
M pkg/webkit/find.go +4 -3
@@ -11,7 +11,8 @@ func (w *WebView) OpenFind(initial string) error {
1111 return ErrNotImplemented
1212 }
1313 quoted := strconv.Quote(initial)
14- js := fmt.Sprintf("window.__dumber_find_open && window.__dumber_find_open(%s)", quoted)
14+ // Prefer unified omnibox open via keyboard shortcut; fallback to legacy helper
15+ js := fmt.Sprintf("(window.__dumber?.omnibox?.open ? window.__dumber.omnibox.open('find', %s) : (window.__dumber_find_open && window.__dumber_find_open(%s)))", quoted, quoted)
1516 return w.InjectScript(js)
1617 }
1718
@@ -21,7 +22,7 @@ func (w *WebView) FindQuery(q string) error {
2122 return ErrNotImplemented
2223 }
2324 quoted := strconv.Quote(q)
24- js := fmt.Sprintf("window.__dumber_find_query && window.__dumber_find_query(%s)", quoted)
25+ js := fmt.Sprintf("(window.__dumber?.omnibox?.findQuery ? window.__dumber.omnibox.findQuery(%s) : (window.__dumber_find_query && window.__dumber_find_query(%s)))", quoted, quoted)
2526 return w.InjectScript(js)
2627 }
2728
@@ -30,5 +31,5 @@ func (w *WebView) CloseFind() error {
3031 if w == nil {
3132 return ErrNotImplemented
3233 }
33- return w.InjectScript("window.__dumber_find_close && window.__dumber_find_close()")
34+ return w.InjectScript("(window.__dumber?.omnibox?.close ? window.__dumber.omnibox.close() : (window.__dumber_find_close && window.__dumber_find_close()))")
3435 }
@@ -11,7 +11,8 @@ func (w *WebView) OpenFind(initial string) error {
11 return ErrNotImplemented11 return ErrNotImplemented
12 }12 }
13 quoted := strconv.Quote(initial)13 quoted := strconv.Quote(initial)
14- js := fmt.Sprintf("window.__dumber_find_open && window.__dumber_find_open(%s)", quoted)14+ // Prefer unified omnibox open via keyboard shortcut; fallback to legacy helper
15+ js := fmt.Sprintf("(window.__dumber?.omnibox?.open ? window.__dumber.omnibox.open('find', %s) : (window.__dumber_find_open && window.__dumber_find_open(%s)))", quoted, quoted)
15 return w.InjectScript(js)16 return w.InjectScript(js)
16 }17 }
17 18
@@ -21,7 +22,7 @@ func (w *WebView) FindQuery(q string) error {
21 return ErrNotImplemented22 return ErrNotImplemented
22 }23 }
23 quoted := strconv.Quote(q)24 quoted := strconv.Quote(q)
24- js := fmt.Sprintf("window.__dumber_find_query && window.__dumber_find_query(%s)", quoted)25+ js := fmt.Sprintf("(window.__dumber?.omnibox?.findQuery ? window.__dumber.omnibox.findQuery(%s) : (window.__dumber_find_query && window.__dumber_find_query(%s)))", quoted, quoted)
25 return w.InjectScript(js)26 return w.InjectScript(js)
26 }27 }
27 28
@@ -30,5 +31,5 @@ func (w *WebView) CloseFind() error {
30 if w == nil {31 if w == nil {
31 return ErrNotImplemented32 return ErrNotImplemented
32 }33 }
33- return w.InjectScript("window.__dumber_find_close && window.__dumber_find_close()")34+ return w.InjectScript("(window.__dumber?.omnibox?.close ? window.__dumber.omnibox.close() : (window.__dumber_find_close && window.__dumber_find_close()))")
34 }35 }
M pkg/webkit/keyboard_cgo.go +34 -1
@@ -178,6 +178,30 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
178178 candidates = append([]string{"alt+" + keyName}, candidates...)
179179 }
180180
181+ // Compute a single normalized shortcut string to forward to GUI (single source of truth)
182+ normalized := ""
183+ if ctrl && shift {
184+ normalized = "cmdorctrl+shift+" + keyName
185+ } else if ctrl {
186+ normalized = "cmdorctrl+" + keyName
187+ } else if alt {
188+ normalized = "alt+" + keyName
189+ } else if keyName != "" {
190+ // For future modes like Vim, we may want to forward non-modified keys
191+ // For now, only forward if a modifier is present to avoid noise
192+ normalized = ""
193+ }
194+
195+ // Forward normalized shortcut to GUI KeyboardService via DOM event if present
196+ if normalized != "" {
197+ regMu.RLock()
198+ vw := viewByID[uid]
199+ regMu.RUnlock()
200+ if vw != nil {
201+ _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", normalized))
202+ }
203+ }
204+
181205 regMu.RLock()
182206 reg := viewShortcuts[uid]
183207 regMu.RUnlock()
@@ -202,6 +226,16 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
202226 regMu.Unlock()
203227 log.Printf("[accelerator] %s", name)
204228 cb()
229+ // Additionally forward well-known GUI shortcuts to the in-page GUI via a DOM event
230+ // so the isolated user content world KeyboardService can handle them uniformly.
231+ if name == "cmdorctrl+l" || name == "cmdorctrl+f" || name == "cmdorctrl+shift+c" {
232+ regMu.RLock()
233+ vw := viewByID[uid]
234+ regMu.RUnlock()
235+ if vw != nil {
236+ _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", name))
237+ }
238+ }
205239 break
206240 }
207241 }
@@ -307,7 +341,6 @@ s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}';
307341 _ = vw.InjectScript(js)
308342 }
309343
310-
311344 //export goOnUcmMessage
312345 func goOnUcmMessage(id C.ulong, json *C.char) {
313346 uid := uintptr(id)
@@ -178,6 +178,30 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
178 candidates = append([]string{"alt+" + keyName}, candidates...)178 candidates = append([]string{"alt+" + keyName}, candidates...)
179 }179 }
180 180
181+ // Compute a single normalized shortcut string to forward to GUI (single source of truth)
182+ normalized := ""
183+ if ctrl && shift {
184+ normalized = "cmdorctrl+shift+" + keyName
185+ } else if ctrl {
186+ normalized = "cmdorctrl+" + keyName
187+ } else if alt {
188+ normalized = "alt+" + keyName
189+ } else if keyName != "" {
190+ // For future modes like Vim, we may want to forward non-modified keys
191+ // For now, only forward if a modifier is present to avoid noise
192+ normalized = ""
193+ }
194+
195+ // Forward normalized shortcut to GUI KeyboardService via DOM event if present
196+ if normalized != "" {
197+ regMu.RLock()
198+ vw := viewByID[uid]
199+ regMu.RUnlock()
200+ if vw != nil {
201+ _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", normalized))
202+ }
203+ }
204+
181 regMu.RLock()205 regMu.RLock()
182 reg := viewShortcuts[uid]206 reg := viewShortcuts[uid]
183 regMu.RUnlock()207 regMu.RUnlock()
@@ -202,6 +226,16 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
202 regMu.Unlock()226 regMu.Unlock()
203 log.Printf("[accelerator] %s", name)227 log.Printf("[accelerator] %s", name)
204 cb()228 cb()
229+ // Additionally forward well-known GUI shortcuts to the in-page GUI via a DOM event
230+ // so the isolated user content world KeyboardService can handle them uniformly.
231+ if name == "cmdorctrl+l" || name == "cmdorctrl+f" || name == "cmdorctrl+shift+c" {
232+ regMu.RLock()
233+ vw := viewByID[uid]
234+ regMu.RUnlock()
235+ if vw != nil {
236+ _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", name))
237+ }
238+ }
205 break239 break
206 }240 }
207 }241 }
@@ -307,7 +341,6 @@ s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}';
307 _ = vw.InjectScript(js)341 _ = vw.InjectScript(js)
308 }342 }
309 343
310-
311 //export goOnUcmMessage344 //export goOnUcmMessage
312 func goOnUcmMessage(id C.ulong, json *C.char) {345 func goOnUcmMessage(id C.ulong, json *C.char) {
313 uid := uintptr(id)346 uid := uintptr(id)
M pkg/webkit/settings.go +4 -0
@@ -40,6 +40,10 @@ type Config struct {
4040
4141 // CodecPreferences for media playback
4242 CodecPreferences CodecPreferencesConfig
43+
44+ // APIToken holds the per-session token used to authenticate dumb://api requests
45+ // It's optional; if empty, API token enforcement may be disabled by the app.
46+ APIToken string
4347 }
4448
4549 // CodecPreferencesConfig holds codec preferences for WebKit media playback
@@ -40,6 +40,10 @@ type Config struct {
40 40
41 // CodecPreferences for media playback41 // CodecPreferences for media playback
42 CodecPreferences CodecPreferencesConfig42 CodecPreferences CodecPreferencesConfig
43+
44+ // APIToken holds the per-session token used to authenticate dumb://api requests
45+ // It's optional; if empty, API token enforcement may be disabled by the app.
46+ APIToken string
43 }47 }
44 48
45 // CodecPreferencesConfig holds codec preferences for WebKit media playback49 // CodecPreferencesConfig holds codec preferences for WebKit media playback

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