accent_picker.go RawHistoryFolder

1package component
2
3import (
4	"sync"
5
6	"github.com/bnema/dumber/internal/application/port"
7	"github.com/bnema/dumber/internal/ui/layout"
8	"github.com/bnema/puregotk/v4/gdk"
9	"github.com/bnema/puregotk/v4/gtk"
10)
11
12// Accent picker layout constants.
13const (
14	accentPickerSpacing    = 8 // Spacing between accent items in pixels.
15	maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9).
16)
17
18// AccentPicker displays an overlay with accent character options.
19// The user can select an accent using:
20// - Left/Right arrow keys to navigate
21// - Number keys 1-9 to select directly
22// - Enter to confirm selection
23// - Escape to cancel
24type AccentPicker struct {
25	factory layout.WidgetFactory
26
27	// Widgets
28	container    layout.BoxWidget     // Outer container for positioning
29	accentLabels []layout.LabelWidget // Individual accent character labels
30
31	// State
32	accents         []rune
33	selectedIndex   int
34	selectedCb      func(rune)
35	cancelCb        func()
36	visible         bool
37	keyController   *gtk.EventControllerKey
38	retainedClicked []any // Keep click callbacks alive
39
40	mu sync.Mutex
41}
42
43// Compile-time interface check.
44var _ port.AccentPickerUI = (*AccentPicker)(nil)
45
46// NewAccentPicker creates a new accent picker component.
47func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker {
48	// Create container box
49	container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing)
50	container.AddCssClass("accent-picker")
51
52	// Position at bottom-center
53	container.SetHalign(gtk.AlignCenterValue)
54	container.SetValign(gtk.AlignEndValue)
55
56	// Don't expand
57	container.SetHexpand(false)
58	container.SetVexpand(false)
59
60	// Make focusable so it can receive keyboard events
61	container.SetFocusable(true)
62	container.SetCanFocus(true)
63
64	// Hidden by default
65	container.SetVisible(false)
66
67	ap := &AccentPicker{
68		factory:       factory,
69		container:     container,
70		selectedIndex: 0,
71	}
72
73	// Set up key event handling
74	ap.setupKeyboardHandling()
75
76	// Set up hover-to-focus: regain focus when mouse enters
77	ap.setupHoverFocus()
78
79	return ap
80}
81
82// Widget returns the accent picker's container widget.
83func (ap *AccentPicker) Widget() layout.Widget {
84	return ap.container
85}
86
87// Show displays the accent picker with the given accent options.
88func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) {
89	ap.mu.Lock()
90	defer ap.mu.Unlock()
91
92	ap.accents = accents
93	ap.selectedCb = selectedCb
94	ap.cancelCb = cancelCb
95	ap.selectedIndex = 0
96	ap.visible = true
97
98	// Clear existing labels
99	ap.clearLabels()
100
101	// Create new labels for each accent
102	ap.accentLabels = make([]layout.LabelWidget, len(accents))
103	ap.retainedClicked = make([]any, len(accents))
104
105	for i, accent := range accents {
106		label := ap.factory.NewLabel(string(accent))
107		label.AddCssClass("accent-picker-item")
108
109		// Add number hint for first maxNumberedAccentItems items
110		if i < maxNumberedAccentItems {
111			label.AddCssClass("accent-picker-numbered")
112		}
113
114		// Set up click handling
115		clickCtrl := gtk.NewGestureClick()
116		idx := i // Capture for closure
117		clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) {
118			ap.selectAccent(idx)
119		}
120		clickCtrl.ConnectPressed(&clickCb)
121		ap.retainedClicked[i] = clickCb
122		label.AddController(&clickCtrl.EventController)
123
124		ap.accentLabels[i] = label
125		ap.container.Append(label)
126	}
127
128	// Highlight first item
129	ap.updateSelection()
130
131	ap.container.SetVisible(true)
132	ap.container.GrabFocus()
133}
134
135// Hide hides the accent picker.
136func (ap *AccentPicker) Hide() {
137	ap.mu.Lock()
138	defer ap.mu.Unlock()
139
140	ap.visible = false
141	ap.container.SetVisible(false)
142	ap.clearLabels()
143}
144
145// IsVisible returns true if the accent picker is currently visible.
146func (ap *AccentPicker) IsVisible() bool {
147	ap.mu.Lock()
148	defer ap.mu.Unlock()
149	return ap.visible
150}
151
152// clearLabels removes all accent labels from the container.
153func (ap *AccentPicker) clearLabels() {
154	for _, label := range ap.accentLabels {
155		label.Unparent()
156	}
157	ap.accentLabels = nil
158	ap.retainedClicked = nil
159}
160
161// updateSelection updates the visual selection highlight.
162func (ap *AccentPicker) updateSelection() {
163	for i, label := range ap.accentLabels {
164		if i == ap.selectedIndex {
165			label.AddCssClass("accent-picker-selected")
166		} else {
167			label.RemoveCssClass("accent-picker-selected")
168		}
169	}
170}
171
172// selectAccent selects the accent at the given index.
173func (ap *AccentPicker) selectAccent(index int) {
174	ap.mu.Lock()
175	if index < 0 || index >= len(ap.accents) || ap.selectedCb == nil {
176		ap.mu.Unlock()
177		return
178	}
179	accent := ap.accents[index]
180	cb := ap.selectedCb // Copy callback reference before unlocking
181	ap.mu.Unlock()      // Release lock BEFORE calling callback to avoid deadlock
182	cb(accent)          // Callback may call Hide() which needs to acquire ap.mu
183}
184
185// cancel cancels the accent picker.
186func (ap *AccentPicker) cancel() {
187	ap.mu.Lock()
188	if ap.cancelCb == nil {
189		ap.mu.Unlock()
190		return
191	}
192	cb := ap.cancelCb // Copy callback reference before unlocking
193	ap.mu.Unlock()    // Release lock BEFORE calling callback to avoid deadlock
194	cb()              // Callback may call Hide() which needs to acquire ap.mu
195}
196
197// setupKeyboardHandling sets up key event handling for navigation.
198func (ap *AccentPicker) setupKeyboardHandling() {
199	keyCtrl := gtk.NewEventControllerKey()
200
201	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
202		return ap.handleKeyPress(keyval)
203	}
204
205	keyCtrl.ConnectKeyPressed(&keyPressedCb)
206	ap.keyController = keyCtrl
207	ap.container.AddController(&keyCtrl.EventController)
208}
209
210// setupHoverFocus sets up mouse hover to regain focus.
211// This ensures the picker regains focus when the mouse enters,
212// so keyboard navigation (including Escape) continues to work.
213func (ap *AccentPicker) setupHoverFocus() {
214	motionCtrl := gtk.NewEventControllerMotion()
215
216	enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
217		ap.mu.Lock()
218		visible := ap.visible
219		ap.mu.Unlock()
220
221		if visible {
222			ap.container.GrabFocus()
223		}
224	}
225
226	motionCtrl.ConnectEnter(&enterCb)
227	ap.container.AddController(&motionCtrl.EventController)
228}
229
230// handleKeyPress processes a key press event.
231// Returns true if the key was handled.
232func (ap *AccentPicker) handleKeyPress(keyval uint) bool {
233	ap.mu.Lock()
234	if !ap.visible {
235		ap.mu.Unlock()
236		return false
237	}
238
239	// Handle navigation keys
240	if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn {
241		return handled
242	}
243
244	// Handle number keys for direct selection
245	if idx, ok := ap.numberKeyToIndex(keyval); ok {
246		ap.mu.Unlock()
247		ap.selectAccent(idx)
248		return true
249	}
250
251	ap.mu.Unlock()
252	return false
253}
254
255// handleNavigationKey handles arrow keys, Enter, and Escape.
256// Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled.
257func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) {
258	switch keyval {
259	case uint(gdk.KEY_Left):
260		if ap.selectedIndex > 0 {
261			ap.selectedIndex--
262			ap.updateSelection()
263		}
264		ap.mu.Unlock()
265		return true, true
266
267	case uint(gdk.KEY_Right):
268		if ap.selectedIndex < len(ap.accents)-1 {
269			ap.selectedIndex++
270			ap.updateSelection()
271		}
272		ap.mu.Unlock()
273		return true, true
274
275	case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
276		idx := ap.selectedIndex
277		ap.mu.Unlock()
278		ap.selectAccent(idx)
279		return true, true
280
281	case uint(gdk.KEY_Escape):
282		ap.mu.Unlock()
283		ap.cancel()
284		return true, true
285
286	default:
287		return false, false
288	}
289}
290
291// numberKeyToIndex converts a number key (1-9) to an accent index.
292// Returns the index and true if the key is a valid number key.
293func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) {
294	keyMap := map[uint]int{
295		uint(gdk.KEY_1):    0,
296		uint(gdk.KEY_KP_1): 0,
297		uint(gdk.KEY_2):    1,
298		uint(gdk.KEY_KP_2): 1,
299		uint(gdk.KEY_3):    2,
300		uint(gdk.KEY_KP_3): 2,
301		uint(gdk.KEY_4):    3,
302		uint(gdk.KEY_KP_4): 3,
303		uint(gdk.KEY_5):    4,
304		uint(gdk.KEY_KP_5): 4,
305		uint(gdk.KEY_6):    5,
306		uint(gdk.KEY_KP_6): 5,
307		uint(gdk.KEY_7):    6,
308		uint(gdk.KEY_KP_7): 6,
309		uint(gdk.KEY_8):    7,
310		uint(gdk.KEY_KP_8): 7,
311		uint(gdk.KEY_9):    8,
312		uint(gdk.KEY_KP_9): 8,
313	}
314
315	if idx, ok := keyMap[keyval]; ok {
316		return idx, true
317	}
318	return 0, false
319}

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