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}