v0.21.0 73bf644

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

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