v0.20.1 d9a391f

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

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