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/jwijenbergh/puregotk/v4/gdk"
10 "github.com/jwijenbergh/puregotk/v4/glib"
11 "github.com/jwijenbergh/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 []interface{}
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 if rowPtr == 0 {
237 return
238 }
239 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240 if row == nil {
241 return
242 }
243 idx := row.GetIndex()
244 tp.mu.Lock()
245 tp.selectedIndex = idx
246 tp.mu.Unlock()
247 }
248 tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
249 tp.listBox.ConnectRowSelected(&rowSelectedCb)
250
251 tp.scrolledWindow.SetChild(&tp.listBox.Widget)
252 return nil
253}
254
255func (tp *TabPicker) createFooter() error {
256 footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
257 tp.footerLabel = gtk.NewLabel(&footerText)
258 if tp.footerLabel == nil {
259 return errNilWidget("tabPickerFooterLabel")
260 }
261 tp.footerLabel.AddCssClass("tab-picker-footer")
262 tp.footerLabel.SetHalign(gtk.AlignCenterValue)
263 return nil
264}
265
266func (tp *TabPicker) assemble() {
267 if tp.outerBox == nil || tp.mainBox == nil {
268 return
269 }
270 if tp.headerBox != nil {
271 tp.mainBox.Append(&tp.headerBox.Widget)
272 }
273 if tp.scrolledWindow != nil {
274 tp.mainBox.Append(&tp.scrolledWindow.Widget)
275 }
276 if tp.footerLabel != nil {
277 tp.mainBox.Append(&tp.footerLabel.Widget)
278 }
279 tp.outerBox.Append(&tp.mainBox.Widget)
280}
281
282func (tp *TabPicker) resizeAndCenter() {
283 if tp.outerBox == nil || tp.mainBox == nil {
284 return
285 }
286
287 width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
288 tp.mainBox.SetSizeRequest(width, -1)
289 tp.outerBox.SetMarginTop(marginTop)
290
291 // Height based on rows
292 tp.mu.RLock()
293 count := len(tp.items)
294 tp.mu.RUnlock()
295
296 if tp.scrolledWindow != nil {
297 rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
298 maxH := TabPickerListDefaults.MaxVisibleRows * rowH
299 h := count * rowH
300 if h > maxH {
301 h = maxH
302 }
303 if h < rowH {
304 h = rowH
305 }
306 cb := glib.SourceFunc(func(_ uintptr) bool {
307 SetScrolledWindowHeight(tp.scrolledWindow, h)
308 tp.outerBox.QueueResize()
309 return false
310 })
311 glib.IdleAdd(&cb, 0)
312 }
313}
314
315func (tp *TabPicker) populateList() {
316 if tp.listBox == nil {
317 return
318 }
319 p := tp.listBox
320 p.RemoveAll()
321
322 tp.mu.RLock()
323 items := append([]TabPickerItem(nil), tp.items...)
324 tp.mu.RUnlock()
325
326 for _, it := range items {
327 row := gtk.NewListBoxRow()
328 if row == nil {
329 continue
330 }
331 row.AddCssClass("tab-picker-row")
332
333 const rowSpacing = 8
334 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
335 if hbox == nil {
336 continue
337 }
338 hbox.SetHexpand(true)
339
340 labelText := it.Title
341 if it.IsNew {
342 labelText = "+ New Tab"
343 }
344
345 label := gtk.NewLabel(&labelText)
346 if label != nil {
347 label.AddCssClass("tab-picker-row-title")
348 label.SetHalign(gtk.AlignStartValue)
349 label.SetHexpand(true)
350 hbox.Append(&label.Widget)
351 }
352
353 row.SetChild(&hbox.Widget)
354 p.Append(&row.Widget)
355 }
356
357 // select first row
358 if row := p.GetRowAtIndex(0); row != nil {
359 p.SelectRow(row)
360 }
361}
362
363func (tp *TabPicker) attachKeyController() {
364 controller := gtk.NewEventControllerKey()
365 if controller == nil {
366 return
367 }
368 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
369
370 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
371 return tp.handleKeyPress(keyval)
372 }
373 tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
374 controller.ConnectKeyPressed(&keyPressedCb)
375 tp.outerBox.AddController(&controller.EventController)
376}
377
378func (tp *TabPicker) handleKeyPress(keyval uint) bool {
379 switch keyval {
380 case uint(gdk.KEY_Escape):
381 tp.Hide(tp.ctx)
382 return true
383 case uint(gdk.KEY_Up), uint(gdk.KEY_k):
384 tp.selectRelative(-1)
385 return true
386 case uint(gdk.KEY_Down), uint(gdk.KEY_j):
387 tp.selectRelative(1)
388 return true
389 case uint(gdk.KEY_n):
390 tp.chooseNewTab()
391 return true
392 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
393 tp.confirmSelection()
394 return true
395 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
396 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
397 idx := int(keyval - uint(gdk.KEY_1))
398 tp.selectIndex(idx)
399 tp.confirmSelection()
400 return true
401 default:
402 return false
403 }
404}
405
406func (tp *TabPicker) selectRelative(delta int) {
407 tp.mu.Lock()
408 defer tp.mu.Unlock()
409
410 count := len(tp.items)
411 if count == 0 {
412 return
413 }
414
415 i := tp.selectedIndex + delta
416 if i < 0 {
417 i = count - 1
418 } else if i >= count {
419 i = 0
420 }
421 tp.selectedIndex = i
422 if tp.listBox != nil {
423 if row := tp.listBox.GetRowAtIndex(i); row != nil {
424 tp.listBox.SelectRow(row)
425 row.GrabFocus()
426 }
427 }
428}
429
430func (tp *TabPicker) selectIndex(index int) {
431 tp.mu.Lock()
432 defer tp.mu.Unlock()
433
434 count := len(tp.items)
435 if count == 0 {
436 return
437 }
438 if index < 0 {
439 index = 0
440 }
441 if index >= count {
442 index = count - 1
443 }
444 tp.selectedIndex = index
445 if tp.listBox != nil {
446 if row := tp.listBox.GetRowAtIndex(index); row != nil {
447 tp.listBox.SelectRow(row)
448 row.GrabFocus()
449 }
450 }
451}
452
453func (tp *TabPicker) chooseNewTab() {
454 tp.mu.RLock()
455 items := append([]TabPickerItem(nil), tp.items...)
456 tp.mu.RUnlock()
457
458 for i := range items {
459 if items[i].IsNew {
460 tp.selectIndex(i)
461 tp.confirmSelection()
462 return
463 }
464 }
465}
466
467func (tp *TabPicker) confirmSelection() {
468 tp.mu.RLock()
469 idx := tp.selectedIndex
470 items := append([]TabPickerItem(nil), tp.items...)
471 tp.mu.RUnlock()
472
473 if idx < 0 || idx >= len(items) {
474 return
475 }
476 item := items[idx]
477
478 tp.Hide(tp.ctx)
479 if tp.onSelect != nil {
480 tp.onSelect(item)
481 }
482}