favorites_sidebar_keyboard.go RawHistoryFolder

1package component
2
3import (
4	"github.com/bnema/puregotk/v4/gdk"
5	"github.com/bnema/puregotk/v4/glib"
6	"github.com/bnema/puregotk/v4/gtk"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9)
10
11//nolint:gocyclo,funlen // Centralized local key routing keeps sidebar keyboard behavior easy to audit.
12func (fs *FavoritesSidebar) setupKeyboardNavigation() {
13	if fs == nil || fs.outerBox == nil {
14		return
15	}
16	keyController := gtk.NewEventControllerKey()
17	if keyController == nil {
18		return
19	}
20	// Directional controls must run before GtkSearchEntry and GtkListBox consume
21	// arrows. Explicit text-input focus tracking below still defers printable
22	// keys to their focused entry.
23	keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
24	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
25		fs.mu.RLock()
26		if fs.destroyed {
27			fs.mu.RUnlock()
28			return false
29		}
30		searchEntry := fs.searchEntry
31		onClose := fs.onClose
32		fs.mu.RUnlock()
33
34		textEditing := fs.inTextEditContext()
35		if fs.isTextInputFocused(fs.tagNameEntry) &&
36			(keyval == uint(gdk.KEY_Return) || keyval == uint(gdk.KEY_KP_Enter)) {
37			return fs.createTagFromFocusedEntry()
38		}
39		if keyval == uint(gdk.KEY_Up) || keyval == uint(gdk.KEY_Down) {
40			if fs.routeDirectionalFocus(keyval) {
41				return true
42			}
43		}
44		if shouldDeferToTextInput(textEditing, keyval) {
45			return false
46		}
47		switch keyval {
48		case uint(gdk.KEY_Escape):
49			if fs.cancelManagement() {
50				return true
51			}
52			if searchEntry != nil && searchEntry.GetText() != "" {
53				searchEntry.SetText("")
54				return true
55			}
56			if onClose != nil {
57				onClose()
58			}
59			return true
60		case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
61			if fs.createTagFromFocusedEntry() {
62				return true
63			}
64			if fs.focusedTagControlIndex() >= 0 {
65				return false
66			}
67			return fs.handleReturnKey(state)
68		case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
69			fs.cycleFocusZone(state&gdk.ShiftMaskValue != 0)
70			return true
71		case uint(gdk.KEY_Page_Up):
72			if textEditing {
73				return false
74			}
75			fs.selectAdjacentRow(-5)
76			return true
77		case uint(gdk.KEY_Page_Down):
78			if textEditing {
79				return false
80			}
81			fs.selectAdjacentRow(5)
82			return true
83		case uint(gdk.KEY_Home):
84			if textEditing {
85				return false
86			}
87			fs.mu.RLock()
88			index := firstSelectableIndex(fs.displayRows)
89			fs.mu.RUnlock()
90			fs.selectIndex(index)
91			return true
92		case uint(gdk.KEY_End):
93			if textEditing {
94				return false
95			}
96			fs.mu.RLock()
97			index := lastSelectableIndex(fs.displayRows)
98			fs.mu.RUnlock()
99			fs.selectIndex(index)
100			return true
101		case uint(gdk.KEY_Up):
102			if textEditing {
103				return false
104			}
105			fs.selectAdjacentRow(-1)
106			return true
107		case uint(gdk.KEY_Down):
108			if textEditing {
109				return false
110			}
111			fs.selectAdjacentRow(1)
112			return true
113		case uint(gdk.KEY_plus), uint(gdk.KEY_KP_Add):
114			if !textEditing && fs.listHasFocus() {
115				return fs.showTagBindingPicker()
116			}
117			return false
118		case uint(gdk.KEY_slash):
119			if textEditing {
120				return false
121			}
122			searchFocused := searchEntry != nil && searchEntry.HasFocus()
123			if !shouldFocusSearchForSlash(searchFocused) {
124				return false
125			}
126			fs.focusSearch()
127			return true
128		}
129		return fs.handleSingleKeyCommand(keyval, state)
130	}
131	fs.retainedCallbacks = append(fs.retainedCallbacks, keyPressedCb)
132	keyController.ConnectKeyPressed(&keyPressedCb)
133	fs.outerBox.AddController(&keyController.EventController)
134}
135
136func (fs *FavoritesSidebar) handleReturnKey(state gdk.ModifierType) bool {
137	if fs == nil {
138		return false
139	}
140	if fs.confirmDeleteActive() {
141		return fs.confirmDeleteFavorite()
142	}
143	formActive := fs.formModeActive()
144	if state&gdk.ControlMaskValue != 0 && fs.submitForm() {
145		return true
146	}
147	if formActive {
148		return false
149	}
150	return fs.handleEnterKey(state)
151}
152
153func (fs *FavoritesSidebar) handleEnterKey(state gdk.ModifierType) bool {
154	if fs == nil {
155		return false
156	}
157	url := fs.selectedEnterURL()
158	if url == "" {
159		return false
160	}
161	return fs.dispatchEnterURL(state, url)
162}
163
164func (fs *FavoritesSidebar) selectedEnterURL() string {
165	if fs == nil {
166		return ""
167	}
168	if fs.listBox == nil {
169		fs.mu.RLock()
170		defer fs.mu.RUnlock()
171		if fs.destroyed || len(fs.displayRows) != 1 {
172			return ""
173		}
174		return fs.rowURLAt(0)
175	}
176	row := fs.listBox.GetSelectedRow()
177	if row == nil || !row.GetSelectable() {
178		return ""
179	}
180	fs.mu.RLock()
181	defer fs.mu.RUnlock()
182	if fs.destroyed {
183		return ""
184	}
185	return fs.rowURLAt(row.GetIndex())
186}
187
188func (fs *FavoritesSidebar) onRowActivated(row *gtk.ListBoxRow) {
189	if row == nil || !row.GetSelectable() {
190		return
191	}
192	fs.mu.RLock()
193	if fs.destroyed {
194		fs.mu.RUnlock()
195		return
196	}
197	url := fs.rowURLAt(row.GetIndex())
198	fs.mu.RUnlock()
199	fs.navigateToURL(url)
200}
201
202func (fs *FavoritesSidebar) selectAdjacentRow(delta int) {
203	if fs == nil || fs.listBox == nil {
204		return
205	}
206	current := -1
207	if row := fs.listBox.GetSelectedRow(); row != nil {
208		current = row.GetIndex()
209	}
210	fs.mu.RLock()
211	if fs.destroyed {
212		fs.mu.RUnlock()
213		return
214	}
215	target := current
216	step := 1
217	if delta < 0 {
218		step = -1
219	}
220	for i := 0; i < abs(delta); i++ {
221		next := nextSelectableIndex(fs.displayRows, target, step)
222		if next == -1 {
223			break
224		}
225		target = next
226	}
227	if current < 0 {
228		if step > 0 {
229			target = firstSelectableIndex(fs.displayRows)
230		} else {
231			target = lastSelectableIndex(fs.displayRows)
232		}
233	}
234	fs.mu.RUnlock()
235	fs.selectIndex(target)
236}
237
238func (fs *FavoritesSidebar) selectIndex(index int) {
239	if fs == nil || index < 0 {
240		return
241	}
242	fs.mu.RLock()
243	destroyed := fs.destroyed
244	listBox := fs.listBox
245	fs.mu.RUnlock()
246	if destroyed || listBox == nil {
247		return
248	}
249	if row := listBox.GetRowAtIndex(index); row != nil && row.GetSelectable() {
250		listBox.SelectRow(row)
251	}
252}
253
254func (fs *FavoritesSidebar) navigateToURL(url string) {
255	if fs == nil || fs.onNavigate == nil || url == "" {
256		return
257	}
258	fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
259		fs.mu.RLock()
260		destroyed := fs.destroyed
261		fs.mu.RUnlock()
262		if !destroyed {
263			fs.handleNavigationError(fs.onNavigate(fs.ctx, url))
264		}
265		return false
266	}))
267}
268
269func (fs *FavoritesSidebar) navigateWithoutClosing(url string) {
270	if fs == nil || url == "" {
271		return
272	}
273	cb := fs.onNavigateKeepOpen
274	if cb == nil {
275		cb = fs.onNavigate
276	}
277	if cb == nil {
278		return
279	}
280	fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
281		fs.mu.RLock()
282		destroyed := fs.destroyed
283		fs.mu.RUnlock()
284		if !destroyed {
285			fs.handleNavigationError(cb(fs.ctx, url))
286		}
287		return false
288	}))
289}
290
291func (fs *FavoritesSidebar) navigateToNewPane(url string) {
292	if fs == nil || fs.onOpenInNewPane == nil || url == "" {
293		return
294	}
295	fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
296		fs.mu.RLock()
297		destroyed := fs.destroyed
298		fs.mu.RUnlock()
299		if !destroyed {
300			fs.handleNavigationError(fs.onOpenInNewPane(fs.ctx, url))
301		}
302		return false
303	}))
304}
305
306func shouldFocusSearchForSlash(searchFocused bool) bool { return !searchFocused }
307
308// shouldDeferToTextInput keeps the sidebar controller from treating text typed
309// into a descendant entry as a global sidebar command.
310// Tab traverses focus and Escape cancels sidebar management while text is focused.
311func shouldDeferToTextInput(textEditing bool, keyval uint) bool {
312	return textEditing && keyval != uint(gdk.KEY_Tab) &&
313		keyval != uint(gdk.KEY_ISO_Left_Tab) && keyval != uint(gdk.KEY_Escape)
314}
315
316func (fs *FavoritesSidebar) routeDirectionalFocus(keyval uint) bool {
317	if fs == nil {
318		return false
319	}
320	fs.mu.RLock()
321	zone := fs.focusZone
322	fs.mu.RUnlock()
323	switch {
324	case zone == favoritesSidebarFocusTags:
325		// Tag filters and creation are deliberately Tab-only controls.
326		return true
327	case keyval == uint(gdk.KEY_Down) && zone == favoritesSidebarFocusSearch:
328		fs.focusListAndSelectFirst()
329		return true
330	case keyval == uint(gdk.KEY_Up) && zone == favoritesSidebarFocusList && fs.selectedRowIsFirst():
331		fs.setFocusZone(favoritesSidebarFocusSearch)
332		if fs.searchEntry != nil {
333			fs.searchEntry.GrabFocus()
334		}
335		return true
336	}
337	return false
338}
339
340func (fs *FavoritesSidebar) focusListAndSelectFirst() {
341	if fs == nil {
342		return
343	}
344	fs.setFocusZone(favoritesSidebarFocusList)
345	if fs.listBox == nil {
346		return
347	}
348	fs.listBox.GrabFocus()
349	fs.mu.RLock()
350	index := firstSelectableIndex(fs.displayRows)
351	fs.mu.RUnlock()
352	fs.selectIndex(index)
353}
354
355func (fs *FavoritesSidebar) selectedRowIsFirst() bool {
356	if fs == nil {
357		return false
358	}
359	if fs.listBox == nil {
360		return true
361	}
362	row := fs.listBox.GetSelectedRow()
363	if row == nil {
364		return true
365	}
366	fs.mu.RLock()
367	first := firstSelectableIndex(fs.displayRows)
368	fs.mu.RUnlock()
369	return row.GetIndex() == first
370}
371
372func (fs *FavoritesSidebar) listHasFocus() bool {
373	if fs == nil {
374		return false
375	}
376	fs.mu.RLock()
377	defer fs.mu.RUnlock()
378	return fs.focusZone == favoritesSidebarFocusList
379}
380
381func (fs *FavoritesSidebar) inTextEditContext() bool {
382	if fs == nil {
383		return false
384	}
385	fs.mu.RLock()
386	defer fs.mu.RUnlock()
387	return fs.textInputWidget != nil
388}
389
390func (fs *FavoritesSidebar) isTextInputFocused(widget *gtk.Entry) bool {
391	if fs == nil || widget == nil {
392		return false
393	}
394	fs.mu.RLock()
395	defer fs.mu.RUnlock()
396	return fs.textInputWidget == &widget.Widget
397}
398
399func (fs *FavoritesSidebar) handleSingleKeyCommand(keyval uint, state gdk.ModifierType) bool {
400	shortcutModifiers := gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue
401	if fs == nil || fs.inTextEditContext() || state&shortcutModifiers != 0 {
402		return false
403	}
404	fs.mu.RLock()
405	mode := fs.mode
406	confirm := fs.confirmDelete
407	fs.mu.RUnlock()
408	if mode == favoritesSidebarModeShortcut {
409		if keyval >= uint(gdk.KEY_1) && keyval <= uint(gdk.KEY_9) {
410			v := int(keyval - uint(gdk.KEY_0))
411			return fs.setShortcutKey(&v)
412		}
413		if keyval == uint(gdk.KEY_BackSpace) || keyval == uint(gdk.KEY_Delete) {
414			return fs.setShortcutKey(nil)
415		}
416	}
417	if keyval == uint(gdk.KEY_Delete) {
418		if confirm {
419			return fs.confirmDeleteFavorite()
420		}
421		return fs.requestDeleteConfirmation()
422	}
423	switch keyval {
424	case uint(gdk.KEY_a):
425		fs.beginAddForm()
426		return true
427	case uint(gdk.KEY_e):
428		fs.beginEditForm()
429		return true
430	case uint(gdk.KEY_s):
431		fs.enterShortcutMode()
432		return true
433	case uint(gdk.KEY_r):
434		fs.startLoad()
435		return true
436	case uint(gdk.KEY_c):
437		fs.clearQueryAndTags()
438		return true
439	}
440	return false
441}
442
443func (fs *FavoritesSidebar) clearQueryAndTags() {
444	if fs == nil {
445		return
446	}
447	if fs.searchEntry != nil {
448		fs.searchEntry.SetText("")
449	}
450	fs.mu.Lock()
451	if !fs.destroyed {
452		fs.currentQuery = ""
453		fs.selectedTagIDs = make(map[entity.TagID]struct{})
454		fs.rebuildDisplayRowsLocked()
455	}
456	fs.mu.Unlock()
457	fs.renderTags()
458	fs.rebuildList()
459}
460
461func (fs *FavoritesSidebar) cycleFocusZone(reverse bool) {
462	if fs == nil {
463		return
464	}
465	if fs.cycleTagControlFocus(reverse) {
466		return
467	}
468	fs.mu.RLock()
469	current := fs.focusZone
470	tagCount := len(fs.tagControls)
471	fs.mu.RUnlock()
472	if reverse && current == favoritesSidebarFocusList && tagCount > 0 {
473		fs.focusTagControl(tagCount - 1)
474		return
475	}
476	zones := fs.availableFocusZones()
477	if len(zones) == 0 {
478		return
479	}
480	idx := -1
481	for i, zone := range zones {
482		if zone == current {
483			idx = i
484			break
485		}
486	}
487	if reverse {
488		idx--
489		if idx < 0 {
490			idx = len(zones) - 1
491		}
492	} else {
493		idx++
494		if idx >= len(zones) {
495			idx = 0
496		}
497	}
498	fs.focusZoneWidget(zones[idx])
499}
500
501func (fs *FavoritesSidebar) cycleTagControlFocus(reverse bool) bool {
502	fs.mu.RLock()
503	inTags := fs.focusZone == favoritesSidebarFocusTags
504	controls := append([]*gtk.Button(nil), fs.tagControls...)
505	fs.mu.RUnlock()
506	if !inTags || len(controls) == 0 {
507		return false
508	}
509	index := fs.focusedTagControlIndex()
510	if index < 0 {
511		if reverse {
512			fs.focusTagControl(len(controls) - 1)
513		} else {
514			fs.focusTagControl(0)
515		}
516		return true
517	}
518	if reverse {
519		if index > 0 {
520			fs.focusTagControl(index - 1)
521			return true
522		}
523		return false
524	}
525	if index < len(controls)-1 {
526		fs.focusTagControl(index + 1)
527		return true
528	}
529	return false
530}
531
532func (fs *FavoritesSidebar) availableFocusZones() []favoritesSidebarFocusZone {
533	zones := []favoritesSidebarFocusZone{}
534	if fs.searchEntry != nil {
535		zones = append(zones, favoritesSidebarFocusSearch)
536	}
537	if fs.tagBox != nil {
538		zones = append(zones, favoritesSidebarFocusTags)
539	}
540	if fs.listBox != nil {
541		zones = append(zones, favoritesSidebarFocusList)
542	}
543	fs.mu.RLock()
544	formActive := fs.mode == favoritesSidebarModeAdd || fs.mode == favoritesSidebarModeEdit
545	confirmActive := fs.confirmDelete
546	fs.mu.RUnlock()
547	if formActive && fs.formBox != nil {
548		zones = append(zones, favoritesSidebarFocusForm)
549	}
550	if confirmActive {
551		zones = append(zones, favoritesSidebarFocusConfirm)
552	}
553	return zones
554}
555
556func (fs *FavoritesSidebar) focusZoneWidget(zone favoritesSidebarFocusZone) {
557	fs.mu.Lock()
558	if !fs.destroyed {
559		fs.focusZone = zone
560	}
561	fs.mu.Unlock()
562	switch zone {
563	case favoritesSidebarFocusSearch:
564		fs.focusSearch()
565	case favoritesSidebarFocusTags:
566		fs.focusTagControl(0)
567	case favoritesSidebarFocusList, favoritesSidebarFocusConfirm:
568		if fs.listBox != nil {
569			fs.listBox.GrabFocus()
570		}
571	case favoritesSidebarFocusForm:
572		fs.focusForm()
573	}
574}
575
576func (fs *FavoritesSidebar) dispatchEnterURL(state gdk.ModifierType, url string) bool {
577	if fs == nil || url == "" {
578		return false
579	}
580	if state&gdk.ShiftMaskValue != 0 {
581		fs.navigateToNewPane(url)
582	} else if state&gdk.ControlMaskValue != 0 {
583		fs.navigateWithoutClosing(url)
584	} else {
585		fs.navigateToURL(url)
586	}
587	return true
588}
589
590func (fs *FavoritesSidebar) handleNavigationError(err error) {
591	if fs == nil || err == nil {
592		return
593	}
594	fs.mu.Lock()
595	if fs.destroyed {
596		fs.mu.Unlock()
597		return
598	}
599	fs.setNoticeLocked(err.Error())
600	fs.mu.Unlock()
601	fs.rebuildList()
602}
603
604func abs(v int) int {
605	if v < 0 {
606		return -v
607	}
608	return v
609}

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