detached 225e37e

feat(find): add find-in-page functionality with keyboard shortcut

commit
225e37e3b3a8aa2a3470896c65f8618881010c08
author
bnema · · 2025-12-21 16:40 UTC
parent
eb2b6f9
signature
unsigned
12 files · +1589 -6
M internal/application/port/mocks/mocks.go +46 -0
@@ -247,6 +247,52 @@ func (_c *MockWebView_GetZoomLevel_Call) RunAndReturn(run func() float64) *MockW
247247 return _c
248248 }
249249
250+// GetFindController provides a mock function for the type MockWebView
251+func (_mock *MockWebView) GetFindController() port.FindController {
252+ ret := _mock.Called()
253+
254+ if len(ret) == 0 {
255+ panic("no return value specified for GetFindController")
256+ }
257+
258+ var r0 port.FindController
259+ if returnFunc, ok := ret.Get(0).(func() port.FindController); ok {
260+ r0 = returnFunc()
261+ } else {
262+ if ret.Get(0) != nil {
263+ r0 = ret.Get(0).(port.FindController)
264+ }
265+ }
266+ return r0
267+}
268+
269+// MockWebView_GetFindController_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFindController'
270+type MockWebView_GetFindController_Call struct {
271+ *mock.Call
272+}
273+
274+// GetFindController is a helper method to define mock.On call
275+func (_e *MockWebView_Expecter) GetFindController() *MockWebView_GetFindController_Call {
276+ return &MockWebView_GetFindController_Call{Call: _e.mock.On("GetFindController")}
277+}
278+
279+func (_c *MockWebView_GetFindController_Call) Run(run func()) *MockWebView_GetFindController_Call {
280+ _c.Call.Run(func(args mock.Arguments) {
281+ run()
282+ })
283+ return _c
284+}
285+
286+func (_c *MockWebView_GetFindController_Call) Return(controller port.FindController) *MockWebView_GetFindController_Call {
287+ _c.Call.Return(controller)
288+ return _c
289+}
290+
291+func (_c *MockWebView_GetFindController_Call) RunAndReturn(run func() port.FindController) *MockWebView_GetFindController_Call {
292+ _c.Call.Return(run)
293+ return _c
294+}
295+
250296 // GoBack provides a mock function for the type MockWebView
251297 func (_mock *MockWebView) GoBack(ctx context.Context) error {
252298 ret := _mock.Called(ctx)
@@ -247,6 +247,52 @@ func (_c *MockWebView_GetZoomLevel_Call) RunAndReturn(run func() float64) *MockW
247 return _c247 return _c
248 }248 }
249 249
250+// GetFindController provides a mock function for the type MockWebView
251+func (_mock *MockWebView) GetFindController() port.FindController {
252+ ret := _mock.Called()
253+
254+ if len(ret) == 0 {
255+ panic("no return value specified for GetFindController")
256+ }
257+
258+ var r0 port.FindController
259+ if returnFunc, ok := ret.Get(0).(func() port.FindController); ok {
260+ r0 = returnFunc()
261+ } else {
262+ if ret.Get(0) != nil {
263+ r0 = ret.Get(0).(port.FindController)
264+ }
265+ }
266+ return r0
267+}
268+
269+// MockWebView_GetFindController_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFindController'
270+type MockWebView_GetFindController_Call struct {
271+ *mock.Call
272+}
273+
274+// GetFindController is a helper method to define mock.On call
275+func (_e *MockWebView_Expecter) GetFindController() *MockWebView_GetFindController_Call {
276+ return &MockWebView_GetFindController_Call{Call: _e.mock.On("GetFindController")}
277+}
278+
279+func (_c *MockWebView_GetFindController_Call) Run(run func()) *MockWebView_GetFindController_Call {
280+ _c.Call.Run(func(args mock.Arguments) {
281+ run()
282+ })
283+ return _c
284+}
285+
286+func (_c *MockWebView_GetFindController_Call) Return(controller port.FindController) *MockWebView_GetFindController_Call {
287+ _c.Call.Return(controller)
288+ return _c
289+}
290+
291+func (_c *MockWebView_GetFindController_Call) RunAndReturn(run func() port.FindController) *MockWebView_GetFindController_Call {
292+ _c.Call.Return(run)
293+ return _c
294+}
295+
250 // GoBack provides a mock function for the type MockWebView296 // GoBack provides a mock function for the type MockWebView
251 func (_mock *MockWebView) GoBack(ctx context.Context) error {297 func (_mock *MockWebView) GoBack(ctx context.Context) error {
252 ret := _mock.Called(ctx)298 ret := _mock.Called(ctx)
M internal/application/port/webview.go +29 -0
@@ -91,6 +91,29 @@ type WebViewCallbacks struct {
9191 OnReadyToShow func()
9292 }
9393
94+// FindOptions configures search behavior.
95+type FindOptions struct {
96+ CaseInsensitive bool
97+ AtWordStarts bool
98+ WrapAround bool
99+}
100+
101+// FindController abstracts WebKit's FindController for clean architecture.
102+type FindController interface {
103+ Search(text string, options FindOptions, maxMatches uint)
104+ CountMatches(text string, options FindOptions, maxMatches uint)
105+ SearchNext()
106+ SearchPrevious()
107+ SearchFinish()
108+ GetSearchText() string
109+
110+ // Signal connections
111+ OnFoundText(callback func(matchCount uint)) uint32
112+ OnFailedToFindText(callback func()) uint32
113+ OnCountedMatches(callback func(matchCount uint)) uint32
114+ DisconnectSignal(id uint32)
115+}
116+
94117 // WebView defines the port interface for browser view operations.
95118 // This interface abstracts the underlying browser engine (WebKit, etc.)
96119 // and exposes only the navigation and state capabilities needed by
@@ -155,6 +178,12 @@ type WebView interface {
155178 // GetZoomLevel returns the current zoom level.
156179 GetZoomLevel() float64
157180
181+ // --- Find ---
182+
183+ // GetFindController returns the find controller for text search.
184+ // Returns nil if find is not supported.
185+ GetFindController() FindController
186+
158187 // --- Callbacks ---
159188
160189 // SetCallbacks registers callback handlers for WebView events.
@@ -91,6 +91,29 @@ type WebViewCallbacks struct {
91 OnReadyToShow func()91 OnReadyToShow func()
92 }92 }
93 93
94+// FindOptions configures search behavior.
95+type FindOptions struct {
96+ CaseInsensitive bool
97+ AtWordStarts bool
98+ WrapAround bool
99+}
100+
101+// FindController abstracts WebKit's FindController for clean architecture.
102+type FindController interface {
103+ Search(text string, options FindOptions, maxMatches uint)
104+ CountMatches(text string, options FindOptions, maxMatches uint)
105+ SearchNext()
106+ SearchPrevious()
107+ SearchFinish()
108+ GetSearchText() string
109+
110+ // Signal connections
111+ OnFoundText(callback func(matchCount uint)) uint32
112+ OnFailedToFindText(callback func()) uint32
113+ OnCountedMatches(callback func(matchCount uint)) uint32
114+ DisconnectSignal(id uint32)
115+}
116+
94 // WebView defines the port interface for browser view operations.117 // WebView defines the port interface for browser view operations.
95 // This interface abstracts the underlying browser engine (WebKit, etc.)118 // This interface abstracts the underlying browser engine (WebKit, etc.)
96 // and exposes only the navigation and state capabilities needed by119 // and exposes only the navigation and state capabilities needed by
@@ -155,6 +178,12 @@ type WebView interface {
155 // GetZoomLevel returns the current zoom level.178 // GetZoomLevel returns the current zoom level.
156 GetZoomLevel() float64179 GetZoomLevel() float64
157 180
181+ // --- Find ---
182+
183+ // GetFindController returns the find controller for text search.
184+ // Returns nil if find is not supported.
185+ GetFindController() FindController
186+
158 // --- Callbacks ---187 // --- Callbacks ---
159 188
160 // SetCallbacks registers callback handlers for WebView events.189 // SetCallbacks registers callback handlers for WebView events.
A internal/application/usecase/find_in_page.go +577 -0
@@ -0,0 +1,577 @@
1+package usecase
2+
3+import (
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+
14+const (
15+ defaultFindDebounce = 100 * time.Millisecond
16+ maxFindMatchCount = 1000
17+ findHistoryLimit = 20
18+)
19+
20+type caseMode int
21+
22+const (
23+ caseModeAuto caseMode = iota
24+ caseModeManual
25+)
26+
27+// FindState represents the current find-in-page state.
28+type 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.
41+type FindInPageUseCase struct {
42+ mu sync.RWMutex
43+ ctx context.Context
44+
45+ controller port.FindController
46+ signalIDs []uint32
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.
71+func 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.
83+func (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, controller.OnFoundText(func(matchCount uint) {
99+ uc.onFoundText(matchCount)
100+ }))
101+ uc.signalIDs = append(uc.signalIDs, controller.OnFailedToFindText(func() {
102+ uc.onFailedToFind()
103+ }))
104+ uc.signalIDs = append(uc.signalIDs, controller.OnCountedMatches(func(matchCount uint) {
105+ uc.onCountedMatches(matchCount)
106+ }))
107+
108+ log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound")
109+}
110+
111+// Unbind detaches the FindController and disconnects signals.
112+func (uc *FindInPageUseCase) Unbind() {
113+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
114+
115+ uc.mu.Lock()
116+ defer uc.mu.Unlock()
117+ uc.disconnectSignalsLocked()
118+ uc.controller = nil
119+ log.Debug().Msg("controller unbound")
120+}
121+
122+// SetOnStateChange sets a callback for state changes.
123+func (uc *FindInPageUseCase) SetOnStateChange(fn func(state FindState)) {
124+ uc.mu.Lock()
125+ defer uc.mu.Unlock()
126+ uc.onStateChange = fn
127+}
128+
129+// SetQuery updates the search query with debouncing.
130+func (uc *FindInPageUseCase) SetQuery(text string) {
131+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
132+
133+ uc.mu.Lock()
134+ uc.query = text
135+ uc.notFound = false
136+ uc.currentIndex = 0
137+ uc.navigating = false // New search, accept found-text signals again
138+ uc.mu.Unlock()
139+
140+ log.Debug().Str("query", text).Msg("query set")
141+
142+ if strings.TrimSpace(text) == "" {
143+ uc.finishSearch()
144+ uc.notifyState()
145+ return
146+ }
147+
148+ uc.debounceMu.Lock()
149+ if uc.debounceTimer != nil {
150+ uc.debounceTimer.Stop()
151+ }
152+ uc.debounceTimer = time.AfterFunc(uc.debounceDelay, func() {
153+ uc.executeSearch(text)
154+ })
155+ uc.debounceMu.Unlock()
156+
157+ uc.notifyState()
158+}
159+
160+// SearchNext selects the next match.
161+func (uc *FindInPageUseCase) SearchNext() {
162+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
163+
164+ uc.mu.Lock()
165+ controller := uc.controller
166+ matchCount := uc.matchCount
167+ query := uc.query
168+ uc.mu.Unlock()
169+
170+ log.Debug().
171+ Str("query", query).
172+ Uint("matchCount", matchCount).
173+ Bool("hasController", controller != nil).
174+ Msg("SearchNext called")
175+
176+ if controller == nil || strings.TrimSpace(query) == "" {
177+ log.Debug().Msg("SearchNext: no controller or empty query")
178+ return
179+ }
180+ if matchCount == 0 {
181+ log.Debug().Msg("SearchNext: no matches, executing fresh search")
182+ uc.flushSearch(query)
183+ return
184+ }
185+
186+ uc.mu.Lock()
187+ uc.navigating = true
188+ uc.mu.Unlock()
189+
190+ log.Debug().Msg("SearchNext: calling controller.SearchNext()")
191+ controller.SearchNext()
192+ uc.advanceIndex(1)
193+}
194+
195+// SearchPrevious selects the previous match.
196+func (uc *FindInPageUseCase) SearchPrevious() {
197+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
198+
199+ uc.mu.Lock()
200+ controller := uc.controller
201+ matchCount := uc.matchCount
202+ query := uc.query
203+ uc.mu.Unlock()
204+
205+ log.Debug().
206+ Str("query", query).
207+ Uint("matchCount", matchCount).
208+ Bool("hasController", controller != nil).
209+ Msg("SearchPrevious called")
210+
211+ if controller == nil || strings.TrimSpace(query) == "" {
212+ return
213+ }
214+ if matchCount == 0 {
215+ uc.flushSearch(query)
216+ return
217+ }
218+
219+ uc.mu.Lock()
220+ uc.navigating = true
221+ uc.mu.Unlock()
222+
223+ log.Debug().Msg("SearchPrevious: calling controller.SearchPrevious()")
224+ controller.SearchPrevious()
225+ uc.advanceIndex(-1)
226+}
227+
228+// Finish clears the current search and highlights.
229+func (uc *FindInPageUseCase) Finish() {
230+ uc.finishSearch()
231+}
232+
233+// SetCaseSensitiveEnabled toggles manual case sensitivity.
234+func (uc *FindInPageUseCase) SetCaseSensitiveEnabled(enabled bool) {
235+ uc.mu.Lock()
236+ if enabled {
237+ uc.caseMode = caseModeManual
238+ uc.caseSensitive = true
239+ } else {
240+ uc.caseMode = caseModeAuto
241+ uc.caseSensitive = false
242+ }
243+ uc.mu.Unlock()
244+
245+ uc.executeSearch(uc.currentQuery())
246+}
247+
248+// SetAtWordStarts toggles word-start matching.
249+func (uc *FindInPageUseCase) SetAtWordStarts(enabled bool) {
250+ uc.mu.Lock()
251+ uc.atWordStarts = enabled
252+ uc.mu.Unlock()
253+
254+ uc.executeSearch(uc.currentQuery())
255+}
256+
257+// SetWrapAround toggles wrap-around searching.
258+func (uc *FindInPageUseCase) SetWrapAround(enabled bool) {
259+ uc.mu.Lock()
260+ uc.wrapAround = enabled
261+ uc.mu.Unlock()
262+
263+ uc.executeSearch(uc.currentQuery())
264+}
265+
266+// SetHighlightEnabled toggles match highlighting.
267+func (uc *FindInPageUseCase) SetHighlightEnabled(enabled bool) {
268+ uc.mu.Lock()
269+ uc.highlightOn = enabled
270+ uc.mu.Unlock()
271+
272+ uc.executeSearch(uc.currentQuery())
273+}
274+
275+// History returns a copy of the search history.
276+func (uc *FindInPageUseCase) History() []string {
277+ uc.mu.RLock()
278+ defer uc.mu.RUnlock()
279+
280+ history := make([]string, len(uc.history))
281+ copy(history, uc.history)
282+ return history
283+}
284+
285+// PrevHistory selects the previous history item.
286+func (uc *FindInPageUseCase) PrevHistory() (string, bool) {
287+ uc.mu.Lock()
288+ defer uc.mu.Unlock()
289+
290+ if len(uc.history) == 0 {
291+ return "", false
292+ }
293+ if uc.historyIndex < 0 {
294+ uc.historyIndex = 0
295+ } else if uc.historyIndex < len(uc.history)-1 {
296+ uc.historyIndex++
297+ }
298+ return uc.history[uc.historyIndex], true
299+}
300+
301+// NextHistory selects the next history item.
302+func (uc *FindInPageUseCase) NextHistory() (string, bool) {
303+ uc.mu.Lock()
304+ defer uc.mu.Unlock()
305+
306+ if len(uc.history) == 0 || uc.historyIndex < 0 {
307+ return "", false
308+ }
309+ if uc.historyIndex > 0 {
310+ uc.historyIndex--
311+ } else {
312+ uc.historyIndex = -1
313+ return "", true
314+ }
315+ return uc.history[uc.historyIndex], true
316+}
317+
318+func (uc *FindInPageUseCase) executeSearch(text string) {
319+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
320+
321+ if strings.TrimSpace(text) == "" {
322+ uc.finishSearch()
323+ uc.notifyState()
324+ return
325+ }
326+
327+ uc.mu.RLock()
328+ controller := uc.controller
329+ uc.mu.RUnlock()
330+
331+ if controller == nil {
332+ log.Debug().Msg("executeSearch: no controller")
333+ return
334+ }
335+
336+ uc.mu.RLock()
337+ opts := uc.buildOptionsLocked(text)
338+ highlight := uc.highlightOn
339+ uc.mu.RUnlock()
340+
341+ if !highlight {
342+ controller.SearchFinish()
343+ uc.setMatchCount(0, true)
344+ return
345+ }
346+
347+ log.Debug().
348+ Str("text", text).
349+ Bool("caseInsensitive", opts.CaseInsensitive).
350+ Bool("wrapAround", opts.WrapAround).
351+ Msg("executing search")
352+
353+ controller.Search(text, opts, maxFindMatchCount)
354+ uc.addHistory(text)
355+ uc.notifyState()
356+}
357+
358+func (uc *FindInPageUseCase) flushSearch(text string) {
359+ uc.debounceMu.Lock()
360+ if uc.debounceTimer != nil {
361+ uc.debounceTimer.Stop()
362+ }
363+ uc.debounceTimer = nil
364+ uc.debounceMu.Unlock()
365+
366+ uc.executeSearch(text)
367+}
368+
369+func (uc *FindInPageUseCase) finishSearch() {
370+ uc.debounceMu.Lock()
371+ if uc.debounceTimer != nil {
372+ uc.debounceTimer.Stop()
373+ }
374+ uc.debounceMu.Unlock()
375+
376+ uc.mu.Lock()
377+ controller := uc.controller
378+ uc.matchCount = 0
379+ uc.currentIndex = 0
380+ uc.notFound = false
381+ uc.mu.Unlock()
382+
383+ if controller != nil {
384+ controller.SearchFinish()
385+ }
386+ uc.notifyState()
387+}
388+
389+func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions {
390+ caseInsensitive := true
391+ if uc.caseMode == caseModeManual {
392+ caseInsensitive = !uc.caseSensitive
393+ } else {
394+ caseInsensitive = !containsUpper(text)
395+ }
396+
397+ return port.FindOptions{
398+ CaseInsensitive: caseInsensitive,
399+ AtWordStarts: uc.atWordStarts,
400+ WrapAround: uc.wrapAround,
401+ }
402+}
403+
404+func (uc *FindInPageUseCase) currentQuery() string {
405+ uc.mu.RLock()
406+ defer uc.mu.RUnlock()
407+ return uc.query
408+}
409+
410+func (uc *FindInPageUseCase) onFoundText(matchCount uint) {
411+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
412+
413+ uc.mu.Lock()
414+ navigating := uc.navigating
415+ if navigating {
416+ // During navigation, WebKit emits found-text with matchCount=1 (the current match)
417+ // NOT the total count. Ignore it to preserve our actual total.
418+ log.Debug().
419+ Uint("signalMatchCount", matchCount).
420+ Uint("preservedTotal", uc.matchCount).
421+ Msg("onFoundText (navigating, ignoring)")
422+ uc.mu.Unlock()
423+ return
424+ }
425+ uc.mu.Unlock()
426+
427+ log.Debug().
428+ Uint("matchCount", matchCount).
429+ Msg("onFoundText")
430+
431+ uc.setMatchCount(matchCount, false)
432+}
433+
434+func (uc *FindInPageUseCase) onFailedToFind() {
435+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
436+
437+ uc.mu.Lock()
438+ navigating := uc.navigating
439+ if navigating {
440+ log.Debug().Msg("onFailedToFind (navigating, ignoring)")
441+ uc.mu.Unlock()
442+ return
443+ }
444+ uc.matchCount = 0
445+ uc.currentIndex = 0
446+ uc.notFound = true
447+ uc.mu.Unlock()
448+
449+ log.Debug().Msg("onFailedToFind")
450+ uc.notifyState()
451+}
452+
453+func (uc *FindInPageUseCase) onCountedMatches(matchCount uint) {
454+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
455+ log.Debug().Uint("matchCount", matchCount).Msg("onCountedMatches")
456+ uc.setMatchCount(matchCount, true)
457+}
458+
459+func (uc *FindInPageUseCase) setMatchCount(matchCount uint, counted bool) {
460+ uc.mu.Lock()
461+ uc.matchCount = matchCount
462+ if matchCount == 0 {
463+ uc.currentIndex = 0
464+ uc.notFound = counted
465+ } else if uc.currentIndex == 0 {
466+ uc.currentIndex = 1
467+ uc.notFound = false
468+ }
469+ uc.mu.Unlock()
470+ uc.notifyState()
471+}
472+
473+func (uc *FindInPageUseCase) advanceIndex(delta int) {
474+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
475+
476+ uc.mu.Lock()
477+ defer uc.mu.Unlock()
478+
479+ if uc.matchCount == 0 {
480+ return
481+ }
482+
483+ index := int(uc.currentIndex)
484+ if index == 0 {
485+ index = 1
486+ }
487+ index += delta
488+ if index <= 0 {
489+ index = int(uc.matchCount)
490+ } else if index > int(uc.matchCount) {
491+ index = 1
492+ }
493+ uc.currentIndex = uint(index)
494+
495+ log.Debug().
496+ Int("delta", delta).
497+ Uint("newIndex", uc.currentIndex).
498+ Uint("matchCount", uc.matchCount).
499+ Msg("advanceIndex")
500+
501+ uc.notifyStateLocked()
502+}
503+
504+func (uc *FindInPageUseCase) addHistory(text string) {
505+ uc.mu.Lock()
506+ defer uc.mu.Unlock()
507+
508+ if strings.TrimSpace(text) == "" {
509+ return
510+ }
511+
512+ // Remove existing entry
513+ for i, item := range uc.history {
514+ if item == text {
515+ uc.history = append(uc.history[:i], uc.history[i+1:]...)
516+ break
517+ }
518+ }
519+
520+ uc.history = append([]string{text}, uc.history...)
521+ if len(uc.history) > findHistoryLimit {
522+ uc.history = uc.history[:findHistoryLimit]
523+ }
524+ uc.historyIndex = -1
525+}
526+
527+func (uc *FindInPageUseCase) notifyState() {
528+ uc.mu.RLock()
529+ defer uc.mu.RUnlock()
530+ uc.notifyStateLocked()
531+}
532+
533+func (uc *FindInPageUseCase) notifyStateLocked() {
534+ if uc.onStateChange == nil {
535+ return
536+ }
537+
538+ query := uc.query
539+ caseSensitive := uc.caseSensitive
540+ if uc.caseMode == caseModeAuto {
541+ caseSensitive = containsUpper(query)
542+ }
543+
544+ state := FindState{
545+ Query: query,
546+ MatchCount: uc.matchCount,
547+ CurrentIndex: uc.currentIndex,
548+ NotFound: uc.notFound,
549+ CaseSensitive: caseSensitive,
550+ AtWordStarts: uc.atWordStarts,
551+ WrapAround: uc.wrapAround,
552+ HighlightOn: uc.highlightOn,
553+ HistoryEnabled: len(uc.history) > 0,
554+ }
555+ uc.onStateChange(state)
556+}
557+
558+func (uc *FindInPageUseCase) disconnectSignalsLocked() {
559+ if uc.controller == nil {
560+ uc.signalIDs = uc.signalIDs[:0]
561+ return
562+ }
563+
564+ for _, id := range uc.signalIDs {
565+ uc.controller.DisconnectSignal(id)
566+ }
567+ uc.signalIDs = uc.signalIDs[:0]
568+}
569+
570+func containsUpper(text string) bool {
571+ for _, r := range text {
572+ if unicode.IsUpper(r) {
573+ return true
574+ }
575+ }
576+ return false
577+}
@@ -0,0 +1,577 @@
1+package usecase
2+
3+import (
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+
14+const (
15+ defaultFindDebounce = 100 * time.Millisecond
16+ maxFindMatchCount = 1000
17+ findHistoryLimit = 20
18+)
19+
20+type caseMode int
21+
22+const (
23+ caseModeAuto caseMode = iota
24+ caseModeManual
25+)
26+
27+// FindState represents the current find-in-page state.
28+type 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.
41+type FindInPageUseCase struct {
42+ mu sync.RWMutex
43+ ctx context.Context
44+
45+ controller port.FindController
46+ signalIDs []uint32
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.
71+func 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.
83+func (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, controller.OnFoundText(func(matchCount uint) {
99+ uc.onFoundText(matchCount)
100+ }))
101+ uc.signalIDs = append(uc.signalIDs, controller.OnFailedToFindText(func() {
102+ uc.onFailedToFind()
103+ }))
104+ uc.signalIDs = append(uc.signalIDs, controller.OnCountedMatches(func(matchCount uint) {
105+ uc.onCountedMatches(matchCount)
106+ }))
107+
108+ log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound")
109+}
110+
111+// Unbind detaches the FindController and disconnects signals.
112+func (uc *FindInPageUseCase) Unbind() {
113+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
114+
115+ uc.mu.Lock()
116+ defer uc.mu.Unlock()
117+ uc.disconnectSignalsLocked()
118+ uc.controller = nil
119+ log.Debug().Msg("controller unbound")
120+}
121+
122+// SetOnStateChange sets a callback for state changes.
123+func (uc *FindInPageUseCase) SetOnStateChange(fn func(state FindState)) {
124+ uc.mu.Lock()
125+ defer uc.mu.Unlock()
126+ uc.onStateChange = fn
127+}
128+
129+// SetQuery updates the search query with debouncing.
130+func (uc *FindInPageUseCase) SetQuery(text string) {
131+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
132+
133+ uc.mu.Lock()
134+ uc.query = text
135+ uc.notFound = false
136+ uc.currentIndex = 0
137+ uc.navigating = false // New search, accept found-text signals again
138+ uc.mu.Unlock()
139+
140+ log.Debug().Str("query", text).Msg("query set")
141+
142+ if strings.TrimSpace(text) == "" {
143+ uc.finishSearch()
144+ uc.notifyState()
145+ return
146+ }
147+
148+ uc.debounceMu.Lock()
149+ if uc.debounceTimer != nil {
150+ uc.debounceTimer.Stop()
151+ }
152+ uc.debounceTimer = time.AfterFunc(uc.debounceDelay, func() {
153+ uc.executeSearch(text)
154+ })
155+ uc.debounceMu.Unlock()
156+
157+ uc.notifyState()
158+}
159+
160+// SearchNext selects the next match.
161+func (uc *FindInPageUseCase) SearchNext() {
162+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
163+
164+ uc.mu.Lock()
165+ controller := uc.controller
166+ matchCount := uc.matchCount
167+ query := uc.query
168+ uc.mu.Unlock()
169+
170+ log.Debug().
171+ Str("query", query).
172+ Uint("matchCount", matchCount).
173+ Bool("hasController", controller != nil).
174+ Msg("SearchNext called")
175+
176+ if controller == nil || strings.TrimSpace(query) == "" {
177+ log.Debug().Msg("SearchNext: no controller or empty query")
178+ return
179+ }
180+ if matchCount == 0 {
181+ log.Debug().Msg("SearchNext: no matches, executing fresh search")
182+ uc.flushSearch(query)
183+ return
184+ }
185+
186+ uc.mu.Lock()
187+ uc.navigating = true
188+ uc.mu.Unlock()
189+
190+ log.Debug().Msg("SearchNext: calling controller.SearchNext()")
191+ controller.SearchNext()
192+ uc.advanceIndex(1)
193+}
194+
195+// SearchPrevious selects the previous match.
196+func (uc *FindInPageUseCase) SearchPrevious() {
197+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
198+
199+ uc.mu.Lock()
200+ controller := uc.controller
201+ matchCount := uc.matchCount
202+ query := uc.query
203+ uc.mu.Unlock()
204+
205+ log.Debug().
206+ Str("query", query).
207+ Uint("matchCount", matchCount).
208+ Bool("hasController", controller != nil).
209+ Msg("SearchPrevious called")
210+
211+ if controller == nil || strings.TrimSpace(query) == "" {
212+ return
213+ }
214+ if matchCount == 0 {
215+ uc.flushSearch(query)
216+ return
217+ }
218+
219+ uc.mu.Lock()
220+ uc.navigating = true
221+ uc.mu.Unlock()
222+
223+ log.Debug().Msg("SearchPrevious: calling controller.SearchPrevious()")
224+ controller.SearchPrevious()
225+ uc.advanceIndex(-1)
226+}
227+
228+// Finish clears the current search and highlights.
229+func (uc *FindInPageUseCase) Finish() {
230+ uc.finishSearch()
231+}
232+
233+// SetCaseSensitiveEnabled toggles manual case sensitivity.
234+func (uc *FindInPageUseCase) SetCaseSensitiveEnabled(enabled bool) {
235+ uc.mu.Lock()
236+ if enabled {
237+ uc.caseMode = caseModeManual
238+ uc.caseSensitive = true
239+ } else {
240+ uc.caseMode = caseModeAuto
241+ uc.caseSensitive = false
242+ }
243+ uc.mu.Unlock()
244+
245+ uc.executeSearch(uc.currentQuery())
246+}
247+
248+// SetAtWordStarts toggles word-start matching.
249+func (uc *FindInPageUseCase) SetAtWordStarts(enabled bool) {
250+ uc.mu.Lock()
251+ uc.atWordStarts = enabled
252+ uc.mu.Unlock()
253+
254+ uc.executeSearch(uc.currentQuery())
255+}
256+
257+// SetWrapAround toggles wrap-around searching.
258+func (uc *FindInPageUseCase) SetWrapAround(enabled bool) {
259+ uc.mu.Lock()
260+ uc.wrapAround = enabled
261+ uc.mu.Unlock()
262+
263+ uc.executeSearch(uc.currentQuery())
264+}
265+
266+// SetHighlightEnabled toggles match highlighting.
267+func (uc *FindInPageUseCase) SetHighlightEnabled(enabled bool) {
268+ uc.mu.Lock()
269+ uc.highlightOn = enabled
270+ uc.mu.Unlock()
271+
272+ uc.executeSearch(uc.currentQuery())
273+}
274+
275+// History returns a copy of the search history.
276+func (uc *FindInPageUseCase) History() []string {
277+ uc.mu.RLock()
278+ defer uc.mu.RUnlock()
279+
280+ history := make([]string, len(uc.history))
281+ copy(history, uc.history)
282+ return history
283+}
284+
285+// PrevHistory selects the previous history item.
286+func (uc *FindInPageUseCase) PrevHistory() (string, bool) {
287+ uc.mu.Lock()
288+ defer uc.mu.Unlock()
289+
290+ if len(uc.history) == 0 {
291+ return "", false
292+ }
293+ if uc.historyIndex < 0 {
294+ uc.historyIndex = 0
295+ } else if uc.historyIndex < len(uc.history)-1 {
296+ uc.historyIndex++
297+ }
298+ return uc.history[uc.historyIndex], true
299+}
300+
301+// NextHistory selects the next history item.
302+func (uc *FindInPageUseCase) NextHistory() (string, bool) {
303+ uc.mu.Lock()
304+ defer uc.mu.Unlock()
305+
306+ if len(uc.history) == 0 || uc.historyIndex < 0 {
307+ return "", false
308+ }
309+ if uc.historyIndex > 0 {
310+ uc.historyIndex--
311+ } else {
312+ uc.historyIndex = -1
313+ return "", true
314+ }
315+ return uc.history[uc.historyIndex], true
316+}
317+
318+func (uc *FindInPageUseCase) executeSearch(text string) {
319+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
320+
321+ if strings.TrimSpace(text) == "" {
322+ uc.finishSearch()
323+ uc.notifyState()
324+ return
325+ }
326+
327+ uc.mu.RLock()
328+ controller := uc.controller
329+ uc.mu.RUnlock()
330+
331+ if controller == nil {
332+ log.Debug().Msg("executeSearch: no controller")
333+ return
334+ }
335+
336+ uc.mu.RLock()
337+ opts := uc.buildOptionsLocked(text)
338+ highlight := uc.highlightOn
339+ uc.mu.RUnlock()
340+
341+ if !highlight {
342+ controller.SearchFinish()
343+ uc.setMatchCount(0, true)
344+ return
345+ }
346+
347+ log.Debug().
348+ Str("text", text).
349+ Bool("caseInsensitive", opts.CaseInsensitive).
350+ Bool("wrapAround", opts.WrapAround).
351+ Msg("executing search")
352+
353+ controller.Search(text, opts, maxFindMatchCount)
354+ uc.addHistory(text)
355+ uc.notifyState()
356+}
357+
358+func (uc *FindInPageUseCase) flushSearch(text string) {
359+ uc.debounceMu.Lock()
360+ if uc.debounceTimer != nil {
361+ uc.debounceTimer.Stop()
362+ }
363+ uc.debounceTimer = nil
364+ uc.debounceMu.Unlock()
365+
366+ uc.executeSearch(text)
367+}
368+
369+func (uc *FindInPageUseCase) finishSearch() {
370+ uc.debounceMu.Lock()
371+ if uc.debounceTimer != nil {
372+ uc.debounceTimer.Stop()
373+ }
374+ uc.debounceMu.Unlock()
375+
376+ uc.mu.Lock()
377+ controller := uc.controller
378+ uc.matchCount = 0
379+ uc.currentIndex = 0
380+ uc.notFound = false
381+ uc.mu.Unlock()
382+
383+ if controller != nil {
384+ controller.SearchFinish()
385+ }
386+ uc.notifyState()
387+}
388+
389+func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions {
390+ caseInsensitive := true
391+ if uc.caseMode == caseModeManual {
392+ caseInsensitive = !uc.caseSensitive
393+ } else {
394+ caseInsensitive = !containsUpper(text)
395+ }
396+
397+ return port.FindOptions{
398+ CaseInsensitive: caseInsensitive,
399+ AtWordStarts: uc.atWordStarts,
400+ WrapAround: uc.wrapAround,
401+ }
402+}
403+
404+func (uc *FindInPageUseCase) currentQuery() string {
405+ uc.mu.RLock()
406+ defer uc.mu.RUnlock()
407+ return uc.query
408+}
409+
410+func (uc *FindInPageUseCase) onFoundText(matchCount uint) {
411+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
412+
413+ uc.mu.Lock()
414+ navigating := uc.navigating
415+ if navigating {
416+ // During navigation, WebKit emits found-text with matchCount=1 (the current match)
417+ // NOT the total count. Ignore it to preserve our actual total.
418+ log.Debug().
419+ Uint("signalMatchCount", matchCount).
420+ Uint("preservedTotal", uc.matchCount).
421+ Msg("onFoundText (navigating, ignoring)")
422+ uc.mu.Unlock()
423+ return
424+ }
425+ uc.mu.Unlock()
426+
427+ log.Debug().
428+ Uint("matchCount", matchCount).
429+ Msg("onFoundText")
430+
431+ uc.setMatchCount(matchCount, false)
432+}
433+
434+func (uc *FindInPageUseCase) onFailedToFind() {
435+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
436+
437+ uc.mu.Lock()
438+ navigating := uc.navigating
439+ if navigating {
440+ log.Debug().Msg("onFailedToFind (navigating, ignoring)")
441+ uc.mu.Unlock()
442+ return
443+ }
444+ uc.matchCount = 0
445+ uc.currentIndex = 0
446+ uc.notFound = true
447+ uc.mu.Unlock()
448+
449+ log.Debug().Msg("onFailedToFind")
450+ uc.notifyState()
451+}
452+
453+func (uc *FindInPageUseCase) onCountedMatches(matchCount uint) {
454+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
455+ log.Debug().Uint("matchCount", matchCount).Msg("onCountedMatches")
456+ uc.setMatchCount(matchCount, true)
457+}
458+
459+func (uc *FindInPageUseCase) setMatchCount(matchCount uint, counted bool) {
460+ uc.mu.Lock()
461+ uc.matchCount = matchCount
462+ if matchCount == 0 {
463+ uc.currentIndex = 0
464+ uc.notFound = counted
465+ } else if uc.currentIndex == 0 {
466+ uc.currentIndex = 1
467+ uc.notFound = false
468+ }
469+ uc.mu.Unlock()
470+ uc.notifyState()
471+}
472+
473+func (uc *FindInPageUseCase) advanceIndex(delta int) {
474+ log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
475+
476+ uc.mu.Lock()
477+ defer uc.mu.Unlock()
478+
479+ if uc.matchCount == 0 {
480+ return
481+ }
482+
483+ index := int(uc.currentIndex)
484+ if index == 0 {
485+ index = 1
486+ }
487+ index += delta
488+ if index <= 0 {
489+ index = int(uc.matchCount)
490+ } else if index > int(uc.matchCount) {
491+ index = 1
492+ }
493+ uc.currentIndex = uint(index)
494+
495+ log.Debug().
496+ Int("delta", delta).
497+ Uint("newIndex", uc.currentIndex).
498+ Uint("matchCount", uc.matchCount).
499+ Msg("advanceIndex")
500+
501+ uc.notifyStateLocked()
502+}
503+
504+func (uc *FindInPageUseCase) addHistory(text string) {
505+ uc.mu.Lock()
506+ defer uc.mu.Unlock()
507+
508+ if strings.TrimSpace(text) == "" {
509+ return
510+ }
511+
512+ // Remove existing entry
513+ for i, item := range uc.history {
514+ if item == text {
515+ uc.history = append(uc.history[:i], uc.history[i+1:]...)
516+ break
517+ }
518+ }
519+
520+ uc.history = append([]string{text}, uc.history...)
521+ if len(uc.history) > findHistoryLimit {
522+ uc.history = uc.history[:findHistoryLimit]
523+ }
524+ uc.historyIndex = -1
525+}
526+
527+func (uc *FindInPageUseCase) notifyState() {
528+ uc.mu.RLock()
529+ defer uc.mu.RUnlock()
530+ uc.notifyStateLocked()
531+}
532+
533+func (uc *FindInPageUseCase) notifyStateLocked() {
534+ if uc.onStateChange == nil {
535+ return
536+ }
537+
538+ query := uc.query
539+ caseSensitive := uc.caseSensitive
540+ if uc.caseMode == caseModeAuto {
541+ caseSensitive = containsUpper(query)
542+ }
543+
544+ state := FindState{
545+ Query: query,
546+ MatchCount: uc.matchCount,
547+ CurrentIndex: uc.currentIndex,
548+ NotFound: uc.notFound,
549+ CaseSensitive: caseSensitive,
550+ AtWordStarts: uc.atWordStarts,
551+ WrapAround: uc.wrapAround,
552+ HighlightOn: uc.highlightOn,
553+ HistoryEnabled: len(uc.history) > 0,
554+ }
555+ uc.onStateChange(state)
556+}
557+
558+func (uc *FindInPageUseCase) disconnectSignalsLocked() {
559+ if uc.controller == nil {
560+ uc.signalIDs = uc.signalIDs[:0]
561+ return
562+ }
563+
564+ for _, id := range uc.signalIDs {
565+ uc.controller.DisconnectSignal(id)
566+ }
567+ uc.signalIDs = uc.signalIDs[:0]
568+}
569+
570+func containsUpper(text string) bool {
571+ for _, r := range text {
572+ if unicode.IsUpper(r) {
573+ return true
574+ }
575+ }
576+ return false
577+}
M internal/infrastructure/webkit/injector.go +26 -0
@@ -91,6 +91,7 @@ const darkModeScript = `(function() {
9191 type ContentInjector struct {
9292 prefersDark bool
9393 themeCSSVars string // CSS custom property declarations for WebUI
94+ findCSS string // CSS for find-in-page highlight styling
9495 }
9596
9697 // NewContentInjector creates a new injector instance.
@@ -111,6 +112,14 @@ func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error
111112 return nil
112113 }
113114
115+// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
116+func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
117+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
118+ ci.findCSS = css
119+ log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
120+ return nil
121+}
122+
114123 // PrefersDark returns the current dark mode preference.
115124 func (ci *ContentInjector) PrefersDark() bool {
116125 return ci.prefersDark
@@ -204,5 +213,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
204213 log.Debug().Msg("theme CSS vars injection configured")
205214 }
206215
216+ // 5. Inject find highlight CSS for all pages
217+ if ci.findCSS != "" {
218+ stylesheet := webkit.NewUserStyleSheet(
219+ ci.findCSS,
220+ webkit.UserContentInjectAllFramesValue,
221+ webkit.UserStyleLevelUserValue,
222+ nil,
223+ nil,
224+ )
225+ if stylesheet == nil {
226+ log.Warn().Msg("failed to create find highlight stylesheet")
227+ } else {
228+ ucm.AddStyleSheet(stylesheet)
229+ log.Debug().Msg("find highlight stylesheet injected")
230+ }
231+ }
232+
207233 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
208234 }
@@ -91,6 +91,7 @@ const darkModeScript = `(function() {
91 type ContentInjector struct {91 type ContentInjector struct {
92 prefersDark bool92 prefersDark bool
93 themeCSSVars string // CSS custom property declarations for WebUI93 themeCSSVars string // CSS custom property declarations for WebUI
94+ findCSS string // CSS for find-in-page highlight styling
94 }95 }
95 96
96 // NewContentInjector creates a new injector instance.97 // NewContentInjector creates a new injector instance.
@@ -111,6 +112,14 @@ func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error
111 return nil112 return nil
112 }113 }
113 114
115+// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
116+func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
117+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
118+ ci.findCSS = css
119+ log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
120+ return nil
121+}
122+
114 // PrefersDark returns the current dark mode preference.123 // PrefersDark returns the current dark mode preference.
115 func (ci *ContentInjector) PrefersDark() bool {124 func (ci *ContentInjector) PrefersDark() bool {
116 return ci.prefersDark125 return ci.prefersDark
@@ -204,5 +213,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
204 log.Debug().Msg("theme CSS vars injection configured")213 log.Debug().Msg("theme CSS vars injection configured")
205 }214 }
206 215
216+ // 5. Inject find highlight CSS for all pages
217+ if ci.findCSS != "" {
218+ stylesheet := webkit.NewUserStyleSheet(
219+ ci.findCSS,
220+ webkit.UserContentInjectAllFramesValue,
221+ webkit.UserStyleLevelUserValue,
222+ nil,
223+ nil,
224+ )
225+ if stylesheet == nil {
226+ log.Warn().Msg("failed to create find highlight stylesheet")
227+ } else {
228+ ucm.AddStyleSheet(stylesheet)
229+ log.Debug().Msg("find highlight stylesheet injected")
230+ }
231+ }
232+
207 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")233 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
208 }234 }
M internal/infrastructure/webkit/webview.go +125 -0
@@ -115,6 +115,114 @@ type WebView struct {
115115
116116 // asyncCallbacks keeps references to async JS callbacks to prevent GC
117117 asyncCallbacks []interface{}
118+
119+ // findController is cached to prevent GC from collecting the Go wrapper
120+ findController *findControllerAdapter
121+ findControllerOnce sync.Once
122+}
123+
124+type findControllerAdapter struct {
125+ fc *webkit.FindController
126+}
127+
128+func (a *findControllerAdapter) Search(text string, opts port.FindOptions, maxMatches uint) {
129+ if a == nil || a.fc == nil {
130+ return
131+ }
132+ var flags uint32
133+ if opts.WrapAround {
134+ flags |= uint32(webkit.FindOptionsWrapAroundValue)
135+ }
136+ if opts.CaseInsensitive {
137+ flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
138+ }
139+ if opts.AtWordStarts {
140+ flags |= uint32(webkit.FindOptionsAtWordStartsValue)
141+ }
142+ a.fc.Search(text, flags, maxMatches)
143+}
144+
145+func (a *findControllerAdapter) CountMatches(text string, opts port.FindOptions, maxMatches uint) {
146+ if a == nil || a.fc == nil {
147+ return
148+ }
149+ var flags uint32
150+ if opts.WrapAround {
151+ flags |= uint32(webkit.FindOptionsWrapAroundValue)
152+ }
153+ if opts.CaseInsensitive {
154+ flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
155+ }
156+ if opts.AtWordStarts {
157+ flags |= uint32(webkit.FindOptionsAtWordStartsValue)
158+ }
159+ a.fc.CountMatches(text, flags, maxMatches)
160+}
161+
162+func (a *findControllerAdapter) SearchNext() {
163+ if a == nil || a.fc == nil {
164+ return
165+ }
166+ a.fc.SearchNext()
167+}
168+
169+func (a *findControllerAdapter) SearchPrevious() {
170+ if a == nil || a.fc == nil {
171+ return
172+ }
173+ a.fc.SearchPrevious()
174+}
175+
176+func (a *findControllerAdapter) SearchFinish() {
177+ if a == nil || a.fc == nil {
178+ return
179+ }
180+ a.fc.SearchFinish()
181+}
182+
183+func (a *findControllerAdapter) GetSearchText() string {
184+ if a == nil || a.fc == nil {
185+ return ""
186+ }
187+ return a.fc.GetSearchText()
188+}
189+
190+func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 {
191+ if a == nil || a.fc == nil || callback == nil {
192+ return 0
193+ }
194+ cb := func(_ webkit.FindController, matchCount uint) {
195+ callback(matchCount)
196+ }
197+ return a.fc.ConnectFoundText(&cb)
198+}
199+
200+func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
201+ if a == nil || a.fc == nil || callback == nil {
202+ return 0
203+ }
204+ cb := func(_ webkit.FindController) {
205+ callback()
206+ }
207+ return a.fc.ConnectFailedToFindText(&cb)
208+}
209+
210+func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
211+ if a == nil || a.fc == nil || callback == nil {
212+ return 0
213+ }
214+ cb := func(_ webkit.FindController, matchCount uint) {
215+ callback(matchCount)
216+ }
217+ return a.fc.ConnectCountedMatches(&cb)
218+}
219+
220+func (a *findControllerAdapter) DisconnectSignal(id uint32) {
221+ if a == nil || a.fc == nil || id == 0 {
222+ return
223+ }
224+ obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
225+ gobject.SignalHandlerDisconnect(obj, id)
118226 }
119227
120228 // NewWebView creates a new WebView with the given context and settings.
@@ -421,6 +529,23 @@ func (wv *WebView) Widget() *webkit.WebView {
421529 return wv.inner
422530 }
423531
532+// GetFindController returns the WebKit FindController wrapped in the port interface.
533+// The adapter is cached to prevent the Go wrapper from being garbage collected.
534+func (wv *WebView) GetFindController() port.FindController {
535+ if wv == nil || wv.inner == nil {
536+ return nil
537+ }
538+
539+ wv.findControllerOnce.Do(func() {
540+ fc := wv.inner.GetFindController()
541+ if fc != nil {
542+ wv.findController = &findControllerAdapter{fc: fc}
543+ }
544+ })
545+
546+ return wv.findController
547+}
548+
424549 // LoadURI loads the given URI.
425550 func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
426551 if wv.destroyed.Load() {
@@ -115,6 +115,114 @@ type WebView struct {
115 115
116 // asyncCallbacks keeps references to async JS callbacks to prevent GC116 // asyncCallbacks keeps references to async JS callbacks to prevent GC
117 asyncCallbacks []interface{}117 asyncCallbacks []interface{}
118+
119+ // findController is cached to prevent GC from collecting the Go wrapper
120+ findController *findControllerAdapter
121+ findControllerOnce sync.Once
122+}
123+
124+type findControllerAdapter struct {
125+ fc *webkit.FindController
126+}
127+
128+func (a *findControllerAdapter) Search(text string, opts port.FindOptions, maxMatches uint) {
129+ if a == nil || a.fc == nil {
130+ return
131+ }
132+ var flags uint32
133+ if opts.WrapAround {
134+ flags |= uint32(webkit.FindOptionsWrapAroundValue)
135+ }
136+ if opts.CaseInsensitive {
137+ flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
138+ }
139+ if opts.AtWordStarts {
140+ flags |= uint32(webkit.FindOptionsAtWordStartsValue)
141+ }
142+ a.fc.Search(text, flags, maxMatches)
143+}
144+
145+func (a *findControllerAdapter) CountMatches(text string, opts port.FindOptions, maxMatches uint) {
146+ if a == nil || a.fc == nil {
147+ return
148+ }
149+ var flags uint32
150+ if opts.WrapAround {
151+ flags |= uint32(webkit.FindOptionsWrapAroundValue)
152+ }
153+ if opts.CaseInsensitive {
154+ flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
155+ }
156+ if opts.AtWordStarts {
157+ flags |= uint32(webkit.FindOptionsAtWordStartsValue)
158+ }
159+ a.fc.CountMatches(text, flags, maxMatches)
160+}
161+
162+func (a *findControllerAdapter) SearchNext() {
163+ if a == nil || a.fc == nil {
164+ return
165+ }
166+ a.fc.SearchNext()
167+}
168+
169+func (a *findControllerAdapter) SearchPrevious() {
170+ if a == nil || a.fc == nil {
171+ return
172+ }
173+ a.fc.SearchPrevious()
174+}
175+
176+func (a *findControllerAdapter) SearchFinish() {
177+ if a == nil || a.fc == nil {
178+ return
179+ }
180+ a.fc.SearchFinish()
181+}
182+
183+func (a *findControllerAdapter) GetSearchText() string {
184+ if a == nil || a.fc == nil {
185+ return ""
186+ }
187+ return a.fc.GetSearchText()
188+}
189+
190+func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 {
191+ if a == nil || a.fc == nil || callback == nil {
192+ return 0
193+ }
194+ cb := func(_ webkit.FindController, matchCount uint) {
195+ callback(matchCount)
196+ }
197+ return a.fc.ConnectFoundText(&cb)
198+}
199+
200+func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
201+ if a == nil || a.fc == nil || callback == nil {
202+ return 0
203+ }
204+ cb := func(_ webkit.FindController) {
205+ callback()
206+ }
207+ return a.fc.ConnectFailedToFindText(&cb)
208+}
209+
210+func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
211+ if a == nil || a.fc == nil || callback == nil {
212+ return 0
213+ }
214+ cb := func(_ webkit.FindController, matchCount uint) {
215+ callback(matchCount)
216+ }
217+ return a.fc.ConnectCountedMatches(&cb)
218+}
219+
220+func (a *findControllerAdapter) DisconnectSignal(id uint32) {
221+ if a == nil || a.fc == nil || id == 0 {
222+ return
223+ }
224+ obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
225+ gobject.SignalHandlerDisconnect(obj, id)
118 }226 }
119 227
120 // NewWebView creates a new WebView with the given context and settings.228 // NewWebView creates a new WebView with the given context and settings.
@@ -421,6 +529,23 @@ func (wv *WebView) Widget() *webkit.WebView {
421 return wv.inner529 return wv.inner
422 }530 }
423 531
532+// GetFindController returns the WebKit FindController wrapped in the port interface.
533+// The adapter is cached to prevent the Go wrapper from being garbage collected.
534+func (wv *WebView) GetFindController() port.FindController {
535+ if wv == nil || wv.inner == nil {
536+ return nil
537+ }
538+
539+ wv.findControllerOnce.Do(func() {
540+ fc := wv.inner.GetFindController()
541+ if fc != nil {
542+ wv.findController = &findControllerAdapter{fc: fc}
543+ }
544+ })
545+
546+ return wv.findController
547+}
548+
424 // LoadURI loads the given URI.549 // LoadURI loads the given URI.
425 func (wv *WebView) LoadURI(ctx context.Context, uri string) error {550 func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
426 if wv.destroyed.Load() {551 if wv.destroyed.Load() {
M internal/ui/app.go +92 -0
@@ -7,6 +7,7 @@ import (
77 "os"
88 "sync"
99
10+ "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/application/usecase"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -22,6 +23,7 @@ import (
2223 "github.com/bnema/dumber/internal/ui/focus"
2324 "github.com/bnema/dumber/internal/ui/input"
2425 "github.com/bnema/dumber/internal/ui/layout"
26+ "github.com/bnema/dumber/internal/ui/theme"
2527 "github.com/bnema/dumber/internal/ui/window"
2628 "github.com/jwijenbergh/puregotk/v4/gdk"
2729 "github.com/jwijenbergh/puregotk/v4/gio"
@@ -67,6 +69,8 @@ type App struct {
6769
6870 // Omnibox configuration (omnibox is created per workspace view)
6971 omniboxCfg component.OmniboxConfig
72+ // Find bar configuration (find bar is created per workspace view)
73+ findBarCfg component.FindBarConfig
7074
7175 // Web content (managed by ContentCoordinator)
7276 pool *webkit.WebViewPool
@@ -244,6 +248,20 @@ func (a *App) onActivate(ctx context.Context) {
244248 a.navCoord.SetOmniboxProvider(a)
245249 log.Debug().Msg("omnibox config stored, provider set")
246250
251+ // Store find bar config (find bar is created per-pane via WorkspaceView)
252+ a.findBarCfg = component.FindBarConfig{
253+ GetFindController: func(paneID entity.PaneID) port.FindController {
254+ if a.contentCoord == nil {
255+ return nil
256+ }
257+ wv := a.contentCoord.GetWebView(paneID)
258+ if wv == nil {
259+ return nil
260+ }
261+ return wv.GetFindController()
262+ },
263+ }
264+
247265 // Create an initial tab using coordinator
248266 initialURL := "dumb://home"
249267 if a.deps.InitialURL != "" {
@@ -412,6 +430,22 @@ func (a *App) initCoordinators(ctx context.Context) {
412430 a.deps.CopyURLUC,
413431 )
414432 a.kbDispatcher.SetOnQuit(a.Quit)
433+ a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
434+ a.ToggleFindBar(ctx)
435+ return nil
436+ })
437+ a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
438+ a.FindNext(ctx)
439+ return nil
440+ })
441+ a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
442+ a.FindPrevious(ctx)
443+ return nil
444+ })
445+ a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
446+ a.CloseFindBar(ctx)
447+ return nil
448+ })
415449
416450 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
417451 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
@@ -501,6 +535,8 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
501535
502536 // Set omnibox config for this workspace view
503537 wsView.SetOmniboxConfig(a.omniboxCfg)
538+ // Set find bar config for this workspace view
539+ wsView.SetFindBarConfig(a.findBarCfg)
504540
505541 // Store in map
506542 a.workspaceViews[tab.ID] = wsView
@@ -623,6 +659,58 @@ func (a *App) ToggleOmnibox(ctx context.Context) {
623659 }
624660 }
625661
662+// ToggleFindBar shows or hides the find bar in the active workspace view.
663+func (a *App) ToggleFindBar(ctx context.Context) {
664+ log := logging.FromContext(ctx)
665+
666+ wsView := a.activeWorkspaceView()
667+ if wsView == nil {
668+ log.Warn().Msg("no active workspace view for find bar toggle")
669+ return
670+ }
671+
672+ if wsView.IsFindBarVisible() {
673+ wsView.HideFindBar()
674+ } else {
675+ wsView.ShowFindBar(ctx)
676+ }
677+}
678+
679+// FindNext selects the next match in the active find bar.
680+func (a *App) FindNext(ctx context.Context) {
681+ wsView := a.activeWorkspaceView()
682+ if wsView == nil {
683+ return
684+ }
685+
686+ if !wsView.IsFindBarVisible() {
687+ wsView.ShowFindBar(ctx)
688+ }
689+ wsView.FindNext()
690+}
691+
692+// FindPrevious selects the previous match in the active find bar.
693+func (a *App) FindPrevious(ctx context.Context) {
694+ wsView := a.activeWorkspaceView()
695+ if wsView == nil {
696+ return
697+ }
698+
699+ if !wsView.IsFindBarVisible() {
700+ wsView.ShowFindBar(ctx)
701+ }
702+ wsView.FindPrevious()
703+}
704+
705+// CloseFindBar hides the find bar if visible.
706+func (a *App) CloseFindBar(ctx context.Context) {
707+ wsView := a.activeWorkspaceView()
708+ if wsView == nil {
709+ return
710+ }
711+ wsView.HideFindBar()
712+}
713+
626714 // UpdateOmniboxZoom implements OmniboxProvider.
627715 // Updates the zoom indicator on the current omnibox if visible.
628716 func (a *App) UpdateOmniboxZoom(factor float64) {
@@ -706,6 +794,10 @@ func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
706794 // Keep injector's dark-mode flag in sync for future navigations
707795 if a.injector != nil {
708796 a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
797+ findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
798+ if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
799+ log.Warn().Err(err).Msg("failed to update find highlight CSS")
800+ }
709801 }
710802
711803 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "sync"8 "sync"
9 9
10+ "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -22,6 +23,7 @@ import (
22 "github.com/bnema/dumber/internal/ui/focus"23 "github.com/bnema/dumber/internal/ui/focus"
23 "github.com/bnema/dumber/internal/ui/input"24 "github.com/bnema/dumber/internal/ui/input"
24 "github.com/bnema/dumber/internal/ui/layout"25 "github.com/bnema/dumber/internal/ui/layout"
26+ "github.com/bnema/dumber/internal/ui/theme"
25 "github.com/bnema/dumber/internal/ui/window"27 "github.com/bnema/dumber/internal/ui/window"
26 "github.com/jwijenbergh/puregotk/v4/gdk"28 "github.com/jwijenbergh/puregotk/v4/gdk"
27 "github.com/jwijenbergh/puregotk/v4/gio"29 "github.com/jwijenbergh/puregotk/v4/gio"
@@ -67,6 +69,8 @@ type App struct {
67 69
68 // Omnibox configuration (omnibox is created per workspace view)70 // Omnibox configuration (omnibox is created per workspace view)
69 omniboxCfg component.OmniboxConfig71 omniboxCfg component.OmniboxConfig
72+ // Find bar configuration (find bar is created per workspace view)
73+ findBarCfg component.FindBarConfig
70 74
71 // Web content (managed by ContentCoordinator)75 // Web content (managed by ContentCoordinator)
72 pool *webkit.WebViewPool76 pool *webkit.WebViewPool
@@ -244,6 +248,20 @@ func (a *App) onActivate(ctx context.Context) {
244 a.navCoord.SetOmniboxProvider(a)248 a.navCoord.SetOmniboxProvider(a)
245 log.Debug().Msg("omnibox config stored, provider set")249 log.Debug().Msg("omnibox config stored, provider set")
246 250
251+ // Store find bar config (find bar is created per-pane via WorkspaceView)
252+ a.findBarCfg = component.FindBarConfig{
253+ GetFindController: func(paneID entity.PaneID) port.FindController {
254+ if a.contentCoord == nil {
255+ return nil
256+ }
257+ wv := a.contentCoord.GetWebView(paneID)
258+ if wv == nil {
259+ return nil
260+ }
261+ return wv.GetFindController()
262+ },
263+ }
264+
247 // Create an initial tab using coordinator265 // Create an initial tab using coordinator
248 initialURL := "dumb://home"266 initialURL := "dumb://home"
249 if a.deps.InitialURL != "" {267 if a.deps.InitialURL != "" {
@@ -412,6 +430,22 @@ func (a *App) initCoordinators(ctx context.Context) {
412 a.deps.CopyURLUC,430 a.deps.CopyURLUC,
413 )431 )
414 a.kbDispatcher.SetOnQuit(a.Quit)432 a.kbDispatcher.SetOnQuit(a.Quit)
433+ a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
434+ a.ToggleFindBar(ctx)
435+ return nil
436+ })
437+ a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
438+ a.FindNext(ctx)
439+ return nil
440+ })
441+ a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
442+ a.FindPrevious(ctx)
443+ return nil
444+ })
445+ a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
446+ a.CloseFindBar(ctx)
447+ return nil
448+ })
415 449
416 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)450 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
417 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {451 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
@@ -501,6 +535,8 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
501 535
502 // Set omnibox config for this workspace view536 // Set omnibox config for this workspace view
503 wsView.SetOmniboxConfig(a.omniboxCfg)537 wsView.SetOmniboxConfig(a.omniboxCfg)
538+ // Set find bar config for this workspace view
539+ wsView.SetFindBarConfig(a.findBarCfg)
504 540
505 // Store in map541 // Store in map
506 a.workspaceViews[tab.ID] = wsView542 a.workspaceViews[tab.ID] = wsView
@@ -623,6 +659,58 @@ func (a *App) ToggleOmnibox(ctx context.Context) {
623 }659 }
624 }660 }
625 661
662+// ToggleFindBar shows or hides the find bar in the active workspace view.
663+func (a *App) ToggleFindBar(ctx context.Context) {
664+ log := logging.FromContext(ctx)
665+
666+ wsView := a.activeWorkspaceView()
667+ if wsView == nil {
668+ log.Warn().Msg("no active workspace view for find bar toggle")
669+ return
670+ }
671+
672+ if wsView.IsFindBarVisible() {
673+ wsView.HideFindBar()
674+ } else {
675+ wsView.ShowFindBar(ctx)
676+ }
677+}
678+
679+// FindNext selects the next match in the active find bar.
680+func (a *App) FindNext(ctx context.Context) {
681+ wsView := a.activeWorkspaceView()
682+ if wsView == nil {
683+ return
684+ }
685+
686+ if !wsView.IsFindBarVisible() {
687+ wsView.ShowFindBar(ctx)
688+ }
689+ wsView.FindNext()
690+}
691+
692+// FindPrevious selects the previous match in the active find bar.
693+func (a *App) FindPrevious(ctx context.Context) {
694+ wsView := a.activeWorkspaceView()
695+ if wsView == nil {
696+ return
697+ }
698+
699+ if !wsView.IsFindBarVisible() {
700+ wsView.ShowFindBar(ctx)
701+ }
702+ wsView.FindPrevious()
703+}
704+
705+// CloseFindBar hides the find bar if visible.
706+func (a *App) CloseFindBar(ctx context.Context) {
707+ wsView := a.activeWorkspaceView()
708+ if wsView == nil {
709+ return
710+ }
711+ wsView.HideFindBar()
712+}
713+
626 // UpdateOmniboxZoom implements OmniboxProvider.714 // UpdateOmniboxZoom implements OmniboxProvider.
627 // Updates the zoom indicator on the current omnibox if visible.715 // Updates the zoom indicator on the current omnibox if visible.
628 func (a *App) UpdateOmniboxZoom(factor float64) {716 func (a *App) UpdateOmniboxZoom(factor float64) {
@@ -706,6 +794,10 @@ func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
706 // Keep injector's dark-mode flag in sync for future navigations794 // Keep injector's dark-mode flag in sync for future navigations
707 if a.injector != nil {795 if a.injector != nil {
708 a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())796 a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
797+ findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
798+ if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
799+ log.Warn().Err(err).Msg("failed to update find highlight CSS")
800+ }
709 }801 }
710 802
711 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)803 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
A internal/ui/component/find_bar.go +371 -0
@@ -0,0 +1,371 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
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+
19+const (
20+ findBarMarginPx = 8
21+ findBarRowSpacing = 6
22+ findBarCountWidth = 6
23+)
24+
25+// FindBar is a compact find-in-page UI overlay.
26+type 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.
49+type FindBarConfig struct {
50+ OnClose func()
51+ GetFindController func(paneID entity.PaneID) port.FindController
52+}
53+
54+// NewFindBar creates a new FindBar component.
55+func 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.
77+func (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.
84+func (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.
92+func (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.
113+func (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.
131+func (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.
138+func (fb *FindBar) FindNext() {
139+ if fb.uc != nil {
140+ fb.uc.SearchNext()
141+ }
142+}
143+
144+// FindPrevious moves to the previous match.
145+func (fb *FindBar) FindPrevious() {
146+ if fb.uc != nil {
147+ fb.uc.SearchPrevious()
148+ }
149+}
150+
151+func (fb *FindBar) createWidgets() error {
152+ fb.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
153+ if fb.outerBox == nil {
154+ return errNilWidget("findOuterBox")
155+ }
156+ fb.outerBox.AddCssClass("find-bar-outer")
157+ fb.outerBox.SetHalign(gtk.AlignEndValue)
158+ fb.outerBox.SetValign(gtk.AlignStartValue)
159+ fb.outerBox.SetVisible(false)
160+ fb.outerBox.SetMarginTop(findBarMarginPx)
161+ fb.outerBox.SetMarginEnd(findBarMarginPx)
162+
163+ fb.containerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
164+ if fb.containerBox == nil {
165+ return errNilWidget("findContainerBox")
166+ }
167+ fb.containerBox.AddCssClass("find-bar-container")
168+
169+ fb.inputRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
170+ if fb.inputRow == nil {
171+ return errNilWidget("findInputRow")
172+ }
173+ fb.inputRow.AddCssClass("find-bar-input-row")
174+ fb.inputRow.SetHalign(gtk.AlignFillValue)
175+
176+ fb.entry = gtk.NewSearchEntry()
177+ if fb.entry == nil {
178+ return errNilWidget("findEntry")
179+ }
180+ fb.entry.AddCssClass("find-bar-entry")
181+ fb.entry.SetHexpand(true)
182+ placeholder := "Find in page..."
183+ fb.entry.SetPlaceholderText(&placeholder)
184+
185+ fb.prevBtn = gtk.NewButtonWithLabel("Prev")
186+ if fb.prevBtn == nil {
187+ return errNilWidget("findPrevBtn")
188+ }
189+ fb.prevBtn.AddCssClass("find-bar-nav")
190+
191+ fb.nextBtn = gtk.NewButtonWithLabel("Next")
192+ if fb.nextBtn == nil {
193+ return errNilWidget("findNextBtn")
194+ }
195+ fb.nextBtn.AddCssClass("find-bar-nav")
196+
197+ fb.countLabel = gtk.NewLabel(nil)
198+ if fb.countLabel == nil {
199+ return errNilWidget("findCountLabel")
200+ }
201+ fb.countLabel.AddCssClass("find-bar-count")
202+ fb.countLabel.SetWidthChars(findBarCountWidth)
203+
204+ fb.closeBtn = gtk.NewButtonWithLabel("X")
205+ if fb.closeBtn == nil {
206+ return errNilWidget("findCloseBtn")
207+ }
208+ fb.closeBtn.AddCssClass("find-bar-close")
209+
210+ fb.optionsRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
211+ if fb.optionsRow == nil {
212+ return errNilWidget("findOptionsRow")
213+ }
214+ fb.optionsRow.AddCssClass("find-bar-options-row")
215+
216+ fb.caseToggle = gtk.NewToggleButtonWithLabel("Aa")
217+ if fb.caseToggle == nil {
218+ return errNilWidget("findCaseToggle")
219+ }
220+ fb.caseToggle.AddCssClass("find-bar-toggle")
221+
222+ fb.wordToggle = gtk.NewToggleButtonWithLabel("W")
223+ if fb.wordToggle == nil {
224+ return errNilWidget("findWordToggle")
225+ }
226+ fb.wordToggle.AddCssClass("find-bar-toggle")
227+
228+ fb.hlToggle = gtk.NewToggleButtonWithLabel("Hl")
229+ if fb.hlToggle == nil {
230+ return errNilWidget("findHlToggle")
231+ }
232+ fb.hlToggle.AddCssClass("find-bar-toggle")
233+ fb.hlToggle.SetActive(true)
234+
235+ fb.inputRow.Append(&fb.entry.Widget)
236+ fb.inputRow.Append(&fb.prevBtn.Widget)
237+ fb.inputRow.Append(&fb.nextBtn.Widget)
238+ fb.inputRow.Append(&fb.countLabel.Widget)
239+ fb.inputRow.Append(&fb.closeBtn.Widget)
240+
241+ fb.optionsRow.Append(&fb.caseToggle.Widget)
242+ fb.optionsRow.Append(&fb.wordToggle.Widget)
243+ fb.optionsRow.Append(&fb.hlToggle.Widget)
244+
245+ fb.containerBox.Append(&fb.inputRow.Widget)
246+ fb.containerBox.Append(&fb.optionsRow.Widget)
247+ fb.outerBox.Append(&fb.containerBox.Widget)
248+
249+ return nil
250+}
251+
252+func (fb *FindBar) setupHandlers() {
253+ if fb.entry != nil {
254+ changedCb := func(_ gtk.SearchEntry) {
255+ if fb.uc != nil {
256+ fb.uc.SetQuery(fb.entry.GetText())
257+ }
258+ }
259+ fb.entry.ConnectSearchChanged(&changedCb)
260+ }
261+
262+ if fb.prevBtn != nil {
263+ prevCb := func(_ gtk.Button) {
264+ fb.FindPrevious()
265+ }
266+ fb.prevBtn.ConnectClicked(&prevCb)
267+ }
268+
269+ if fb.nextBtn != nil {
270+ nextCb := func(_ gtk.Button) {
271+ fb.FindNext()
272+ }
273+ fb.nextBtn.ConnectClicked(&nextCb)
274+ }
275+
276+ if fb.closeBtn != nil {
277+ closeCb := func(_ gtk.Button) {
278+ fb.Hide()
279+ if fb.onClose != nil {
280+ fb.onClose()
281+ }
282+ }
283+ fb.closeBtn.ConnectClicked(&closeCb)
284+ }
285+
286+ if fb.caseToggle != nil {
287+ caseCb := func(_ gtk.ToggleButton) {
288+ if fb.uc != nil {
289+ fb.uc.SetCaseSensitiveEnabled(fb.caseToggle.GetActive())
290+ }
291+ }
292+ fb.caseToggle.ConnectToggled(&caseCb)
293+ }
294+
295+ if fb.wordToggle != nil {
296+ wordCb := func(_ gtk.ToggleButton) {
297+ if fb.uc != nil {
298+ fb.uc.SetAtWordStarts(fb.wordToggle.GetActive())
299+ }
300+ }
301+ fb.wordToggle.ConnectToggled(&wordCb)
302+ }
303+
304+ if fb.hlToggle != nil {
305+ hlCb := func(_ gtk.ToggleButton) {
306+ if fb.uc != nil {
307+ fb.uc.SetHighlightEnabled(fb.hlToggle.GetActive())
308+ }
309+ }
310+ fb.hlToggle.ConnectToggled(&hlCb)
311+ }
312+
313+ controller := gtk.NewEventControllerKey()
314+ if controller != nil {
315+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
316+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
317+ switch keyval {
318+ case uint(gdk.KEY_Escape):
319+ fb.Hide()
320+ if fb.onClose != nil {
321+ fb.onClose()
322+ }
323+ return true
324+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
325+ if state&gdk.ShiftMaskValue != 0 {
326+ fb.FindPrevious()
327+ } else {
328+ fb.FindNext()
329+ }
330+ return true
331+ }
332+ return false
333+ }
334+ controller.ConnectKeyPressed(&keyPressedCb)
335+ fb.outerBox.AddController(&controller.EventController)
336+ }
337+}
338+
339+func (fb *FindBar) bindUseCase() {
340+ if fb.uc == nil {
341+ return
342+ }
343+
344+ fb.uc.SetOnStateChange(func(state usecase.FindState) {
345+ cb := glib.SourceFunc(func(_ uintptr) bool {
346+ if fb.entry == nil || fb.countLabel == nil {
347+ return false
348+ }
349+
350+ text := ""
351+ if state.Query != "" {
352+ text = fmt.Sprintf("%d/%d", state.CurrentIndex, state.MatchCount)
353+ }
354+ fb.countLabel.SetText(text)
355+
356+ if state.MatchCount > 0 {
357+ fb.countLabel.AddCssClass("find-bar-count-has")
358+ } else {
359+ fb.countLabel.RemoveCssClass("find-bar-count-has")
360+ }
361+
362+ if state.NotFound && state.Query != "" {
363+ fb.entry.AddCssClass("not-found")
364+ } else {
365+ fb.entry.RemoveCssClass("not-found")
366+ }
367+ return false
368+ })
369+ glib.IdleAdd(&cb, 0)
370+ })
371+}
@@ -0,0 +1,371 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
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+
19+const (
20+ findBarMarginPx = 8
21+ findBarRowSpacing = 6
22+ findBarCountWidth = 6
23+)
24+
25+// FindBar is a compact find-in-page UI overlay.
26+type 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.
49+type FindBarConfig struct {
50+ OnClose func()
51+ GetFindController func(paneID entity.PaneID) port.FindController
52+}
53+
54+// NewFindBar creates a new FindBar component.
55+func 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.
77+func (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.
84+func (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.
92+func (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.
113+func (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.
131+func (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.
138+func (fb *FindBar) FindNext() {
139+ if fb.uc != nil {
140+ fb.uc.SearchNext()
141+ }
142+}
143+
144+// FindPrevious moves to the previous match.
145+func (fb *FindBar) FindPrevious() {
146+ if fb.uc != nil {
147+ fb.uc.SearchPrevious()
148+ }
149+}
150+
151+func (fb *FindBar) createWidgets() error {
152+ fb.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
153+ if fb.outerBox == nil {
154+ return errNilWidget("findOuterBox")
155+ }
156+ fb.outerBox.AddCssClass("find-bar-outer")
157+ fb.outerBox.SetHalign(gtk.AlignEndValue)
158+ fb.outerBox.SetValign(gtk.AlignStartValue)
159+ fb.outerBox.SetVisible(false)
160+ fb.outerBox.SetMarginTop(findBarMarginPx)
161+ fb.outerBox.SetMarginEnd(findBarMarginPx)
162+
163+ fb.containerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
164+ if fb.containerBox == nil {
165+ return errNilWidget("findContainerBox")
166+ }
167+ fb.containerBox.AddCssClass("find-bar-container")
168+
169+ fb.inputRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
170+ if fb.inputRow == nil {
171+ return errNilWidget("findInputRow")
172+ }
173+ fb.inputRow.AddCssClass("find-bar-input-row")
174+ fb.inputRow.SetHalign(gtk.AlignFillValue)
175+
176+ fb.entry = gtk.NewSearchEntry()
177+ if fb.entry == nil {
178+ return errNilWidget("findEntry")
179+ }
180+ fb.entry.AddCssClass("find-bar-entry")
181+ fb.entry.SetHexpand(true)
182+ placeholder := "Find in page..."
183+ fb.entry.SetPlaceholderText(&placeholder)
184+
185+ fb.prevBtn = gtk.NewButtonWithLabel("Prev")
186+ if fb.prevBtn == nil {
187+ return errNilWidget("findPrevBtn")
188+ }
189+ fb.prevBtn.AddCssClass("find-bar-nav")
190+
191+ fb.nextBtn = gtk.NewButtonWithLabel("Next")
192+ if fb.nextBtn == nil {
193+ return errNilWidget("findNextBtn")
194+ }
195+ fb.nextBtn.AddCssClass("find-bar-nav")
196+
197+ fb.countLabel = gtk.NewLabel(nil)
198+ if fb.countLabel == nil {
199+ return errNilWidget("findCountLabel")
200+ }
201+ fb.countLabel.AddCssClass("find-bar-count")
202+ fb.countLabel.SetWidthChars(findBarCountWidth)
203+
204+ fb.closeBtn = gtk.NewButtonWithLabel("X")
205+ if fb.closeBtn == nil {
206+ return errNilWidget("findCloseBtn")
207+ }
208+ fb.closeBtn.AddCssClass("find-bar-close")
209+
210+ fb.optionsRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
211+ if fb.optionsRow == nil {
212+ return errNilWidget("findOptionsRow")
213+ }
214+ fb.optionsRow.AddCssClass("find-bar-options-row")
215+
216+ fb.caseToggle = gtk.NewToggleButtonWithLabel("Aa")
217+ if fb.caseToggle == nil {
218+ return errNilWidget("findCaseToggle")
219+ }
220+ fb.caseToggle.AddCssClass("find-bar-toggle")
221+
222+ fb.wordToggle = gtk.NewToggleButtonWithLabel("W")
223+ if fb.wordToggle == nil {
224+ return errNilWidget("findWordToggle")
225+ }
226+ fb.wordToggle.AddCssClass("find-bar-toggle")
227+
228+ fb.hlToggle = gtk.NewToggleButtonWithLabel("Hl")
229+ if fb.hlToggle == nil {
230+ return errNilWidget("findHlToggle")
231+ }
232+ fb.hlToggle.AddCssClass("find-bar-toggle")
233+ fb.hlToggle.SetActive(true)
234+
235+ fb.inputRow.Append(&fb.entry.Widget)
236+ fb.inputRow.Append(&fb.prevBtn.Widget)
237+ fb.inputRow.Append(&fb.nextBtn.Widget)
238+ fb.inputRow.Append(&fb.countLabel.Widget)
239+ fb.inputRow.Append(&fb.closeBtn.Widget)
240+
241+ fb.optionsRow.Append(&fb.caseToggle.Widget)
242+ fb.optionsRow.Append(&fb.wordToggle.Widget)
243+ fb.optionsRow.Append(&fb.hlToggle.Widget)
244+
245+ fb.containerBox.Append(&fb.inputRow.Widget)
246+ fb.containerBox.Append(&fb.optionsRow.Widget)
247+ fb.outerBox.Append(&fb.containerBox.Widget)
248+
249+ return nil
250+}
251+
252+func (fb *FindBar) setupHandlers() {
253+ if fb.entry != nil {
254+ changedCb := func(_ gtk.SearchEntry) {
255+ if fb.uc != nil {
256+ fb.uc.SetQuery(fb.entry.GetText())
257+ }
258+ }
259+ fb.entry.ConnectSearchChanged(&changedCb)
260+ }
261+
262+ if fb.prevBtn != nil {
263+ prevCb := func(_ gtk.Button) {
264+ fb.FindPrevious()
265+ }
266+ fb.prevBtn.ConnectClicked(&prevCb)
267+ }
268+
269+ if fb.nextBtn != nil {
270+ nextCb := func(_ gtk.Button) {
271+ fb.FindNext()
272+ }
273+ fb.nextBtn.ConnectClicked(&nextCb)
274+ }
275+
276+ if fb.closeBtn != nil {
277+ closeCb := func(_ gtk.Button) {
278+ fb.Hide()
279+ if fb.onClose != nil {
280+ fb.onClose()
281+ }
282+ }
283+ fb.closeBtn.ConnectClicked(&closeCb)
284+ }
285+
286+ if fb.caseToggle != nil {
287+ caseCb := func(_ gtk.ToggleButton) {
288+ if fb.uc != nil {
289+ fb.uc.SetCaseSensitiveEnabled(fb.caseToggle.GetActive())
290+ }
291+ }
292+ fb.caseToggle.ConnectToggled(&caseCb)
293+ }
294+
295+ if fb.wordToggle != nil {
296+ wordCb := func(_ gtk.ToggleButton) {
297+ if fb.uc != nil {
298+ fb.uc.SetAtWordStarts(fb.wordToggle.GetActive())
299+ }
300+ }
301+ fb.wordToggle.ConnectToggled(&wordCb)
302+ }
303+
304+ if fb.hlToggle != nil {
305+ hlCb := func(_ gtk.ToggleButton) {
306+ if fb.uc != nil {
307+ fb.uc.SetHighlightEnabled(fb.hlToggle.GetActive())
308+ }
309+ }
310+ fb.hlToggle.ConnectToggled(&hlCb)
311+ }
312+
313+ controller := gtk.NewEventControllerKey()
314+ if controller != nil {
315+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
316+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
317+ switch keyval {
318+ case uint(gdk.KEY_Escape):
319+ fb.Hide()
320+ if fb.onClose != nil {
321+ fb.onClose()
322+ }
323+ return true
324+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
325+ if state&gdk.ShiftMaskValue != 0 {
326+ fb.FindPrevious()
327+ } else {
328+ fb.FindNext()
329+ }
330+ return true
331+ }
332+ return false
333+ }
334+ controller.ConnectKeyPressed(&keyPressedCb)
335+ fb.outerBox.AddController(&controller.EventController)
336+ }
337+}
338+
339+func (fb *FindBar) bindUseCase() {
340+ if fb.uc == nil {
341+ return
342+ }
343+
344+ fb.uc.SetOnStateChange(func(state usecase.FindState) {
345+ cb := glib.SourceFunc(func(_ uintptr) bool {
346+ if fb.entry == nil || fb.countLabel == nil {
347+ return false
348+ }
349+
350+ text := ""
351+ if state.Query != "" {
352+ text = fmt.Sprintf("%d/%d", state.CurrentIndex, state.MatchCount)
353+ }
354+ fb.countLabel.SetText(text)
355+
356+ if state.MatchCount > 0 {
357+ fb.countLabel.AddCssClass("find-bar-count-has")
358+ } else {
359+ fb.countLabel.RemoveCssClass("find-bar-count-has")
360+ }
361+
362+ if state.NotFound && state.Query != "" {
363+ fb.entry.AddCssClass("not-found")
364+ } else {
365+ fb.entry.RemoveCssClass("not-found")
366+ }
367+ return false
368+ })
369+ glib.IdleAdd(&cb, 0)
370+ })
371+}
M internal/ui/component/workspace_view.go +122 -0
@@ -37,6 +37,12 @@ type WorkspaceView struct {
3737 omniboxPaneID entity.PaneID // Which pane has the omnibox
3838 omniboxCfg OmniboxConfig // Stored config for creating omniboxes
3939
40+ // Find bar lifecycle
41+ findBar *FindBar // Current find bar (nil if not shown)
42+ findBarWidget layout.Widget // Wrapped find bar widget for overlay
43+ findBarPaneID entity.PaneID // Which pane has the find bar
44+ findBarCfg FindBarConfig // Stored config for creating find bars
45+
4046 workspace *entity.Workspace
4147 paneViews map[entity.PaneID]*PaneView
4248 activePaneID entity.PaneID
@@ -181,6 +187,10 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
181187 if wv.activePaneID != paneID && wv.omnibox != nil {
182188 wv.hideOmniboxInternal()
183189 }
190+ // Destroy find bar if active pane is changing
191+ if wv.activePaneID != paneID && wv.findBar != nil {
192+ wv.hideFindBarInternal()
193+ }
184194
185195 // Deactivate current active pane
186196 if wv.activePaneID != "" {
@@ -437,6 +447,13 @@ func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
437447 wv.omniboxCfg = cfg
438448 }
439449
450+// SetFindBarConfig stores the find bar configuration for later use.
451+func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
452+ wv.mu.Lock()
453+ defer wv.mu.Unlock()
454+ wv.findBarCfg = cfg
455+}
456+
440457 // ShowOmnibox creates and shows the omnibox in the active pane.
441458 func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
442459 wv.mu.Lock()
@@ -520,6 +537,111 @@ func (wv *WorkspaceView) hideOmniboxInternal() {
520537 wv.logger.Debug().Msg("omnibox hidden and destroyed")
521538 }
522539
540+// ShowFindBar creates and shows the find bar in the active pane.
541+func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
542+ wv.mu.Lock()
543+ defer wv.mu.Unlock()
544+
545+ // If find bar already exists, just show it
546+ if wv.findBar != nil {
547+ wv.findBar.Show()
548+ return
549+ }
550+
551+ pv := wv.paneViews[wv.activePaneID]
552+ if pv == nil {
553+ wv.logger.Warn().Str("paneID", string(wv.activePaneID)).Msg("cannot show find bar: active pane not found")
554+ return
555+ }
556+
557+ cfg := wv.findBarCfg
558+ cfg.OnClose = func() {
559+ wv.HideFindBar()
560+ }
561+ findBar := NewFindBar(ctx, cfg)
562+ if findBar == nil {
563+ wv.logger.Error().Msg("failed to create find bar")
564+ return
565+ }
566+
567+ findBarWidget := findBar.WidgetAsLayout(wv.factory)
568+ if findBarWidget == nil {
569+ wv.logger.Error().Msg("failed to wrap find bar widget")
570+ return
571+ }
572+
573+ pv.AddOverlayWidget(findBarWidget)
574+
575+ if cfg.GetFindController != nil {
576+ controller := cfg.GetFindController(wv.activePaneID)
577+ findBar.SetFindController(controller)
578+ }
579+
580+ wv.findBar = findBar
581+ wv.findBarWidget = findBarWidget
582+ wv.findBarPaneID = wv.activePaneID
583+
584+ findBar.Show()
585+
586+ wv.logger.Debug().Str("paneID", string(wv.activePaneID)).Msg("find bar shown")
587+}
588+
589+// HideFindBar hides and destroys the current find bar.
590+func (wv *WorkspaceView) HideFindBar() {
591+ wv.mu.Lock()
592+ defer wv.mu.Unlock()
593+
594+ wv.hideFindBarInternal()
595+}
596+
597+// FindNext triggers the next match in the find bar if available.
598+func (wv *WorkspaceView) FindNext() {
599+ wv.mu.RLock()
600+ findBar := wv.findBar
601+ wv.mu.RUnlock()
602+
603+ if findBar != nil {
604+ findBar.FindNext()
605+ }
606+}
607+
608+// FindPrevious triggers the previous match in the find bar if available.
609+func (wv *WorkspaceView) FindPrevious() {
610+ wv.mu.RLock()
611+ findBar := wv.findBar
612+ wv.mu.RUnlock()
613+
614+ if findBar != nil {
615+ findBar.FindPrevious()
616+ }
617+}
618+
619+// IsFindBarVisible returns whether the find bar is currently visible.
620+func (wv *WorkspaceView) IsFindBarVisible() bool {
621+ wv.mu.RLock()
622+ defer wv.mu.RUnlock()
623+ return wv.findBar != nil && wv.findBar.IsVisible()
624+}
625+
626+// hideFindBarInternal destroys the find bar without locking.
627+func (wv *WorkspaceView) hideFindBarInternal() {
628+ if wv.findBar == nil {
629+ return
630+ }
631+
632+ wv.findBar.Hide()
633+
634+ if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
635+ pv.RemoveOverlayWidget(wv.findBarWidget)
636+ }
637+
638+ wv.findBar = nil
639+ wv.findBarWidget = nil
640+ wv.findBarPaneID = ""
641+
642+ wv.logger.Debug().Msg("find bar hidden and destroyed")
643+}
644+
523645 // GetOmnibox returns the current omnibox if visible.
524646 func (wv *WorkspaceView) GetOmnibox() *Omnibox {
525647 wv.mu.RLock()
@@ -37,6 +37,12 @@ type WorkspaceView struct {
37 omniboxPaneID entity.PaneID // Which pane has the omnibox37 omniboxPaneID entity.PaneID // Which pane has the omnibox
38 omniboxCfg OmniboxConfig // Stored config for creating omniboxes38 omniboxCfg OmniboxConfig // Stored config for creating omniboxes
39 39
40+ // Find bar lifecycle
41+ findBar *FindBar // Current find bar (nil if not shown)
42+ findBarWidget layout.Widget // Wrapped find bar widget for overlay
43+ findBarPaneID entity.PaneID // Which pane has the find bar
44+ findBarCfg FindBarConfig // Stored config for creating find bars
45+
40 workspace *entity.Workspace46 workspace *entity.Workspace
41 paneViews map[entity.PaneID]*PaneView47 paneViews map[entity.PaneID]*PaneView
42 activePaneID entity.PaneID48 activePaneID entity.PaneID
@@ -181,6 +187,10 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
181 if wv.activePaneID != paneID && wv.omnibox != nil {187 if wv.activePaneID != paneID && wv.omnibox != nil {
182 wv.hideOmniboxInternal()188 wv.hideOmniboxInternal()
183 }189 }
190+ // Destroy find bar if active pane is changing
191+ if wv.activePaneID != paneID && wv.findBar != nil {
192+ wv.hideFindBarInternal()
193+ }
184 194
185 // Deactivate current active pane195 // Deactivate current active pane
186 if wv.activePaneID != "" {196 if wv.activePaneID != "" {
@@ -437,6 +447,13 @@ func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
437 wv.omniboxCfg = cfg447 wv.omniboxCfg = cfg
438 }448 }
439 449
450+// SetFindBarConfig stores the find bar configuration for later use.
451+func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
452+ wv.mu.Lock()
453+ defer wv.mu.Unlock()
454+ wv.findBarCfg = cfg
455+}
456+
440 // ShowOmnibox creates and shows the omnibox in the active pane.457 // ShowOmnibox creates and shows the omnibox in the active pane.
441 func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {458 func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
442 wv.mu.Lock()459 wv.mu.Lock()
@@ -520,6 +537,111 @@ func (wv *WorkspaceView) hideOmniboxInternal() {
520 wv.logger.Debug().Msg("omnibox hidden and destroyed")537 wv.logger.Debug().Msg("omnibox hidden and destroyed")
521 }538 }
522 539
540+// ShowFindBar creates and shows the find bar in the active pane.
541+func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
542+ wv.mu.Lock()
543+ defer wv.mu.Unlock()
544+
545+ // If find bar already exists, just show it
546+ if wv.findBar != nil {
547+ wv.findBar.Show()
548+ return
549+ }
550+
551+ pv := wv.paneViews[wv.activePaneID]
552+ if pv == nil {
553+ wv.logger.Warn().Str("paneID", string(wv.activePaneID)).Msg("cannot show find bar: active pane not found")
554+ return
555+ }
556+
557+ cfg := wv.findBarCfg
558+ cfg.OnClose = func() {
559+ wv.HideFindBar()
560+ }
561+ findBar := NewFindBar(ctx, cfg)
562+ if findBar == nil {
563+ wv.logger.Error().Msg("failed to create find bar")
564+ return
565+ }
566+
567+ findBarWidget := findBar.WidgetAsLayout(wv.factory)
568+ if findBarWidget == nil {
569+ wv.logger.Error().Msg("failed to wrap find bar widget")
570+ return
571+ }
572+
573+ pv.AddOverlayWidget(findBarWidget)
574+
575+ if cfg.GetFindController != nil {
576+ controller := cfg.GetFindController(wv.activePaneID)
577+ findBar.SetFindController(controller)
578+ }
579+
580+ wv.findBar = findBar
581+ wv.findBarWidget = findBarWidget
582+ wv.findBarPaneID = wv.activePaneID
583+
584+ findBar.Show()
585+
586+ wv.logger.Debug().Str("paneID", string(wv.activePaneID)).Msg("find bar shown")
587+}
588+
589+// HideFindBar hides and destroys the current find bar.
590+func (wv *WorkspaceView) HideFindBar() {
591+ wv.mu.Lock()
592+ defer wv.mu.Unlock()
593+
594+ wv.hideFindBarInternal()
595+}
596+
597+// FindNext triggers the next match in the find bar if available.
598+func (wv *WorkspaceView) FindNext() {
599+ wv.mu.RLock()
600+ findBar := wv.findBar
601+ wv.mu.RUnlock()
602+
603+ if findBar != nil {
604+ findBar.FindNext()
605+ }
606+}
607+
608+// FindPrevious triggers the previous match in the find bar if available.
609+func (wv *WorkspaceView) FindPrevious() {
610+ wv.mu.RLock()
611+ findBar := wv.findBar
612+ wv.mu.RUnlock()
613+
614+ if findBar != nil {
615+ findBar.FindPrevious()
616+ }
617+}
618+
619+// IsFindBarVisible returns whether the find bar is currently visible.
620+func (wv *WorkspaceView) IsFindBarVisible() bool {
621+ wv.mu.RLock()
622+ defer wv.mu.RUnlock()
623+ return wv.findBar != nil && wv.findBar.IsVisible()
624+}
625+
626+// hideFindBarInternal destroys the find bar without locking.
627+func (wv *WorkspaceView) hideFindBarInternal() {
628+ if wv.findBar == nil {
629+ return
630+ }
631+
632+ wv.findBar.Hide()
633+
634+ if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
635+ pv.RemoveOverlayWidget(wv.findBarWidget)
636+ }
637+
638+ wv.findBar = nil
639+ wv.findBarWidget = nil
640+ wv.findBarPaneID = ""
641+
642+ wv.logger.Debug().Msg("find bar hidden and destroyed")
643+}
644+
523 // GetOmnibox returns the current omnibox if visible.645 // GetOmnibox returns the current omnibox if visible.
524 func (wv *WorkspaceView) GetOmnibox() *Omnibox {646 func (wv *WorkspaceView) GetOmnibox() *Omnibox {
525 wv.mu.RLock()647 wv.mu.RLock()
M internal/ui/dispatcher/keyboard.go +50 -6
@@ -14,12 +14,16 @@ import (
1414
1515 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
1616 type KeyboardDispatcher struct {
17- tabCoord *coordinator.TabCoordinator
18- wsCoord *coordinator.WorkspaceCoordinator
19- navCoord *coordinator.NavigationCoordinator
20- zoomUC *usecase.ManageZoomUseCase
21- copyURLUC *usecase.CopyURLUseCase
22- onQuit func()
17+ tabCoord *coordinator.TabCoordinator
18+ wsCoord *coordinator.WorkspaceCoordinator
19+ navCoord *coordinator.NavigationCoordinator
20+ zoomUC *usecase.ManageZoomUseCase
21+ copyURLUC *usecase.CopyURLUseCase
22+ onQuit func()
23+ onFindOpen func(ctx context.Context) error
24+ onFindNext func(ctx context.Context) error
25+ onFindPrev func(ctx context.Context) error
26+ onFindClose func(ctx context.Context) error
2327 }
2428
2529 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -48,6 +52,26 @@ func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
4852 d.onQuit = fn
4953 }
5054
55+// SetOnFindOpen sets the callback for opening the find bar.
56+func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
57+ d.onFindOpen = fn
58+}
59+
60+// SetOnFindNext sets the callback for the next match action.
61+func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
62+ d.onFindNext = fn
63+}
64+
65+// SetOnFindPrev sets the callback for the previous match action.
66+func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
67+ d.onFindPrev = fn
68+}
69+
70+// SetOnFindClose sets the callback for closing the find bar.
71+func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
72+ d.onFindClose = fn
73+}
74+
5175 // Dispatch routes a keyboard action to the appropriate coordinator.
5276 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
5377 log := logging.FromContext(ctx)
@@ -138,6 +162,26 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
138162 // UI
139163 case input.ActionOpenOmnibox:
140164 return d.navCoord.OpenOmnibox(ctx)
165+ case input.ActionOpenFind:
166+ if d.onFindOpen != nil {
167+ return d.onFindOpen(ctx)
168+ }
169+ log.Debug().Msg("find open action (no handler)")
170+ case input.ActionFindNext:
171+ if d.onFindNext != nil {
172+ return d.onFindNext(ctx)
173+ }
174+ log.Debug().Msg("find next action (no handler)")
175+ case input.ActionFindPrev:
176+ if d.onFindPrev != nil {
177+ return d.onFindPrev(ctx)
178+ }
179+ log.Debug().Msg("find prev action (no handler)")
180+ case input.ActionCloseFind:
181+ if d.onFindClose != nil {
182+ return d.onFindClose(ctx)
183+ }
184+ log.Debug().Msg("find close action (no handler)")
141185 case input.ActionOpenDevTools:
142186 return d.navCoord.OpenDevTools(ctx)
143187 case input.ActionToggleFullscreen:
@@ -14,12 +14,16 @@ import (
14 14
15 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.15 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
16 type KeyboardDispatcher struct {16 type KeyboardDispatcher struct {
17- tabCoord *coordinator.TabCoordinator17+ tabCoord *coordinator.TabCoordinator
18- wsCoord *coordinator.WorkspaceCoordinator18+ wsCoord *coordinator.WorkspaceCoordinator
19- navCoord *coordinator.NavigationCoordinator19+ navCoord *coordinator.NavigationCoordinator
20- zoomUC *usecase.ManageZoomUseCase20+ zoomUC *usecase.ManageZoomUseCase
21- copyURLUC *usecase.CopyURLUseCase21+ copyURLUC *usecase.CopyURLUseCase
22- onQuit func()22+ onQuit func()
23+ onFindOpen func(ctx context.Context) error
24+ onFindNext func(ctx context.Context) error
25+ onFindPrev func(ctx context.Context) error
26+ onFindClose func(ctx context.Context) error
23 }27 }
24 28
25 // NewKeyboardDispatcher creates a new KeyboardDispatcher.29 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -48,6 +52,26 @@ func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
48 d.onQuit = fn52 d.onQuit = fn
49 }53 }
50 54
55+// SetOnFindOpen sets the callback for opening the find bar.
56+func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
57+ d.onFindOpen = fn
58+}
59+
60+// SetOnFindNext sets the callback for the next match action.
61+func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
62+ d.onFindNext = fn
63+}
64+
65+// SetOnFindPrev sets the callback for the previous match action.
66+func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
67+ d.onFindPrev = fn
68+}
69+
70+// SetOnFindClose sets the callback for closing the find bar.
71+func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
72+ d.onFindClose = fn
73+}
74+
51 // Dispatch routes a keyboard action to the appropriate coordinator.75 // Dispatch routes a keyboard action to the appropriate coordinator.
52 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {76 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
53 log := logging.FromContext(ctx)77 log := logging.FromContext(ctx)
@@ -138,6 +162,26 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
138 // UI162 // UI
139 case input.ActionOpenOmnibox:163 case input.ActionOpenOmnibox:
140 return d.navCoord.OpenOmnibox(ctx)164 return d.navCoord.OpenOmnibox(ctx)
165+ case input.ActionOpenFind:
166+ if d.onFindOpen != nil {
167+ return d.onFindOpen(ctx)
168+ }
169+ log.Debug().Msg("find open action (no handler)")
170+ case input.ActionFindNext:
171+ if d.onFindNext != nil {
172+ return d.onFindNext(ctx)
173+ }
174+ log.Debug().Msg("find next action (no handler)")
175+ case input.ActionFindPrev:
176+ if d.onFindPrev != nil {
177+ return d.onFindPrev(ctx)
178+ }
179+ log.Debug().Msg("find prev action (no handler)")
180+ case input.ActionCloseFind:
181+ if d.onFindClose != nil {
182+ return d.onFindClose(ctx)
183+ }
184+ log.Debug().Msg("find close action (no handler)")
141 case input.ActionOpenDevTools:185 case input.ActionOpenDevTools:
142 return d.navCoord.OpenDevTools(ctx)186 return d.navCoord.OpenDevTools(ctx)
143 case input.ActionToggleFullscreen:187 case input.ActionToggleFullscreen:
M internal/ui/input/shortcuts.go +9 -0
@@ -93,6 +93,10 @@ const (
9393
9494 // UI
9595 ActionOpenOmnibox Action = "open_omnibox"
96+ ActionOpenFind Action = "open_find"
97+ ActionFindNext Action = "find_next"
98+ ActionFindPrev Action = "find_prev"
99+ ActionCloseFind Action = "close_find"
96100 ActionOpenDevTools Action = "open_devtools"
97101 ActionToggleFullscreen Action = "toggle_fullscreen"
98102
@@ -173,6 +177,11 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
173177
174178 // Standard browser shortcuts (hardcoded defaults)
175179 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
180+ s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
181+ s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
182+ s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
183+ s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
184+ s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
176185 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
177186 s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
178187 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
@@ -93,6 +93,10 @@ const (
93 93
94 // UI94 // UI
95 ActionOpenOmnibox Action = "open_omnibox"95 ActionOpenOmnibox Action = "open_omnibox"
96+ ActionOpenFind Action = "open_find"
97+ ActionFindNext Action = "find_next"
98+ ActionFindPrev Action = "find_prev"
99+ ActionCloseFind Action = "close_find"
96 ActionOpenDevTools Action = "open_devtools"100 ActionOpenDevTools Action = "open_devtools"
97 ActionToggleFullscreen Action = "toggle_fullscreen"101 ActionToggleFullscreen Action = "toggle_fullscreen"
98 102
@@ -173,6 +177,11 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
173 177
174 // Standard browser shortcuts (hardcoded defaults)178 // Standard browser shortcuts (hardcoded defaults)
175 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox179 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
180+ s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
181+ s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
182+ s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
183+ s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
184+ s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
176 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload185 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
177 s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload186 s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
178 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload187 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
M internal/ui/theme/css.go +84 -0
@@ -40,6 +40,10 @@ func GenerateCSSWithScale(p Palette, scale float64) string {
4040 sb.WriteString(generateOmniboxCSS(p))
4141 sb.WriteString("\n")
4242
43+ // Find bar styling
44+ sb.WriteString(generateFindBarCSS(p))
45+ sb.WriteString("\n")
46+
4347 // Pane styling
4448 sb.WriteString(generatePaneCSS(p))
4549 sb.WriteString("\n")
@@ -297,6 +301,86 @@ entry.omnibox-entry > *:focus-visible {
297301 `
298302 }
299303
304+// generateFindBarCSS creates find bar styles.
305+// Uses em units for scalable UI.
306+func generateFindBarCSS(p Palette) string {
307+ _ = p
308+ return `/* ===== Find Bar Styling ===== */
309+.find-bar-outer {
310+ margin: 0.5em;
311+}
312+
313+.find-bar-container {
314+ background-color: var(--surface-variant);
315+ border: 0.0625em solid var(--border);
316+ border-radius: 0.25em;
317+ box-shadow: 0 0.25em 0.75em alpha(black, 0.2);
318+ padding: 0.5em;
319+ min-width: 20em;
320+}
321+
322+.find-bar-input-row {
323+ margin-bottom: 0.25em;
324+}
325+
326+.find-bar-entry {
327+ background-color: var(--bg);
328+ color: var(--text);
329+ border: 0.0625em solid var(--border);
330+ border-radius: 0.1875em;
331+ padding: 0.4em 0.5em;
332+ caret-color: var(--accent);
333+}
334+
335+entry.find-bar-entry:focus,
336+entry.find-bar-entry:focus-within,
337+entry.find-bar-entry:focus-visible {
338+ border-color: var(--accent);
339+ outline-style: none;
340+ outline-width: 0;
341+ outline-color: transparent;
342+}
343+
344+.find-bar-entry.not-found {
345+ background-color: alpha(#ef4444, 0.15);
346+ border-color: #ef4444;
347+}
348+
349+.find-bar-count {
350+ font-size: 0.8em;
351+ color: var(--muted);
352+ min-width: 3.5em;
353+}
354+
355+.find-bar-count.find-bar-count-has {
356+ color: var(--accent);
357+}
358+
359+.find-bar-nav,
360+.find-bar-close,
361+.find-bar-toggle {
362+ background-image: none;
363+ background-color: alpha(var(--bg), 0.6);
364+ border: 0.0625em solid var(--border);
365+ border-radius: 0.1875em;
366+ padding: 0.25em 0.5em;
367+ transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
368+}
369+
370+.find-bar-nav:hover,
371+.find-bar-close:hover,
372+.find-bar-toggle:hover {
373+ background-color: alpha(var(--accent), 0.15);
374+}
375+
376+.find-bar-toggle:checked {
377+ background-color: alpha(var(--accent), 0.2);
378+ border-color: var(--accent);
379+ color: var(--accent);
380+}
381+`
382+}
383+
300384 // generatePaneCSS creates pane border styles.
301385 // Uses em units for scalable UI.
302386 func generatePaneCSS(p Palette) string {
@@ -40,6 +40,10 @@ func GenerateCSSWithScale(p Palette, scale float64) string {
40 sb.WriteString(generateOmniboxCSS(p))40 sb.WriteString(generateOmniboxCSS(p))
41 sb.WriteString("\n")41 sb.WriteString("\n")
42 42
43+ // Find bar styling
44+ sb.WriteString(generateFindBarCSS(p))
45+ sb.WriteString("\n")
46+
43 // Pane styling47 // Pane styling
44 sb.WriteString(generatePaneCSS(p))48 sb.WriteString(generatePaneCSS(p))
45 sb.WriteString("\n")49 sb.WriteString("\n")
@@ -297,6 +301,86 @@ entry.omnibox-entry > *:focus-visible {
297 `301 `
298 }302 }
299 303
304+// generateFindBarCSS creates find bar styles.
305+// Uses em units for scalable UI.
306+func generateFindBarCSS(p Palette) string {
307+ _ = p
308+ return `/* ===== Find Bar Styling ===== */
309+.find-bar-outer {
310+ margin: 0.5em;
311+}
312+
313+.find-bar-container {
314+ background-color: var(--surface-variant);
315+ border: 0.0625em solid var(--border);
316+ border-radius: 0.25em;
317+ box-shadow: 0 0.25em 0.75em alpha(black, 0.2);
318+ padding: 0.5em;
319+ min-width: 20em;
320+}
321+
322+.find-bar-input-row {
323+ margin-bottom: 0.25em;
324+}
325+
326+.find-bar-entry {
327+ background-color: var(--bg);
328+ color: var(--text);
329+ border: 0.0625em solid var(--border);
330+ border-radius: 0.1875em;
331+ padding: 0.4em 0.5em;
332+ caret-color: var(--accent);
333+}
334+
335+entry.find-bar-entry:focus,
336+entry.find-bar-entry:focus-within,
337+entry.find-bar-entry:focus-visible {
338+ border-color: var(--accent);
339+ outline-style: none;
340+ outline-width: 0;
341+ outline-color: transparent;
342+}
343+
344+.find-bar-entry.not-found {
345+ background-color: alpha(#ef4444, 0.15);
346+ border-color: #ef4444;
347+}
348+
349+.find-bar-count {
350+ font-size: 0.8em;
351+ color: var(--muted);
352+ min-width: 3.5em;
353+}
354+
355+.find-bar-count.find-bar-count-has {
356+ color: var(--accent);
357+}
358+
359+.find-bar-nav,
360+.find-bar-close,
361+.find-bar-toggle {
362+ background-image: none;
363+ background-color: alpha(var(--bg), 0.6);
364+ border: 0.0625em solid var(--border);
365+ border-radius: 0.1875em;
366+ padding: 0.25em 0.5em;
367+ transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
368+}
369+
370+.find-bar-nav:hover,
371+.find-bar-close:hover,
372+.find-bar-toggle:hover {
373+ background-color: alpha(var(--accent), 0.15);
374+}
375+
376+.find-bar-toggle:checked {
377+ background-color: alpha(var(--accent), 0.2);
378+ border-color: var(--accent);
379+ color: var(--accent);
380+}
381+`
382+}
383+
300 // generatePaneCSS creates pane border styles.384 // generatePaneCSS creates pane border styles.
301 // Uses em units for scalable UI.385 // Uses em units for scalable UI.
302 func generatePaneCSS(p Palette) string {386 func generatePaneCSS(p Palette) string {
A internal/ui/theme/find_highlight.go +58 -0
@@ -0,0 +1,58 @@
1+package theme
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+)
8+
9+// GenerateFindHighlightCSS builds CSS to style find-in-page matches using the accent color.
10+func GenerateFindHighlightCSS(p Palette) string {
11+ accent := strings.TrimSpace(p.Accent)
12+ dim := rgbaFromHex(accent, 0.50)
13+ active := accent
14+
15+ return fmt.Sprintf(`/* Find highlight styling */
16+::-webkit-search-results {
17+ background-color: %s !important;
18+}
19+
20+::-webkit-search-results-decoration {
21+ background-color: %s !important;
22+}
23+
24+::selection {
25+ background-color: %s !important;
26+}
27+`, dim, active, active)
28+}
29+
30+func rgbaFromHex(hex string, alpha float64) string {
31+ if len(hex) != 7 || !strings.HasPrefix(hex, "#") {
32+ return hex
33+ }
34+
35+ r, rOK := parseHexByte(hex[1:3])
36+ g, gOK := parseHexByte(hex[3:5])
37+ b, bOK := parseHexByte(hex[5:7])
38+ if !rOK || !gOK || !bOK {
39+ return hex
40+ }
41+
42+ if alpha < 0 {
43+ alpha = 0
44+ }
45+ if alpha > 1 {
46+ alpha = 1
47+ }
48+
49+ return fmt.Sprintf("rgba(%d, %d, %d, %.2f)", r, g, b, alpha)
50+}
51+
52+func parseHexByte(value string) (uint64, bool) {
53+ parsed, err := strconv.ParseUint(value, 16, 8)
54+ if err != nil {
55+ return 0, false
56+ }
57+ return parsed, true
58+}
@@ -0,0 +1,58 @@
1+package theme
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+)
8+
9+// GenerateFindHighlightCSS builds CSS to style find-in-page matches using the accent color.
10+func GenerateFindHighlightCSS(p Palette) string {
11+ accent := strings.TrimSpace(p.Accent)
12+ dim := rgbaFromHex(accent, 0.50)
13+ active := accent
14+
15+ return fmt.Sprintf(`/* Find highlight styling */
16+::-webkit-search-results {
17+ background-color: %s !important;
18+}
19+
20+::-webkit-search-results-decoration {
21+ background-color: %s !important;
22+}
23+
24+::selection {
25+ background-color: %s !important;
26+}
27+`, dim, active, active)
28+}
29+
30+func rgbaFromHex(hex string, alpha float64) string {
31+ if len(hex) != 7 || !strings.HasPrefix(hex, "#") {
32+ return hex
33+ }
34+
35+ r, rOK := parseHexByte(hex[1:3])
36+ g, gOK := parseHexByte(hex[3:5])
37+ b, bOK := parseHexByte(hex[5:7])
38+ if !rOK || !gOK || !bOK {
39+ return hex
40+ }
41+
42+ if alpha < 0 {
43+ alpha = 0
44+ }
45+ if alpha > 1 {
46+ alpha = 1
47+ }
48+
49+ return fmt.Sprintf("rgba(%d, %d, %d, %.2f)", r, g, b, alpha)
50+}
51+
52+func parseHexByte(value string) (uint64, bool) {
53+ parsed, err := strconv.ParseUint(value, 16, 8)
54+ if err != nil {
55+ return 0, false
56+ }
57+ return parsed, true
58+}

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