v0.27.2 5960dd8

tab_picker.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close