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// =============================================================================
12// Keyboard navigation
13// =============================================================================
14
15func (hs *HistorySidebar) setupKeyboardNavigation() {
16 if hs.outerBox == nil {
17 return
18 }
19
20 // The ListBox already supports Up/Down arrow navigation natively.
21 // We add a PhaseCapture key controller on the outerBox to intercept
22 // keys before the ListBox processes them.
23 keyController := gtk.NewEventControllerKey()
24 if keyController == nil {
25 return
26 }
27 keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
28
29 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
30 switch keyval {
31 // --- Escape: clear search or close sidebar ---
32 case uint(gdk.KEY_Escape):
33 if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
34 hs.searchEntry.SetText("")
35 return true
36 }
37 // Close sidebar explicitly and restore focus
38 hs.closeSidebar()
39 return true
40
41 // --- Enter variants ---
42 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
43 return hs.handleEnterKey(state)
44
45 // --- Delete: remove selected entry ---
46 case uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
47 if hs.searchEntry != nil && hs.searchEntry.GetText() != "" {
48 return false
49 }
50 return hs.handleDeleteKey()
51
52 // --- PageUp / PageDown: scroll by page ---
53 case uint(gdk.KEY_Page_Up):
54 hs.scrollByPage(-1)
55 return true
56 case uint(gdk.KEY_Page_Down):
57 hs.scrollByPage(1)
58 return true
59
60 // --- Home / End: jump to first/last selectable row ---
61 case uint(gdk.KEY_Home):
62 hs.jumpToFirstSelectable()
63 return true
64 case uint(gdk.KEY_End):
65 hs.jumpToLastSelectable()
66 return true
67
68 // --- Up / Down: previous / next selectable row ---
69 // Ctrl+Up / Ctrl+Down: previous / next day group jump ---
70 case uint(gdk.KEY_Up):
71 if state&gdk.ControlMaskValue != 0 {
72 hs.jumpToPreviousDay()
73 return true
74 }
75 hs.selectPreviousRow()
76 return true
77 case uint(gdk.KEY_Down):
78 if state&gdk.ControlMaskValue != 0 {
79 hs.jumpToNextDay()
80 return true
81 }
82 hs.selectNextRow()
83 return true
84 }
85
86 return false
87 }
88
89 hs.retainedCallbacks = append(hs.retainedCallbacks, keyPressedCb)
90 keyController.ConnectKeyPressed(&keyPressedCb)
91
92 hs.outerBox.AddController(&keyController.EventController)
93}
94
95// handleEnterKey processes Enter, Ctrl+Enter, and Shift+Enter on a selected row.
96// Returns true if the key was consumed.
97func (hs *HistorySidebar) handleEnterKey(state gdk.ModifierType) bool {
98 // Determine activation mode from modifiers
99 var action HistorySidebarKeyboardAction
100
101 switch {
102 case state&gdk.ControlMaskValue != 0:
103 // Ctrl+Enter: navigate but keep sidebar open
104 action = SidebarActionKeepOpenOnActivate
105 case state&gdk.ShiftMaskValue != 0:
106 // Shift+Enter: navigate in new pane
107 action = SidebarActionNewPaneOnActivate
108 default:
109 // Plain Enter: navigate using the default activation behavior.
110 action = SidebarActionCloseOnActivate
111 }
112
113 // Find the selected row and its URL
114 row := hs.listBox.GetSelectedRow()
115 if row == nil || !row.GetSelectable() {
116 return false
117 }
118
119 hs.mu.RLock()
120 url := hs.entryURLAtIndex(row.GetIndex())
121 hs.mu.RUnlock()
122 if url == "" {
123 return false
124 }
125
126 // Schedule activation on the GTK main thread
127 switch action {
128 case SidebarActionKeepOpenOnActivate:
129 hs.navigateWithoutClosing(url)
130 case SidebarActionNewPaneOnActivate:
131 hs.navigateToNewPane(url)
132 default:
133 hs.navigateToURL(url)
134 }
135
136 // Consume the key event
137 return true
138}
139
140// handleDeleteKey removes the selected history entry and updates the selection.
141// Returns true if the key was consumed.
142func (hs *HistorySidebar) handleDeleteKey() bool {
143 row := hs.listBox.GetSelectedRow()
144 if row == nil || !row.GetSelectable() {
145 return false
146 }
147 if hs.historyUC == nil {
148 return false
149 }
150
151 idx := row.GetIndex()
152
153 hs.mu.RLock()
154 url := hs.entryURLAtIndex(idx)
155 entryID := hs.findEntryIDByIndex(idx)
156 nextSelectedURL := ""
157 if nextRow := hs.findNextSelectableAfter(idx); nextRow != -1 {
158 nextSelectedURL = hs.entryURLAtIndex(nextRow)
159 }
160 hs.mu.RUnlock()
161
162 if url == "" || entryID <= 0 {
163 return false
164 }
165
166 go func() {
167 if err := hs.historyUC.Delete(hs.ctx, entryID); err != nil {
168 hs.logger.Error().Err(err).Int64("entry_id", entryID).Msg("failed to delete history entry")
169 return
170 }
171
172 cb := glib.SourceFunc(func(uintptr) bool {
173 hs.mu.Lock()
174 if hs.destroyed {
175 hs.mu.Unlock()
176 return false
177 }
178 hs.applyDeletedEntryLocked(entryID, nextSelectedURL)
179 hs.mu.Unlock()
180
181 hs.rebuildList()
182 return false
183 })
184 hs.scheduleIdle(cb)
185 }()
186
187 return true
188}
189
190// applyDeletedEntryLocked updates local sidebar state after a successful
191// history delete. Must be called with hs.mu write lock held.
192func (hs *HistorySidebar) applyDeletedEntryLocked(entryID int64, nextSelectedURL string) {
193 hs.preserveScrollAndSelection()
194 hs.prevSelectedURL = nextSelectedURL
195 hs.searchGen++
196 hs.loadGen++
197 hs.isLoading = false
198 hs.loadStarted = false
199 hs.removeFromAllEntries(entryID)
200 hs.totalLoaded = len(hs.allEntries)
201 hs.removeFromSearchResults(entryID)
202 hs.rebuildLocalGroups()
203}
204
205// findEntryIDByIndex returns the entry ID for the linear ListBox index.
206// Must be called with hs.mu read lock held.
207func (hs *HistorySidebar) findEntryIDByIndex(index int) int64 {
208 entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(index)
209 if entry == nil {
210 return 0
211 }
212 return entry.ID
213}
214
215// rebuildLocalGroups rebuilds hs.groups from the current allEntries and query.
216// Must be called with hs.mu write lock held.
217func (hs *HistorySidebar) rebuildLocalGroups() {
218 if hs.currentQuery == "" {
219 hs.setDisplayGroupsLocked(groupHistoryByDay(hs.allEntries))
220 } else if hs.searchResults != nil {
221 hs.setDisplayGroupsLocked(groupHistoryByDay(hs.searchResults))
222 } else {
223 // For search mode, the search results are handled by doFTSearch.
224 // Removing an entry while in search mode would need a re-search.
225 // Fall back to grouping searchResults if they exist.
226 hs.setDisplayGroupsLocked(nil)
227 }
228}
229
230// removeFromAllEntries removes all history entries matching the given URL or ID
231// from hs.allEntries. Must be called with hs.mu write lock held.
232func (hs *HistorySidebar) removeFromAllEntries(id int64) {
233 filtered := make([]*entity.HistoryEntry, 0, len(hs.allEntries))
234 for _, e := range hs.allEntries {
235 if e != nil && e.ID == id {
236 continue
237 }
238 filtered = append(filtered, e)
239 }
240 hs.allEntries = filtered
241}
242
243// removeFromSearchResults removes all history entries matching the given ID
244// from hs.searchResults. Must be called with hs.mu write lock held.
245func (hs *HistorySidebar) removeFromSearchResults(id int64) {
246 if hs.searchResults == nil {
247 return
248 }
249 filtered := make([]*entity.HistoryEntry, 0, len(hs.searchResults))
250 for _, e := range hs.searchResults {
251 if e != nil && e.ID == id {
252 continue
253 }
254 filtered = append(filtered, e)
255 }
256 hs.searchResults = filtered
257}
258
259// findNextSelectableAfter returns the ListBox index of the next selectable
260// row after the given index, falling back to the previous selectable row.
261// Must be called with hs.mu read lock held.
262func (hs *HistorySidebar) findNextSelectableAfter(idx int) int {
263 model := newKeyboardNavModelFromRows(hs.displayRows)
264 if next := model.nextSelectableIndex(idx, +1); next != -1 {
265 return next
266 }
267 if prev := model.nextSelectableIndex(idx, -1); prev != -1 {
268 return prev
269 }
270 return -1
271}
272
273// scrollByPage scrolls the list by one page up or down,
274// keeping the selection visible.
275func (hs *HistorySidebar) scrollByPage(direction int) {
276 if hs.scrolledWin == nil {
277 return
278 }
279 vadj := hs.scrolledWin.GetVadjustment()
280 if vadj == nil {
281 return
282 }
283 pageSize := vadj.GetPageSize()
284 current := vadj.GetValue()
285 newVal := current + float64(direction)*(pageSize*0.9) // 90% page for overlap
286 if newVal < 0 {
287 newVal = 0
288 }
289 upper := vadj.GetUpper() - pageSize
290 if newVal > upper {
291 newVal = upper
292 }
293 if newVal >= 0 {
294 vadj.SetValue(newVal)
295 }
296}
297
298// jumpToPreviousDay selects the first entry in the previous day group
299// relative to the currently selected row.
300func (hs *HistorySidebar) jumpToPreviousDay() {
301 currentIdx := -1
302 if row := hs.listBox.GetSelectedRow(); row != nil {
303 currentIdx = row.GetIndex()
304 }
305
306 hs.mu.RLock()
307 targetIdx := newKeyboardNavModelFromRows(hs.displayRows).previousDayBoundary(currentIdx)
308 hs.mu.RUnlock()
309 if targetIdx == -1 {
310 hs.jumpToFirstSelectable()
311 return
312 }
313 if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
314 hs.listBox.SelectRow(row)
315 hs.scrollRowIntoView(targetIdx)
316 return
317 }
318 hs.jumpToFirstSelectable()
319}
320
321// jumpToNextDay selects the first entry in the next day group
322// relative to the currently selected row.
323func (hs *HistorySidebar) jumpToNextDay() {
324 currentIdx := -1
325 if row := hs.listBox.GetSelectedRow(); row != nil {
326 currentIdx = row.GetIndex()
327 }
328
329 hs.mu.RLock()
330 targetIdx := newKeyboardNavModelFromRows(hs.displayRows).nextDayBoundary(currentIdx)
331 hs.mu.RUnlock()
332 if targetIdx == -1 {
333 hs.jumpToLastSelectable()
334 return
335 }
336 if row := hs.listBox.GetRowAtIndex(targetIdx); row != nil && row.GetSelectable() {
337 hs.listBox.SelectRow(row)
338 hs.scrollRowIntoView(targetIdx)
339 return
340 }
341 hs.jumpToLastSelectable()
342}
343
344// scrollRowIntoView scrolls the scrolled window to ensure the row at
345// the given ListBox index is visible.
346func (hs *HistorySidebar) scrollRowIntoView(index int) {
347 hs.ensureRowVisible(index)
348}
349
350// jumpToFirstSelectable selects the first selectable row in the list.
351func (hs *HistorySidebar) jumpToFirstSelectable() {
352 for i := 0; ; i++ {
353 row := hs.listBox.GetRowAtIndex(i)
354 if row == nil {
355 break
356 }
357 if row.GetSelectable() {
358 hs.listBox.SelectRow(row)
359 hs.ensureRowVisible(i)
360 return
361 }
362 }
363}
364
365// jumpToLastSelectable selects the last selectable row in the list.
366func (hs *HistorySidebar) jumpToLastSelectable() {
367 // Walk backwards through the rows
368 maxIdx := 0
369 var lastRow *gtk.ListBoxRow
370 for i := 0; ; i++ {
371 row := hs.listBox.GetRowAtIndex(i)
372 if row == nil {
373 break
374 }
375 maxIdx = i
376 if row.GetSelectable() {
377 lastRow = row
378 }
379 }
380 // GetRowAtIndex returns nil once the index is past the end of the list,
381 // so this scan terminates naturally at the first missing row.
382 // If we found a selectable row, try it. Otherwise fall back to last row.
383 if lastRow != nil {
384 hs.listBox.SelectRow(lastRow)
385 hs.ensureRowVisible(lastRow.GetIndex())
386 return
387 }
388 // Fallback: last row regardless of selectability
389 if maxIdx > 0 {
390 if row := hs.listBox.GetRowAtIndex(maxIdx); row != nil {
391 hs.listBox.SelectRow(row)
392 hs.ensureRowVisible(maxIdx)
393 }
394 }
395}
396
397// =============================================================================
398// Up/Down row selection (with search entry focus preserved)
399// =============================================================================
400
401// selectPreviousRow selects the previous selectable row (skipping headers).
402// Focus remains in the search entry; the ListBox selection is updated
403// programmatically and scrolled into view.
404func (hs *HistorySidebar) selectPreviousRow() {
405 hs.selectAdjacentRow(-1)
406}
407
408// selectNextRow selects the next selectable row (skipping headers).
409// Focus remains in the search entry; the ListBox selection is updated
410// programmatically and scrolled into view.
411func (hs *HistorySidebar) selectNextRow() {
412 hs.selectAdjacentRow(1)
413}
414
415// selectAdjacentRow moves selection by direction (-1 or +1) to the next
416// selectable row, skipping non-selectable (header) rows. If nothing is
417// currently selected, it selects the first (down) or last (up) selectable
418// row. Focus remains in the search entry.
419func (hs *HistorySidebar) selectAdjacentRow(direction int) {
420 if hs.listBox == nil {
421 return
422 }
423
424 current := -1
425 if row := hs.listBox.GetSelectedRow(); row != nil {
426 current = row.GetIndex()
427 }
428
429 hs.mu.RLock()
430 model := newKeyboardNavModelFromRows(hs.displayRows)
431 var target int
432 if current < 0 {
433 if direction > 0 {
434 target = model.firstSelectableIndex()
435 } else {
436 target = model.lastSelectableIndex()
437 }
438 } else {
439 target = model.nextSelectableIndex(current, direction)
440 }
441 hs.mu.RUnlock()
442
443 if target == -1 {
444 return
445 }
446 if row := hs.listBox.GetRowAtIndex(target); row != nil && row.GetSelectable() {
447 hs.listBox.SelectRow(row)
448 hs.ensureRowVisible(target)
449 }
450}
451
452// ensureRowVisible adjusts the scrolled window so the row at index is
453// visible, WITHOUT calling GrabFocus (preserving search entry focus).
454// The Y position is computed by summing the allocated heights of all
455// preceding rows.
456func (hs *HistorySidebar) ensureRowVisible(index int) {
457 if hs.scrolledWin == nil || hs.listBox == nil {
458 return
459 }
460 vadj := hs.scrolledWin.GetVadjustment()
461 if vadj == nil {
462 return
463 }
464 row := hs.listBox.GetRowAtIndex(index)
465 if row == nil {
466 return
467 }
468
469 // Sum allocated heights of all preceding rows to estimate Y position.
470 var yPos int
471 for i := range index {
472 r := hs.listBox.GetRowAtIndex(i)
473 if r == nil {
474 continue
475 }
476 yPos += r.GetAllocatedHeight()
477 }
478
479 rowHeight := row.GetAllocatedHeight()
480 if rowHeight <= 0 {
481 return
482 }
483
484 pageSize := vadj.GetPageSize()
485 current := vadj.GetValue()
486 rowTop := float64(yPos)
487 rowBottom := rowTop + float64(rowHeight)
488
489 if rowTop < current {
490 // Row is above the visible area — scroll up.
491 vadj.SetValue(rowTop)
492 } else if rowBottom > current+pageSize {
493 // Row is below the visible area — scroll down.
494 vadj.SetValue(rowBottom - pageSize)
495 }
496}
497
498// =============================================================================
499// Row activation (Enter / click)
500// =============================================================================
501
502func (hs *HistorySidebar) onRowActivated(row *gtk.ListBoxRow) {
503 if row == nil || !row.GetSelectable() {
504 return
505 }
506
507 hs.mu.RLock()
508 // Allow activation when browse is loaded or when search results are available.
509 // This prevents a race where the user searches before the initial browse page
510 // finishes loading — browse may be left unloaded, but search results should
511 // still be activatable.
512 hasSearchResults := hs.searchDone && hs.searchResults != nil
513 if (!hs.loadDone && !hasSearchResults) || len(hs.groups) == 0 {
514 hs.mu.RUnlock()
515 return
516 }
517 entry := newKeyboardNavModelFromRows(hs.displayRows).entryAt(row.GetIndex())
518 hs.mu.RUnlock()
519 if entry == nil || entry.URL == "" {
520 return
521 }
522
523 hs.navigateToURL(entry.URL)
524}
525
526func (hs *HistorySidebar) navigateToURL(url string) {
527 if hs.onURL == nil || url == "" {
528 return
529 }
530
531 navigateCb := glib.SourceFunc(func(uintptr) bool {
532 hs.mu.RLock()
533 destroyed := hs.destroyed
534 hs.mu.RUnlock()
535 if destroyed {
536 return false
537 }
538 hs.onURL(hs.ctx, url)
539 return false
540 })
541 hs.scheduleIdle(navigateCb)
542}
543
544// navigateWithoutClosing navigates to the URL but does NOT close the sidebar.
545// Used by Ctrl+Enter activation.
546func (hs *HistorySidebar) navigateWithoutClosing(url string) {
547 if hs.onNavigateKeepOpen == nil || url == "" {
548 return
549 }
550 hs.doNavigateWithoutClose(url)
551}
552
553// doNavigateWithoutClose schedules navigation without closing the sidebar.
554// Uses the dedicated OnNavigateKeepOpen path so hosts can override the
555// default activation behavior when they need a distinct keep-open action.
556func (hs *HistorySidebar) doNavigateWithoutClose(url string) {
557 navigateCb := glib.SourceFunc(func(uintptr) bool {
558 hs.mu.RLock()
559 destroyed := hs.destroyed
560 hs.mu.RUnlock()
561 if destroyed {
562 return false
563 }
564 hs.onNavigateKeepOpen(hs.ctx, url)
565 return false
566 })
567 hs.scheduleIdle(navigateCb)
568}
569
570// navigateToNewPane navigates to the URL by opening it in a new pane.
571// The sidebar stays open. Used by Shift+Enter activation.
572func (hs *HistorySidebar) navigateToNewPane(url string) {
573 if hs.onOpenInNewPane == nil || url == "" {
574 return
575 }
576
577 navigateCb := glib.SourceFunc(func(uintptr) bool {
578 hs.mu.RLock()
579 destroyed := hs.destroyed
580 hs.mu.RUnlock()
581 if destroyed {
582 return false
583 }
584 if err := hs.onOpenInNewPane(hs.ctx, url); err != nil {
585 hs.logger.Error().Err(err).Str("url", url).Msg("history sidebar new-pane navigation failed")
586 }
587 return false
588 })
589 hs.scheduleIdle(navigateCb)
590}
591
592// closeSidebar calls the configured OnClose callback to tell the host to
593// hide the sidebar and restore focus to the active content pane/webview.
594func (hs *HistorySidebar) closeSidebar() {
595 if hs.onClose != nil {
596 hs.onClose()
597 }
598}