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 []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 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 := count * rowH
297 if h > maxH {
298 h = maxH
299 }
300 if h < rowH {
301 h = rowH
302 }
303 cb := glib.SourceFunc(func(_ uintptr) bool {
304 SetScrolledWindowHeight(tp.scrolledWindow, h)
305 tp.outerBox.QueueResize()
306 return false
307 })
308 glib.IdleAdd(&cb, 0)
309 }
310}
311
312func (tp *TabPicker) populateList() {
313 if tp.listBox == nil {
314 return
315 }
316 p := tp.listBox
317 p.RemoveAll()
318
319 tp.mu.RLock()
320 items := append([]TabPickerItem(nil), tp.items...)
321 tp.mu.RUnlock()
322
323 for _, it := range items {
324 row := gtk.NewListBoxRow()
325 if row == nil {
326 continue
327 }
328 row.AddCssClass("tab-picker-row")
329
330 const rowSpacing = 8
331 hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
332 if hbox == nil {
333 continue
334 }
335 hbox.SetHexpand(true)
336
337 labelText := it.Title
338 if it.IsNew {
339 labelText = "+ New Tab"
340 }
341
342 label := gtk.NewLabel(&labelText)
343 if label != nil {
344 label.AddCssClass("tab-picker-row-title")
345 label.SetHalign(gtk.AlignStartValue)
346 label.SetHexpand(true)
347 hbox.Append(&label.Widget)
348 }
349
350 row.SetChild(&hbox.Widget)
351 p.Append(&row.Widget)
352 }
353
354 // select first row
355 if row := p.GetRowAtIndex(0); row != nil {
356 p.SelectRow(row)
357 }
358}
359
360func (tp *TabPicker) attachKeyController() {
361 controller := gtk.NewEventControllerKey()
362 if controller == nil {
363 return
364 }
365 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
366
367 keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
368 return tp.handleKeyPress(keyval)
369 }
370 tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
371 controller.ConnectKeyPressed(&keyPressedCb)
372 tp.outerBox.AddController(&controller.EventController)
373}
374
375func (tp *TabPicker) handleKeyPress(keyval uint) bool {
376 switch keyval {
377 case uint(gdk.KEY_Escape):
378 tp.Hide(tp.ctx)
379 return true
380 case uint(gdk.KEY_Up), uint(gdk.KEY_k):
381 tp.selectRelative(-1)
382 return true
383 case uint(gdk.KEY_Down), uint(gdk.KEY_j):
384 tp.selectRelative(1)
385 return true
386 case uint(gdk.KEY_n):
387 tp.chooseNewTab()
388 return true
389 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
390 tp.confirmSelection()
391 return true
392 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
393 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
394 idx := int(keyval - uint(gdk.KEY_1))
395 tp.selectIndex(idx)
396 tp.confirmSelection()
397 return true
398 default:
399 return false
400 }
401}
402
403func (tp *TabPicker) selectRelative(delta int) {
404 tp.mu.Lock()
405 defer tp.mu.Unlock()
406
407 count := len(tp.items)
408 if count == 0 {
409 return
410 }
411
412 i := tp.selectedIndex + delta
413 if i < 0 {
414 i = count - 1
415 } else if i >= count {
416 i = 0
417 }
418 tp.selectedIndex = i
419 if tp.listBox != nil {
420 if row := tp.listBox.GetRowAtIndex(i); row != nil {
421 tp.listBox.SelectRow(row)
422 row.GrabFocus()
423 }
424 }
425}
426
427func (tp *TabPicker) selectIndex(index int) {
428 tp.mu.Lock()
429 defer tp.mu.Unlock()
430
431 count := len(tp.items)
432 if count == 0 {
433 return
434 }
435 if index < 0 {
436 index = 0
437 }
438 if index >= count {
439 index = count - 1
440 }
441 tp.selectedIndex = index
442 if tp.listBox != nil {
443 if row := tp.listBox.GetRowAtIndex(index); row != nil {
444 tp.listBox.SelectRow(row)
445 row.GrabFocus()
446 }
447 }
448}
449
450func (tp *TabPicker) chooseNewTab() {
451 tp.mu.RLock()
452 items := append([]TabPickerItem(nil), tp.items...)
453 tp.mu.RUnlock()
454
455 for i := range items {
456 if items[i].IsNew {
457 tp.selectIndex(i)
458 tp.confirmSelection()
459 return
460 }
461 }
462}
463
464func (tp *TabPicker) confirmSelection() {
465 tp.mu.RLock()
466 idx := tp.selectedIndex
467 items := append([]TabPickerItem(nil), tp.items...)
468 tp.mu.RUnlock()
469
470 if idx < 0 || idx >= len(items) {
471 return
472 }
473 item := items[idx]
474
475 tp.Hide(tp.ctx)
476 if tp.onSelect != nil {
477 tp.onSelect(item)
478 }
479}