1package usecase
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "time"
8 "unicode"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/logging"
12)
13
14const (
15 defaultFindDebounce = 100 * time.Millisecond
16 maxFindMatchCount = 1000
17 findHistoryLimit = 20
18)
19
20type caseMode int
21
22const (
23 caseModeAuto caseMode = iota
24 caseModeManual
25)
26
27// FindState represents the current find-in-page state.
28type FindState struct {
29 Query string
30 MatchCount uint
31 CurrentIndex uint
32 NotFound bool
33 CaseSensitive bool
34 AtWordStarts bool
35 WrapAround bool
36 HighlightOn bool
37 HistoryEnabled bool
38}
39
40// FindInPageUseCase manages find-in-page business logic.
41type FindInPageUseCase struct {
42 mu sync.RWMutex
43 ctx context.Context
44
45 controller port.FindController
46 signalIDs []uint
47
48 query string
49 matchCount uint
50 currentIndex uint
51 notFound bool
52 navigating bool // true during SearchNext/SearchPrev to ignore found-text signals
53
54 caseMode caseMode
55 caseSensitive bool
56 atWordStarts bool
57 wrapAround bool
58 highlightOn bool
59
60 history []string
61 historyIndex int
62
63 debounceDelay time.Duration
64 debounceTimer *time.Timer
65 debounceMu sync.Mutex
66
67 onStateChange func(state FindState)
68}
69
70// NewFindInPageUseCase creates a new find-in-page use case.
71func NewFindInPageUseCase(ctx context.Context) *FindInPageUseCase {
72 return &FindInPageUseCase{
73 ctx: ctx,
74 caseMode: caseModeAuto,
75 wrapAround: true,
76 highlightOn: true,
77 historyIndex: -1,
78 debounceDelay: defaultFindDebounce,
79 }
80}
81
82// Bind attaches a FindController and connects its signals.
83func (uc *FindInPageUseCase) Bind(controller port.FindController) {
84 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
85
86 uc.mu.Lock()
87 defer uc.mu.Unlock()
88
89 uc.disconnectSignalsLocked()
90 uc.controller = controller
91 uc.signalIDs = uc.signalIDs[:0]
92
93 if controller == nil {
94 log.Debug().Msg("bind called with nil controller")
95 return
96 }
97
98 uc.signalIDs = append(uc.signalIDs,
99 controller.OnFoundText(func(matchCount uint) {
100 uc.onFoundText(matchCount)
101 }),
102 controller.OnFailedToFindText(func() {
103 uc.onFailedToFind()
104 }),
105 controller.OnCountedMatches(func(matchCount uint) {
106 uc.onCountedMatches(matchCount)
107 }),
108 )
109
110 log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound")
111}
112
113// Unbind detaches the FindController and disconnects signals.
114func (uc *FindInPageUseCase) Unbind() {
115 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
116
117 uc.mu.Lock()
118 defer uc.mu.Unlock()
119 uc.disconnectSignalsLocked()
120 uc.controller = nil
121 log.Debug().Msg("controller unbound")
122}
123
124// SetOnStateChange sets a callback for state changes.
125func (uc *FindInPageUseCase) SetOnStateChange(fn func(state FindState)) {
126 uc.mu.Lock()
127 defer uc.mu.Unlock()
128 uc.onStateChange = fn
129}
130
131// SetQuery updates the search query with debouncing.
132func (uc *FindInPageUseCase) SetQuery(text string) {
133 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
134
135 uc.mu.Lock()
136 uc.query = text
137 uc.notFound = false
138 uc.currentIndex = 0
139 uc.navigating = false // New search, accept found-text signals again
140 uc.mu.Unlock()
141
142 log.Debug().Str("query", text).Msg("query set")
143
144 if strings.TrimSpace(text) == "" {
145 uc.finishSearch()
146 uc.notifyState()
147 return
148 }
149
150 uc.debounceMu.Lock()
151 if uc.debounceTimer != nil {
152 uc.debounceTimer.Stop()
153 }
154 uc.debounceTimer = time.AfterFunc(uc.debounceDelay, func() {
155 uc.executeSearch(text)
156 })
157 uc.debounceMu.Unlock()
158
159 uc.notifyState()
160}
161
162// SearchNext selects the next match.
163func (uc *FindInPageUseCase) SearchNext() {
164 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
165
166 uc.mu.Lock()
167 controller := uc.controller
168 matchCount := uc.matchCount
169 query := uc.query
170 uc.mu.Unlock()
171
172 log.Debug().
173 Str("query", query).
174 Uint("matchCount", matchCount).
175 Bool("hasController", controller != nil).
176 Msg("SearchNext called")
177
178 if controller == nil || strings.TrimSpace(query) == "" {
179 log.Debug().Msg("SearchNext: no controller or empty query")
180 return
181 }
182 if matchCount == 0 {
183 log.Debug().Msg("SearchNext: no matches, executing fresh search")
184 uc.flushSearch(query)
185 return
186 }
187
188 uc.mu.Lock()
189 uc.navigating = true
190 uc.mu.Unlock()
191
192 log.Debug().Msg("SearchNext: calling controller.SearchNext()")
193 controller.SearchNext()
194 uc.advanceIndex(1)
195}
196
197// SearchPrevious selects the previous match.
198func (uc *FindInPageUseCase) SearchPrevious() {
199 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
200
201 uc.mu.Lock()
202 controller := uc.controller
203 matchCount := uc.matchCount
204 query := uc.query
205 uc.mu.Unlock()
206
207 log.Debug().
208 Str("query", query).
209 Uint("matchCount", matchCount).
210 Bool("hasController", controller != nil).
211 Msg("SearchPrevious called")
212
213 if controller == nil || strings.TrimSpace(query) == "" {
214 return
215 }
216 if matchCount == 0 {
217 uc.flushSearch(query)
218 return
219 }
220
221 uc.mu.Lock()
222 uc.navigating = true
223 uc.mu.Unlock()
224
225 log.Debug().Msg("SearchPrevious: calling controller.SearchPrevious()")
226 controller.SearchPrevious()
227 uc.advanceIndex(-1)
228}
229
230// Finish clears the current search and highlights.
231func (uc *FindInPageUseCase) Finish() {
232 uc.finishSearch()
233}
234
235// SetCaseSensitiveEnabled toggles manual case sensitivity.
236func (uc *FindInPageUseCase) SetCaseSensitiveEnabled(enabled bool) {
237 uc.mu.Lock()
238 if enabled {
239 uc.caseMode = caseModeManual
240 uc.caseSensitive = true
241 } else {
242 uc.caseMode = caseModeAuto
243 uc.caseSensitive = false
244 }
245 uc.mu.Unlock()
246
247 uc.executeSearch(uc.currentQuery())
248}
249
250// SetAtWordStarts toggles word-start matching.
251func (uc *FindInPageUseCase) SetAtWordStarts(enabled bool) {
252 uc.mu.Lock()
253 uc.atWordStarts = enabled
254 uc.mu.Unlock()
255
256 uc.executeSearch(uc.currentQuery())
257}
258
259// SetWrapAround toggles wrap-around searching.
260func (uc *FindInPageUseCase) SetWrapAround(enabled bool) {
261 uc.mu.Lock()
262 uc.wrapAround = enabled
263 uc.mu.Unlock()
264
265 uc.executeSearch(uc.currentQuery())
266}
267
268// SetHighlightEnabled toggles match highlighting.
269func (uc *FindInPageUseCase) SetHighlightEnabled(enabled bool) {
270 uc.mu.Lock()
271 uc.highlightOn = enabled
272 uc.mu.Unlock()
273
274 uc.executeSearch(uc.currentQuery())
275}
276
277// History returns a copy of the search history.
278func (uc *FindInPageUseCase) History() []string {
279 uc.mu.RLock()
280 defer uc.mu.RUnlock()
281
282 history := make([]string, len(uc.history))
283 copy(history, uc.history)
284 return history
285}
286
287// PrevHistory selects the previous history item.
288func (uc *FindInPageUseCase) PrevHistory() (string, bool) {
289 uc.mu.Lock()
290 defer uc.mu.Unlock()
291
292 if len(uc.history) == 0 {
293 return "", false
294 }
295 if uc.historyIndex < 0 {
296 uc.historyIndex = 0
297 } else if uc.historyIndex < len(uc.history)-1 {
298 uc.historyIndex++
299 }
300 return uc.history[uc.historyIndex], true
301}
302
303// NextHistory selects the next history item.
304func (uc *FindInPageUseCase) NextHistory() (string, bool) {
305 uc.mu.Lock()
306 defer uc.mu.Unlock()
307
308 if len(uc.history) == 0 || uc.historyIndex < 0 {
309 return "", false
310 }
311 if uc.historyIndex > 0 {
312 uc.historyIndex--
313 } else {
314 uc.historyIndex = -1
315 return "", true
316 }
317 return uc.history[uc.historyIndex], true
318}
319
320func (uc *FindInPageUseCase) executeSearch(text string) {
321 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
322
323 if strings.TrimSpace(text) == "" {
324 uc.finishSearch()
325 uc.notifyState()
326 return
327 }
328
329 uc.mu.RLock()
330 controller := uc.controller
331 uc.mu.RUnlock()
332
333 if controller == nil {
334 log.Debug().Msg("executeSearch: no controller")
335 return
336 }
337
338 uc.mu.RLock()
339 opts := uc.buildOptionsLocked(text)
340 highlight := uc.highlightOn
341 uc.mu.RUnlock()
342
343 if !highlight {
344 controller.SearchFinish()
345 uc.setMatchCount(0, true)
346 return
347 }
348
349 log.Debug().
350 Str("text", text).
351 Bool("caseInsensitive", opts.CaseInsensitive).
352 Bool("wrapAround", opts.WrapAround).
353 Msg("executing search")
354
355 controller.Search(text, opts, maxFindMatchCount)
356 uc.addHistory(text)
357 uc.notifyState()
358}
359
360func (uc *FindInPageUseCase) flushSearch(text string) {
361 uc.debounceMu.Lock()
362 if uc.debounceTimer != nil {
363 uc.debounceTimer.Stop()
364 }
365 uc.debounceTimer = nil
366 uc.debounceMu.Unlock()
367
368 uc.executeSearch(text)
369}
370
371func (uc *FindInPageUseCase) finishSearch() {
372 uc.debounceMu.Lock()
373 if uc.debounceTimer != nil {
374 uc.debounceTimer.Stop()
375 }
376 uc.debounceMu.Unlock()
377
378 uc.mu.Lock()
379 controller := uc.controller
380 uc.matchCount = 0
381 uc.currentIndex = 0
382 uc.notFound = false
383 uc.mu.Unlock()
384
385 if controller != nil {
386 controller.SearchFinish()
387 }
388 uc.notifyState()
389}
390
391func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions {
392 var caseInsensitive bool
393 if uc.caseMode == caseModeManual {
394 caseInsensitive = !uc.caseSensitive
395 } else {
396 caseInsensitive = !containsUpper(text)
397 }
398
399 return port.FindOptions{
400 CaseInsensitive: caseInsensitive,
401 AtWordStarts: uc.atWordStarts,
402 WrapAround: uc.wrapAround,
403 }
404}
405
406func (uc *FindInPageUseCase) currentQuery() string {
407 uc.mu.RLock()
408 defer uc.mu.RUnlock()
409 return uc.query
410}
411
412func (uc *FindInPageUseCase) onFoundText(matchCount uint) {
413 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
414
415 uc.mu.Lock()
416 navigating := uc.navigating
417 if navigating {
418 // During navigation, WebKit emits found-text with matchCount=1 (the current match)
419 // NOT the total count. Ignore it to preserve our actual total.
420 log.Debug().
421 Uint("signalMatchCount", matchCount).
422 Uint("preservedTotal", uc.matchCount).
423 Msg("onFoundText (navigating, ignoring)")
424 uc.mu.Unlock()
425 return
426 }
427 uc.mu.Unlock()
428
429 log.Debug().
430 Uint("matchCount", matchCount).
431 Msg("onFoundText")
432
433 uc.setMatchCount(matchCount, false)
434}
435
436func (uc *FindInPageUseCase) onFailedToFind() {
437 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
438
439 uc.mu.Lock()
440 navigating := uc.navigating
441 if navigating {
442 log.Debug().Msg("onFailedToFind (navigating, ignoring)")
443 uc.mu.Unlock()
444 return
445 }
446 uc.matchCount = 0
447 uc.currentIndex = 0
448 uc.notFound = true
449 uc.mu.Unlock()
450
451 log.Debug().Msg("onFailedToFind")
452 uc.notifyState()
453}
454
455func (uc *FindInPageUseCase) onCountedMatches(matchCount uint) {
456 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
457 log.Debug().Uint("matchCount", matchCount).Msg("onCountedMatches")
458 uc.setMatchCount(matchCount, true)
459}
460
461func (uc *FindInPageUseCase) setMatchCount(matchCount uint, counted bool) {
462 uc.mu.Lock()
463 uc.matchCount = matchCount
464 if matchCount == 0 {
465 uc.currentIndex = 0
466 uc.notFound = counted
467 } else if uc.currentIndex == 0 {
468 uc.currentIndex = 1
469 uc.notFound = false
470 }
471 uc.mu.Unlock()
472 uc.notifyState()
473}
474
475func (uc *FindInPageUseCase) advanceIndex(delta int) {
476 log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
477
478 uc.mu.Lock()
479 defer uc.mu.Unlock()
480
481 if uc.matchCount == 0 {
482 return
483 }
484
485 index := int(uc.currentIndex)
486 if index == 0 {
487 index = 1
488 }
489 index += delta
490 if index <= 0 {
491 index = int(uc.matchCount)
492 } else if index > int(uc.matchCount) {
493 index = 1
494 }
495 uc.currentIndex = uint(index)
496
497 log.Debug().
498 Int("delta", delta).
499 Uint("newIndex", uc.currentIndex).
500 Uint("matchCount", uc.matchCount).
501 Msg("advanceIndex")
502
503 uc.notifyStateLocked()
504}
505
506func (uc *FindInPageUseCase) addHistory(text string) {
507 uc.mu.Lock()
508 defer uc.mu.Unlock()
509
510 if strings.TrimSpace(text) == "" {
511 return
512 }
513
514 // Remove existing entry
515 for i, item := range uc.history {
516 if item == text {
517 uc.history = append(uc.history[:i], uc.history[i+1:]...)
518 break
519 }
520 }
521
522 uc.history = append([]string{text}, uc.history...)
523 if len(uc.history) > findHistoryLimit {
524 uc.history = uc.history[:findHistoryLimit]
525 }
526 uc.historyIndex = -1
527}
528
529func (uc *FindInPageUseCase) notifyState() {
530 uc.mu.RLock()
531 defer uc.mu.RUnlock()
532 uc.notifyStateLocked()
533}
534
535func (uc *FindInPageUseCase) notifyStateLocked() {
536 if uc.onStateChange == nil {
537 return
538 }
539
540 query := uc.query
541 caseSensitive := uc.caseSensitive
542 if uc.caseMode == caseModeAuto {
543 caseSensitive = containsUpper(query)
544 }
545
546 state := FindState{
547 Query: query,
548 MatchCount: uc.matchCount,
549 CurrentIndex: uc.currentIndex,
550 NotFound: uc.notFound,
551 CaseSensitive: caseSensitive,
552 AtWordStarts: uc.atWordStarts,
553 WrapAround: uc.wrapAround,
554 HighlightOn: uc.highlightOn,
555 HistoryEnabled: len(uc.history) > 0,
556 }
557 uc.onStateChange(state)
558}
559
560func (uc *FindInPageUseCase) disconnectSignalsLocked() {
561 if uc.controller == nil {
562 uc.signalIDs = uc.signalIDs[:0]
563 return
564 }
565
566 for _, id := range uc.signalIDs {
567 uc.controller.DisconnectSignal(id)
568 }
569 uc.signalIDs = uc.signalIDs[:0]
570}
571
572func containsUpper(text string) bool {
573 for _, r := range text {
574 if unicode.IsUpper(r) {
575 return true
576 }
577 }
578 return false
579}