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}