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}