main 5041353

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

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