v0.26.0 ae3a77a

keyboard.go RawHistoryFolder

1package dispatcher
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/application/usecase"
8	"github.com/bnema/dumber/internal/domain/entity"
9	domainurl "github.com/bnema/dumber/internal/domain/url"
10	"github.com/bnema/dumber/internal/infrastructure/config"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/internal/ui/component"
13	"github.com/bnema/dumber/internal/ui/coordinator"
14	"github.com/bnema/dumber/internal/ui/input"
15	"github.com/jwijenbergh/puregotk/v4/glib"
16)
17
18// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
19type KeyboardDispatcher struct {
20	tabCoord         *coordinator.TabCoordinator
21	wsCoord          *coordinator.WorkspaceCoordinator
22	navCoord         *coordinator.NavigationCoordinator
23	zoomUC           *usecase.ManageZoomUseCase
24	copyURLUC        *usecase.CopyURLUseCase
25	actionHandlers   map[input.Action]func(ctx context.Context) error
26	onQuit           func()
27	onFindOpen       func(ctx context.Context) error
28	onFindNext       func(ctx context.Context) error
29	onFindPrev       func(ctx context.Context) error
30	onFindClose      func(ctx context.Context) error
31	onSessionOpen    func(ctx context.Context) error
32	onMovePaneToTab  func(ctx context.Context) error
33	onMovePaneToNext func(ctx context.Context) error
34}
35
36// NewKeyboardDispatcher creates a new KeyboardDispatcher.
37func NewKeyboardDispatcher(
38	ctx context.Context,
39	tabCoord *coordinator.TabCoordinator,
40	wsCoord *coordinator.WorkspaceCoordinator,
41	navCoord *coordinator.NavigationCoordinator,
42	zoomUC *usecase.ManageZoomUseCase,
43	copyURLUC *usecase.CopyURLUseCase,
44) *KeyboardDispatcher {
45	log := logging.FromContext(ctx)
46	log.Debug().Msg("creating keyboard dispatcher")
47
48	dispatcher := &KeyboardDispatcher{
49		tabCoord:  tabCoord,
50		wsCoord:   wsCoord,
51		navCoord:  navCoord,
52		zoomUC:    zoomUC,
53		copyURLUC: copyURLUC,
54	}
55	dispatcher.initActionHandlers()
56	return dispatcher
57}
58
59// SetOnQuit sets the callback for quit action.
60func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
61	d.onQuit = fn
62}
63
64// SetOnFindOpen sets the callback for opening the find bar.
65func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
66	d.onFindOpen = fn
67}
68
69// SetOnFindNext sets the callback for the next match action.
70func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
71	d.onFindNext = fn
72}
73
74// SetOnFindPrev sets the callback for the previous match action.
75func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
76	d.onFindPrev = fn
77}
78
79// SetOnFindClose sets the callback for closing the find bar.
80func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
81	d.onFindClose = fn
82}
83
84// SetOnSessionOpen sets the callback for opening the session manager.
85func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
86	d.onSessionOpen = fn
87}
88
89func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
90	d.onMovePaneToTab = fn
91}
92
93func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
94	d.onMovePaneToNext = fn
95}
96
97func (d *KeyboardDispatcher) initActionHandlers() {
98	const (
99		firstTabIndex   = 0
100		secondTabIndex  = 1
101		thirdTabIndex   = 2
102		fourthTabIndex  = 3
103		fifthTabIndex   = 4
104		sixthTabIndex   = 5
105		seventhTabIndex = 6
106		eighthTabIndex  = 7
107		ninthTabIndex   = 8
108		tenthTabIndex   = 9
109	)
110	d.actionHandlers = map[input.Action]func(ctx context.Context) error{
111		// Tab actions
112		input.ActionNewTab: func(ctx context.Context) error {
113			cfg := config.Get()
114			_, err := d.tabCoord.Create(ctx, domainurl.Normalize(cfg.Workspace.NewPaneURL))
115			return err
116		},
117		input.ActionCloseTab:         d.tabCoord.Close,
118		input.ActionNextTab:          d.tabCoord.SwitchNext,
119		input.ActionPreviousTab:      d.tabCoord.SwitchPrev,
120		input.ActionSwitchLastTab:    d.tabCoord.SwitchToLastActive,
121		input.ActionSwitchTabIndex1:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, firstTabIndex) },
122		input.ActionSwitchTabIndex2:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, secondTabIndex) },
123		input.ActionSwitchTabIndex3:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, thirdTabIndex) },
124		input.ActionSwitchTabIndex4:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fourthTabIndex) },
125		input.ActionSwitchTabIndex5:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fifthTabIndex) },
126		input.ActionSwitchTabIndex6:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, sixthTabIndex) },
127		input.ActionSwitchTabIndex7:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, seventhTabIndex) },
128		input.ActionSwitchTabIndex8:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, eighthTabIndex) },
129		input.ActionSwitchTabIndex9:  func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, ninthTabIndex) },
130		input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, tenthTabIndex) },
131		input.ActionRenameTab: func(ctx context.Context) error {
132			return d.logNoop(ctx, "rename tab action (not yet implemented)")
133		},
134		// Pane actions
135		input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
136		input.ActionSplitLeft:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
137		input.ActionSplitUp:    func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
138		input.ActionSplitDown:  func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
139		input.ActionClosePane:  d.wsCoord.ClosePane,
140		input.ActionStackPane:  d.wsCoord.StackPane,
141		input.ActionMovePaneToTab: func(ctx context.Context) error {
142			return d.handleMovePaneToTab(ctx)
143		},
144		input.ActionMovePaneToNextTab: func(ctx context.Context) error {
145			return d.handleMovePaneToNextTab(ctx)
146		},
147		input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
148			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
149		},
150		input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
151			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
152		},
153		input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
154			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
155		},
156		input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
157			return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
158		},
159		input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
160		input.ActionFocusLeft:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
161		input.ActionFocusUp:    func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
162		input.ActionFocusDown:  func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
163		// Resize actions
164		input.ActionResizeIncreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
165		input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
166		input.ActionResizeIncreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
167		input.ActionResizeIncreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
168		input.ActionResizeDecreaseLeft:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
169		input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
170		input.ActionResizeDecreaseUp:    func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
171		input.ActionResizeDecreaseDown:  func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
172		input.ActionResizeIncrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
173		input.ActionResizeDecrease:      func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
174		// Stack navigation
175		input.ActionStackNavUp:   func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
176		input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
177		// Navigation
178		input.ActionGoBack:     d.navCoord.GoBack,
179		input.ActionGoForward:  d.navCoord.GoForward,
180		input.ActionReload:     d.navCoord.Reload,
181		input.ActionHardReload: d.navCoord.HardReload,
182		input.ActionPrintPage:  d.navCoord.PrintPage,
183		// Zoom actions
184		input.ActionZoomIn:    func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
185		input.ActionZoomOut:   func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
186		input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
187		// UI
188		input.ActionOpenOmnibox:  d.navCoord.OpenOmnibox,
189		input.ActionOpenFind:     d.handleFindOpen,
190		input.ActionFindNext:     d.handleFindNext,
191		input.ActionFindPrev:     d.handleFindPrev,
192		input.ActionCloseFind:    d.handleFindClose,
193		input.ActionOpenDevTools: d.navCoord.OpenDevTools,
194		input.ActionToggleFullscreen: func(ctx context.Context) error {
195			return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
196		},
197		// Clipboard
198		input.ActionCopyURL: d.handleCopyURL,
199		// Session management
200		input.ActionOpenSessionManager: d.handleSessionOpen,
201		// Application
202		input.ActionQuit: d.handleQuit,
203	}
204}
205
206// Dispatch routes a keyboard action to the appropriate coordinator.
207func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
208	log := logging.FromContext(ctx)
209	log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
210
211	if handler, ok := d.actionHandlers[action]; ok {
212		return handler(ctx)
213	}
214
215	log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
216	return nil
217}
218
219func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
220	if d.onFindOpen != nil {
221		return d.onFindOpen(ctx)
222	}
223	logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
224	return nil
225}
226
227func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
228	if d.onFindNext != nil {
229		return d.onFindNext(ctx)
230	}
231	logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
232	return nil
233}
234
235func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
236	if d.onFindPrev != nil {
237		return d.onFindPrev(ctx)
238	}
239	logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
240	return nil
241}
242
243func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
244	if d.onFindClose != nil {
245		return d.onFindClose(ctx)
246	}
247	logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
248	return nil
249}
250
251func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
252	if d.onSessionOpen != nil {
253		return d.onSessionOpen(ctx)
254	}
255	logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
256	return nil
257}
258
259func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
260	if d.onMovePaneToTab != nil {
261		return d.onMovePaneToTab(ctx)
262	}
263	logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
264	return nil
265}
266
267func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
268	if d.onMovePaneToNext != nil {
269		return d.onMovePaneToNext(ctx)
270	}
271	logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
272	return nil
273}
274
275func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
276	if ctx == nil {
277		return fmt.Errorf("missing context")
278	}
279	logging.FromContext(ctx).Debug().Msg(message)
280	return nil
281}
282
283func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
284	if d.onQuit != nil {
285		d.onQuit()
286	}
287	if ctx == nil {
288		return fmt.Errorf("missing context")
289	}
290	return nil
291}
292
293// handleZoom processes zoom in/out/reset actions for the active WebView.
294func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
295	log := logging.FromContext(ctx)
296
297	if d.zoomUC == nil {
298		log.Warn().Msg("zoom use case not available")
299		return nil
300	}
301
302	wv := d.navCoord.ActiveWebView(ctx)
303	if wv == nil {
304		log.Debug().Msg("no active webview for zoom")
305		return nil
306	}
307
308	domain, err := usecase.ExtractDomain(wv.URI())
309	if err != nil {
310		log.Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
311		return nil
312	}
313
314	current := wv.GetZoomLevel()
315	var newZoom *entity.ZoomLevel
316
317	switch action {
318	case "in":
319		newZoom, err = d.zoomUC.ZoomIn(ctx, domain, current)
320	case "out":
321		newZoom, err = d.zoomUC.ZoomOut(ctx, domain, current)
322	case "reset":
323		err = d.zoomUC.ResetZoom(ctx, domain)
324		if err == nil {
325			newZoom = entity.NewZoomLevel(domain, d.zoomUC.DefaultZoom())
326		}
327	}
328
329	if err != nil {
330		log.Error().Err(err).Str("action", action).Msg("zoom action failed")
331		return err
332	}
333
334	if newZoom != nil {
335		if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
336			log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
337			return setErr
338		}
339
340		// Notify omnibox to update zoom indicator
341		d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
342
343		// Show zoom toast on the active pane
344		zoomPercent := int(newZoom.ZoomFactor * 100)
345		d.wsCoord.ShowZoomToast(ctx, zoomPercent)
346
347		log.Debug().
348			Str("domain", domain).
349			Str("action", action).
350			Float64("zoom", newZoom.ZoomFactor).
351			Msg("zoom applied")
352	}
353
354	return nil
355}
356
357// handleCopyURL copies the active pane's URL to clipboard.
358func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
359	log := logging.FromContext(ctx)
360
361	if d.copyURLUC == nil {
362		log.Warn().Msg("copy URL use case not available")
363		return nil
364	}
365
366	wv := d.navCoord.ActiveWebView(ctx)
367	if wv == nil {
368		log.Debug().Msg("no active webview for copy URL")
369		return nil
370	}
371
372	uri := wv.URI()
373	if uri == "" {
374		log.Debug().Msg("active webview has empty URI")
375		return nil
376	}
377
378	// Copy URL in background goroutine
379	go func() {
380		if err := d.copyURLUC.Copy(ctx, uri); err != nil {
381			log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
382			return
383		}
384
385		// Show toast on GTK main thread
386		cb := glib.SourceFunc(func(_ uintptr) bool {
387			d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
388			return false
389		})
390		glib.IdleAdd(&cb, 0)
391	}()
392
393	return nil
394}

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