1package component
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/ui/layout"
9 "github.com/bnema/puregotk/v4/gdk"
10 "github.com/bnema/puregotk/v4/glib"
11 "github.com/bnema/puregotk/v4/gtk"
12)
13
14type TabPickerItem struct {
15 TabID entity.TabID
16 Title string
17 IsNew bool
18 Index int // 0-based index in tab list, -1 for + New
19}
20
21// TabPicker is a modal overlay for selecting a tab destination.
22//
23// It is intentionally similar to SessionManager/Omnibox patterns.
24type TabPicker struct {
25 // GTK widgets
26 outerBox *gtk.Box
27 mainBox *gtk.Box
28 headerBox *gtk.Box
29 titleLabel *gtk.Label
30 scrolledWindow *gtk.ScrolledWindow
31 listBox *gtk.ListBox
32 footerLabel *gtk.Label
33
34 parentOverlay layout.OverlayWidget
35
36 mu sync.RWMutex
37 visible bool
38 items []TabPickerItem
39 selectedIndex int
40 uiScale float64
41
42 onClose func()
43 onSelect func(item TabPickerItem)
44
45 retainedCallbacks []any
46 ctx context.Context
47}
48
49type TabPickerConfig struct {
50 UIScale float64
51 OnClose func()
52 OnSelect func(item TabPickerItem)
53}
54
55func NewTabPicker(ctx context.Context, cfg TabPickerConfig) *TabPicker {
56 uiScale := cfg.UIScale
57 if uiScale <= 0 {
58 uiScale = 1.0
59 }
60
61 tp := &TabPicker{
62 ctx: ctx,
63 onClose: cfg.OnClose,
64 onSelect: cfg.OnSelect,
65 selectedIndex: 0,
66 uiScale: uiScale,
67 }
68
69 if err := tp.createWidgets(); err != nil {
70 return nil
71 }
72 tp.attachKeyController()
73 return tp
74}
75
76func (tp *TabPicker) SetParentOverlay(overlay layout.OverlayWidget) {
77 tp.mu.Lock()
78 defer tp.mu.Unlock()
79 tp.parentOverlay = overlay
80}
81
82func (tp *TabPicker) Widget() *gtk.Widget {
83 if tp.outerBox == nil {
84 return nil
85 }
86 return &tp.outerBox.Widget
87}
88
89func (tp *TabPicker) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
90 if tp.outerBox == nil {
91 return nil
92 }
93 return factory.WrapWidget(&tp.outerBox.Widget)
94}
95
96func (tp *TabPicker) IsVisible() bool {
97 tp.mu.RLock()
98 defer tp.mu.RUnlock()
99 return tp.visible
100}
101
102func (tp *TabPicker) Show(ctx context.Context, items []TabPickerItem) {
103 tp.mu.Lock()
104 if tp.visible {
105 tp.mu.Unlock()
106 return
107 }
108 tp.visible = true
109 tp.items = items
110 tp.selectedIndex = 0
111 tp.mu.Unlock()
112
113 tp.populateList()
114 tp.resizeAndCenter()
115 if tp.outerBox != nil {
116 tp.outerBox.SetVisible(true)
117 }
118 if tp.listBox != nil {
119 tp.listBox.GrabFocus()
120 }
121}
122
123func (tp *TabPicker) Hide(ctx context.Context) {
124 tp.mu.Lock()
125 if !tp.visible {
126 tp.mu.Unlock()
127 return
128 }
129 tp.visible = false
130 tp.mu.Unlock()
131
132 if tp.outerBox != nil {
133 tp.outerBox.SetVisible(false)
134 }
135 if tp.listBox != nil {
136 tp.listBox.RemoveAll()
137 }
138
139 if tp.onClose != nil {
140 tp.onClose()
141 }
142}
143
144func (tp *TabPicker) Toggle(ctx context.Context, items []TabPickerItem) {
145 if tp.IsVisible() {
146 tp.Hide(ctx)
147 } else {
148 tp.Show(ctx, items)
149 }
150}
151
152func (tp *TabPicker) createWidgets() error {
153 if err := tp.createOuter(); err != nil {
154 return err
155 }
156 if err := tp.createMain(); err != nil {
157 return err
158 }
159 if err := tp.createHeader(); err != nil {
160 return err
161 }
162 if err := tp.createList(); err != nil {
163 return err
164 }
165 if err := tp.createFooter(); err != nil {
166 return err
167 }
168 tp.assemble()
169 return nil
170}
171
172func (tp *TabPicker) createOuter() error {
173 tp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
174 if tp.outerBox == nil {
175 return errNilWidget("tabPickerOuterBox")
176 }
177 tp.outerBox.AddCssClass("tab-picker-outer")
178 tp.outerBox.SetHalign(gtk.AlignCenterValue)
179 tp.outerBox.SetValign(gtk.AlignStartValue)
180 tp.outerBox.SetVisible(false)
181 return nil
182}
183
184func (tp *TabPicker) createMain() error {
185 tp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
186 if tp.mainBox == nil {
187 return errNilWidget("tabPickerMainBox")
188 }
189 tp.mainBox.AddCssClass("tab-picker-container")
190 return nil
191}
192
193func (tp *TabPicker) createHeader() error {
194 tp.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
195 if tp.headerBox == nil {
196 return errNilWidget("tabPickerHeaderBox")
197 }
198 tp.headerBox.AddCssClass("tab-picker-header")
199
200 title := "Move Pane To Tab"
201 tp.titleLabel = gtk.NewLabel(&title)
202 if tp.titleLabel == nil {
203 return errNilWidget("tabPickerTitleLabel")
204 }
205 tp.titleLabel.AddCssClass("tab-picker-title")
206 tp.titleLabel.SetHalign(gtk.AlignStartValue)
207 tp.titleLabel.SetHexpand(true)
208 tp.headerBox.Append(&tp.titleLabel.Widget)
209
210 shortcutText := "Ctrl+P m"
211 shortcutLabel := gtk.NewLabel(&shortcutText)
212 if shortcutLabel != nil {
213 shortcutLabel.AddCssClass("omnibox-shortcut-badge")
214 tp.headerBox.Append(&shortcutLabel.Widget)
215 }
216 return nil
217}
218
219func (tp *TabPicker) createList() error {
220 tp.scrolledWindow = gtk.NewScrolledWindow()
221 if tp.scrolledWindow == nil {
222 return errNilWidget("tabPickerScrolledWindow")
223 }
224 tp.scrolledWindow.AddCssClass("tab-picker-scrolled")
225 tp.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
226 tp.scrolledWindow.SetPropagateNaturalHeight(false)
227
228 tp.listBox = gtk.NewListBox()
229 if tp.listBox == nil {
230 return errNilWidget("tabPickerListBox")
231 }
232 tp.listBox.AddCssClass("tab-picker-list")
233 tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234
235 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
237 if row == nil {
238 return
239 }
240 idx := row.GetIndex()
241 tp.mu.Lock()
242 tp.selectedIndex = idx
243 tp.mu.Unlock()
244 }
245 tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
246 tp.listBox.ConnectRowSelected(&rowSelectedCb)
247
248 tp.scrolledWindow.SetChild(&tp.listBox.Widget)
249 return nil
250}
251
252func (tp *TabPicker) createFooter() error {
253 footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
254 tp.footerLabel = gtk.NewLabel(&footerText)
255 if tp.footerLabel == nil {
256 return errNilWidget("tabPickerFooterLabel")
257 }
258 tp.footerLabel.AddCssClass("tab-picker-footer")
259 tp.footerLabel.SetHalign(gtk.AlignCenterValue)
260 return nil
261}
262
263func (tp *TabPicker) assemble() {
264 if tp.outerBox == nil || tp.mainBox == nil {
265 return
266 }
267 if tp.headerBox != nil {
268 tp.mainBox.Append(&tp.headerBox.Widget)
269 }
270 if tp.scrolledWindow != nil {
271 tp.mainBox.Append(&tp.scrolledWindow.Widget)
272 }
273 if tp.footerLabel != nil {
274 tp.mainBox.Append(&tp.footerLabel.Widget)
275 }
276 tp.outerBox.Append(&tp.mainBox.Widget)
277}
278
279func (tp *TabPicker) resizeAndCenter() {
280 if tp.outerBox == nil || tp.mainBox == nil {
281 return
282 }
283
284 width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
285 tp.mainBox.SetSizeRequest(width, -1)
286 tp.outerBox.SetMarginTop(marginTop)
287
288 // Height based on rows
289 tp.mu.RLock()
290 count := len(tp.items)
291 tp.mu.RUnlock()
292
293 if tp.scrolledWindow != nil {
294 rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
295 maxH := TabPickerListDefaults.MaxVisibleRows * rowH
296 h := max(min(count*rowH, maxH), rowH)
297 cb := glib.SourceFunc(func(_ uintptr) bool {
298 SetScrolledWindowHeight(tp.scrolledWindow, h)
299 tp.outerBox.QueueResize()
300 return false
301 })
302 glib.IdleAdd(&cb, 0)
303 }
304}
305
306func (tp *TabPicker) populateList() {
307 if tp.listBox == nil {
308 return
309 }
310 p := tp.listBox
311 p.RemoveAll()
312
313 tp.mu.RLock()
314 items := append([]TabPickerItem(nil), tp.items...)
315 tp.mu.RUnlock()
316
317 for _, it := range items {
318 row := gtk.NewListBoxRow()
319 if row == nil {
320 continue
321 }
322 row.AddCssClass("tab-picker-row")
323
324 const rowSpacing = 8
325 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
326 if hbox == nil {
327 continue
328 }
329 hbox.SetHexpand(true)
330
331 labelText := it.Title
332 if it.IsNew {
333 labelText = "+ New Tab"
334 }
335
336 label := gtk.NewLabel(&labelText)
337 if label != nil {
338 label.AddCssClass("tab-picker-row-title")
339 label.SetHalign(gtk.AlignStartValue)
340 label.SetHexpand(true)
341 hbox.Append(&label.Widget)
342 }
343
344 row.SetChild(&hbox.Widget)
345 p.Append(&row.Widget)
346 }
347
348 // select first row
349 if row := p.GetRowAtIndex(0); row != nil {
350 p.SelectRow(row)
351 }
352}
353
354func (tp *TabPicker) attachKeyController() {
355 controller := gtk.NewEventControllerKey()
356 if controller == nil {
357 return
358 }
359 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
360
361 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
362 return tp.handleKeyPress(keyval)
363 }
364 tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
365 controller.ConnectKeyPressed(&keyPressedCb)
366 tp.outerBox.AddController(&controller.EventController)
367}
368
369func (tp *TabPicker) handleKeyPress(keyval uint) bool {
370 switch keyval {
371 case uint(gdk.KEY_Escape):
372 tp.Hide(tp.ctx)
373 return true
374 case uint(gdk.KEY_Up), uint(gdk.KEY_k):
375 tp.selectRelative(-1)
376 return true
377 case uint(gdk.KEY_Down), uint(gdk.KEY_j):
378 tp.selectRelative(1)
379 return true
380 case uint(gdk.KEY_n):
381 tp.chooseNewTab()
382 return true
383 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
384 tp.confirmSelection()
385 return true
386 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
387 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
388 idx := int(keyval - uint(gdk.KEY_1))
389 tp.selectIndex(idx)
390 tp.confirmSelection()
391 return true
392 default:
393 return false
394 }
395}
396
397func (tp *TabPicker) selectRelative(delta int) {
398 tp.mu.Lock()
399 defer tp.mu.Unlock()
400
401 count := len(tp.items)
402 if count == 0 {
403 return
404 }
405
406 i := tp.selectedIndex + delta
407 if i < 0 {
408 i = count - 1
409 } else if i >= count {
410 i = 0
411 }
412 tp.selectedIndex = i
413 if tp.listBox != nil {
414 if row := tp.listBox.GetRowAtIndex(i); row != nil {
415 tp.listBox.SelectRow(row)
416 row.GrabFocus()
417 }
418 }
419}
420
421func (tp *TabPicker) selectIndex(index int) {
422 tp.mu.Lock()
423 defer tp.mu.Unlock()
424
425 count := len(tp.items)
426 if count == 0 {
427 return
428 }
429 if index < 0 {
430 index = 0
431 }
432 if index >= count {
433 index = count - 1
434 }
435 tp.selectedIndex = index
436 if tp.listBox != nil {
437 if row := tp.listBox.GetRowAtIndex(index); row != nil {
438 tp.listBox.SelectRow(row)
439 row.GrabFocus()
440 }
441 }
442}
443
444func (tp *TabPicker) chooseNewTab() {
445 tp.mu.RLock()
446 items := append([]TabPickerItem(nil), tp.items...)
447 tp.mu.RUnlock()
448
449 for i := range items {
450 if items[i].IsNew {
451 tp.selectIndex(i)
452 tp.confirmSelection()
453 return
454 }
455 }
456}
457
458func (tp *TabPicker) confirmSelection() {
459 tp.mu.RLock()
460 idx := tp.selectedIndex
461 items := append([]TabPickerItem(nil), tp.items...)
462 tp.mu.RUnlock()
463
464 if idx < 0 || idx >= len(items) {
465 return
466 }
467 item := items[idx]
468
469 tp.Hide(tp.ctx)
470 if tp.onSelect != nil {
471 tp.onSelect(item)
472 }
473}