v0.23.2 bfd5eb4

find_bar.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"fmt"
7	"sync"
8
9	"github.com/bnema/dumber/internal/application/port"
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/layout"
14	"github.com/jwijenbergh/puregotk/v4/gdk"
15	"github.com/jwijenbergh/puregotk/v4/glib"
16	"github.com/jwijenbergh/puregotk/v4/gtk"
17)
18
19const (
20	findBarMarginPx   = 8
21	findBarRowSpacing = 6
22	findBarCountWidth = 6
23)
24
25// FindBar is a compact find-in-page UI overlay.
26type FindBar struct {
27	outerBox     *gtk.Box
28	containerBox *gtk.Box
29	inputRow     *gtk.Box
30	optionsRow   *gtk.Box
31	entry        *gtk.SearchEntry
32	prevBtn      *gtk.Button
33	nextBtn      *gtk.Button
34	countLabel   *gtk.Label
35	closeBtn     *gtk.Button
36	caseToggle   *gtk.ToggleButton
37	wordToggle   *gtk.ToggleButton
38	hlToggle     *gtk.ToggleButton
39
40	uc *usecase.FindInPageUseCase
41
42	visible           bool
43	mu                sync.RWMutex
44	ctx               context.Context
45	onClose           func()
46	retainedCallbacks []interface{} // Keep callbacks alive for GC
47}
48
49// FindBarConfig holds configuration for creating a FindBar.
50type FindBarConfig struct {
51	OnClose           func()
52	GetFindController func(paneID entity.PaneID) port.FindController
53	OnFocusIn         func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
54	OnFocusOut        func()                       // Callback when entry loses focus
55}
56
57// NewFindBar creates a new FindBar component.
58func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar {
59	log := logging.FromContext(ctx)
60
61	fb := &FindBar{
62		ctx:     ctx,
63		onClose: cfg.OnClose,
64		uc:      usecase.NewFindInPageUseCase(ctx),
65	}
66
67	if err := fb.createWidgets(); err != nil {
68		log.Error().Err(err).Msg("failed to create find bar widgets")
69		return nil
70	}
71
72	fb.setupHandlers()
73	fb.bindUseCase()
74	fb.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
75
76	log.Debug().Msg("find bar created")
77	return fb
78}
79
80// SetFindController attaches the FindController to the use case.
81func (fb *FindBar) SetFindController(controller port.FindController) {
82	if fb.uc != nil {
83		fb.uc.Bind(controller)
84	}
85}
86
87// WidgetAsLayout returns the find bar's outer widget as a layout.Widget.
88func (fb *FindBar) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
89	if fb.outerBox == nil {
90		return nil
91	}
92	return factory.WrapWidget(&fb.outerBox.Widget)
93}
94
95// Show displays the find bar and focuses the entry.
96func (fb *FindBar) Show() {
97	fb.mu.Lock()
98	defer fb.mu.Unlock()
99
100	if fb.outerBox == nil {
101		return
102	}
103
104	fb.outerBox.SetVisible(true)
105	fb.visible = true
106
107	if fb.entry != nil {
108		fb.entry.GrabFocus()
109		text := fb.entry.GetText()
110		if text != "" {
111			fb.entry.SelectRegion(0, len(text))
112		}
113	}
114}
115
116// Hide hides the find bar and clears highlights.
117func (fb *FindBar) Hide() {
118	fb.mu.Lock()
119	defer fb.mu.Unlock()
120
121	if fb.outerBox == nil {
122		return
123	}
124
125	if fb.uc != nil {
126		fb.uc.Finish()
127		fb.uc.Unbind()
128	}
129
130	fb.outerBox.SetVisible(false)
131	fb.visible = false
132}
133
134// IsVisible returns whether the find bar is visible.
135func (fb *FindBar) IsVisible() bool {
136	fb.mu.RLock()
137	defer fb.mu.RUnlock()
138	return fb.visible
139}
140
141// FindNext moves to the next match.
142func (fb *FindBar) FindNext() {
143	if fb.uc != nil {
144		fb.uc.SearchNext()
145	}
146}
147
148// FindPrevious moves to the previous match.
149func (fb *FindBar) FindPrevious() {
150	if fb.uc != nil {
151		fb.uc.SearchPrevious()
152	}
153}
154
155func (fb *FindBar) createWidgets() error {
156	if err := fb.initContainerWidgets(); err != nil {
157		return err
158	}
159	if err := fb.initInputRowWidgets(); err != nil {
160		return err
161	}
162	if err := fb.initOptionsRowWidgets(); err != nil {
163		return err
164	}
165	fb.assembleWidgets()
166	return nil
167}
168
169func (fb *FindBar) setupHandlers() {
170	fb.connectEntryHandlers()
171	fb.connectNavHandlers()
172	fb.connectToggleHandlers()
173	fb.attachKeyController()
174}
175
176func (fb *FindBar) initContainerWidgets() error {
177	fb.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
178	if fb.outerBox == nil {
179		return errNilWidget("findOuterBox")
180	}
181	fb.outerBox.AddCssClass("find-bar-outer")
182	fb.outerBox.SetHalign(gtk.AlignEndValue)
183	fb.outerBox.SetValign(gtk.AlignStartValue)
184	fb.outerBox.SetVisible(false)
185	fb.outerBox.SetMarginTop(findBarMarginPx)
186	fb.outerBox.SetMarginEnd(findBarMarginPx)
187
188	fb.containerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
189	if fb.containerBox == nil {
190		return errNilWidget("findContainerBox")
191	}
192	fb.containerBox.AddCssClass("find-bar-container")
193	return nil
194}
195
196func (fb *FindBar) initInputRowWidgets() error {
197	fb.inputRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
198	if fb.inputRow == nil {
199		return errNilWidget("findInputRow")
200	}
201	fb.inputRow.AddCssClass("find-bar-input-row")
202	fb.inputRow.SetHalign(gtk.AlignFillValue)
203
204	fb.entry = gtk.NewSearchEntry()
205	if fb.entry == nil {
206		return errNilWidget("findEntry")
207	}
208	fb.entry.AddCssClass("find-bar-entry")
209	fb.entry.SetHexpand(true)
210	placeholder := "Find in page..."
211	fb.entry.SetPlaceholderText(&placeholder)
212
213	fb.prevBtn = gtk.NewButtonWithLabel("Prev")
214	if fb.prevBtn == nil {
215		return errNilWidget("findPrevBtn")
216	}
217	fb.prevBtn.AddCssClass("find-bar-nav")
218
219	fb.nextBtn = gtk.NewButtonWithLabel("Next")
220	if fb.nextBtn == nil {
221		return errNilWidget("findNextBtn")
222	}
223	fb.nextBtn.AddCssClass("find-bar-nav")
224
225	fb.countLabel = gtk.NewLabel(nil)
226	if fb.countLabel == nil {
227		return errNilWidget("findCountLabel")
228	}
229	fb.countLabel.AddCssClass("find-bar-count")
230	fb.countLabel.SetWidthChars(findBarCountWidth)
231
232	fb.closeBtn = gtk.NewButtonWithLabel("X")
233	if fb.closeBtn == nil {
234		return errNilWidget("findCloseBtn")
235	}
236	fb.closeBtn.AddCssClass("find-bar-close")
237	return nil
238}
239
240func (fb *FindBar) initOptionsRowWidgets() error {
241	fb.optionsRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
242	if fb.optionsRow == nil {
243		return errNilWidget("findOptionsRow")
244	}
245	fb.optionsRow.AddCssClass("find-bar-options-row")
246
247	fb.caseToggle = gtk.NewToggleButtonWithLabel("Aa")
248	if fb.caseToggle == nil {
249		return errNilWidget("findCaseToggle")
250	}
251	fb.caseToggle.AddCssClass("find-bar-toggle")
252
253	fb.wordToggle = gtk.NewToggleButtonWithLabel("W")
254	if fb.wordToggle == nil {
255		return errNilWidget("findWordToggle")
256	}
257	fb.wordToggle.AddCssClass("find-bar-toggle")
258
259	fb.hlToggle = gtk.NewToggleButtonWithLabel("Hl")
260	if fb.hlToggle == nil {
261		return errNilWidget("findHlToggle")
262	}
263	fb.hlToggle.AddCssClass("find-bar-toggle")
264	fb.hlToggle.SetActive(true)
265	return nil
266}
267
268func (fb *FindBar) assembleWidgets() {
269	fb.inputRow.Append(&fb.entry.Widget)
270	fb.inputRow.Append(&fb.prevBtn.Widget)
271	fb.inputRow.Append(&fb.nextBtn.Widget)
272	fb.inputRow.Append(&fb.countLabel.Widget)
273	fb.inputRow.Append(&fb.closeBtn.Widget)
274
275	fb.optionsRow.Append(&fb.caseToggle.Widget)
276	fb.optionsRow.Append(&fb.wordToggle.Widget)
277	fb.optionsRow.Append(&fb.hlToggle.Widget)
278
279	fb.containerBox.Append(&fb.inputRow.Widget)
280	fb.containerBox.Append(&fb.optionsRow.Widget)
281	fb.outerBox.Append(&fb.containerBox.Widget)
282}
283
284func (fb *FindBar) connectEntryHandlers() {
285	if fb.entry == nil {
286		return
287	}
288	changedCb := func(_ gtk.SearchEntry) {
289		if fb.uc != nil {
290			fb.uc.SetQuery(fb.entry.GetText())
291		}
292	}
293	fb.entry.ConnectSearchChanged(&changedCb)
294}
295
296func (fb *FindBar) connectNavHandlers() {
297	if fb.prevBtn != nil {
298		prevCb := func(_ gtk.Button) {
299			fb.FindPrevious()
300		}
301		fb.prevBtn.ConnectClicked(&prevCb)
302	}
303
304	if fb.nextBtn != nil {
305		nextCb := func(_ gtk.Button) {
306			fb.FindNext()
307		}
308		fb.nextBtn.ConnectClicked(&nextCb)
309	}
310
311	if fb.closeBtn != nil {
312		closeCb := func(_ gtk.Button) {
313			fb.Hide()
314			if fb.onClose != nil {
315				fb.onClose()
316			}
317		}
318		fb.closeBtn.ConnectClicked(&closeCb)
319	}
320}
321
322func (fb *FindBar) connectToggleHandlers() {
323	if fb.caseToggle != nil {
324		caseCb := func(_ gtk.ToggleButton) {
325			if fb.uc != nil {
326				fb.uc.SetCaseSensitiveEnabled(fb.caseToggle.GetActive())
327			}
328		}
329		fb.caseToggle.ConnectToggled(&caseCb)
330	}
331
332	if fb.wordToggle != nil {
333		wordCb := func(_ gtk.ToggleButton) {
334			if fb.uc != nil {
335				fb.uc.SetAtWordStarts(fb.wordToggle.GetActive())
336			}
337		}
338		fb.wordToggle.ConnectToggled(&wordCb)
339	}
340
341	if fb.hlToggle != nil {
342		hlCb := func(_ gtk.ToggleButton) {
343			if fb.uc != nil {
344				fb.uc.SetHighlightEnabled(fb.hlToggle.GetActive())
345			}
346		}
347		fb.hlToggle.ConnectToggled(&hlCb)
348	}
349}
350
351func (fb *FindBar) attachKeyController() {
352	controller := gtk.NewEventControllerKey()
353	if controller == nil {
354		return
355	}
356	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
357	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
358		switch keyval {
359		case uint(gdk.KEY_Escape):
360			fb.Hide()
361			if fb.onClose != nil {
362				fb.onClose()
363			}
364			return true
365		case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
366			if state&gdk.ShiftMaskValue != 0 {
367				fb.FindPrevious()
368			} else {
369				fb.FindNext()
370			}
371			return true
372		}
373		return false
374	}
375	controller.ConnectKeyPressed(&keyPressedCb)
376	fb.outerBox.AddController(&controller.EventController)
377}
378
379func (fb *FindBar) bindUseCase() {
380	if fb.uc == nil {
381		return
382	}
383
384	fb.uc.SetOnStateChange(func(state usecase.FindState) {
385		cb := glib.SourceFunc(func(_ uintptr) bool {
386			if fb.entry == nil || fb.countLabel == nil {
387				return false
388			}
389
390			text := ""
391			if state.Query != "" {
392				text = fmt.Sprintf("%d/%d", state.CurrentIndex, state.MatchCount)
393			}
394			fb.countLabel.SetText(text)
395
396			if state.MatchCount > 0 {
397				fb.countLabel.AddCssClass("find-bar-count-has")
398			} else {
399				fb.countLabel.RemoveCssClass("find-bar-count-has")
400			}
401
402			if state.NotFound && state.Query != "" {
403				fb.entry.AddCssClass("not-found")
404			} else {
405				fb.entry.RemoveCssClass("not-found")
406			}
407			return false
408		})
409		glib.IdleAdd(&cb, 0)
410	})
411}
412
413// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
414func (fb *FindBar) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
415	if onFocusIn == nil && onFocusOut == nil {
416		return
417	}
418
419	if fb.entry == nil {
420		return
421	}
422
423	focusController := gtk.NewEventControllerFocus()
424	if focusController == nil {
425		return
426	}
427
428	if onFocusIn != nil {
429		entry := fb.entry // Capture for closure
430		focusInCb := func(_ gtk.EventControllerFocus) {
431			onFocusIn(entry)
432		}
433		fb.retainedCallbacks = append(fb.retainedCallbacks, focusInCb)
434		focusController.ConnectEnter(&focusInCb)
435	}
436
437	if onFocusOut != nil {
438		focusOutCb := func(_ gtk.EventControllerFocus) {
439			onFocusOut()
440		}
441		fb.retainedCallbacks = append(fb.retainedCallbacks, focusOutCb)
442		focusController.ConnectLeave(&focusOutCb)
443	}
444
445	fb.entry.AddController(&focusController.EventController)
446}

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