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}