v0.21.0 73bf644

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

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