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}