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 | |||
| 247 | 247 | 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 | 296 | // GoBack provides a mock function for the type MockWebView | |
| 251 | 297 | func (_mock *MockWebView) GoBack(ctx context.Context) error { | |
| 252 | 298 | ret := _mock.Called(ctx) | |
| @@ -247,6 +247,52 @@ func (_c *MockWebView_GetZoomLevel_Call) RunAndReturn(run func() float64) *MockW | |||||
| 247 | return _c | 247 | 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 MockWebView | 296 | // 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 { | |||
| 91 | 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 | 117 | // WebView defines the port interface for browser view operations. | |
| 95 | 118 | // This interface abstracts the underlying browser engine (WebKit, etc.) | |
| 96 | 119 | // and exposes only the navigation and state capabilities needed by | |
| @@ -155,6 +178,12 @@ type WebView interface { | |||
| 155 | 178 | // GetZoomLevel returns the current zoom level. | |
| 156 | 179 | 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 | 187 | // --- Callbacks --- | |
| 159 | 188 | ||
| 160 | 189 | // 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 by | 119 | // 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() float64 | 179 | 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() { | |||
| 91 | 91 | type ContentInjector struct { | |
| 92 | 92 | prefersDark bool | |
| 93 | 93 | themeCSSVars string // CSS custom property declarations for WebUI | |
| 94 | + | findCSS string // CSS for find-in-page highlight styling | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 96 | 97 | // NewContentInjector creates a new injector instance. | |
| @@ -111,6 +112,14 @@ func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error | |||
| 111 | 112 | 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 | 123 | // PrefersDark returns the current dark mode preference. | |
| 115 | 124 | func (ci *ContentInjector) PrefersDark() bool { | |
| 116 | 125 | return ci.prefersDark | |
| @@ -204,5 +213,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 204 | 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 | 233 | log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected") | |
| 208 | 234 | } | |
| @@ -91,6 +91,7 @@ const darkModeScript = `(function() { | |||||
| 91 | type ContentInjector struct { | 91 | type ContentInjector struct { | ||
| 92 | prefersDark bool | 92 | prefersDark bool | ||
| 93 | themeCSSVars string // CSS custom property declarations for WebUI | 93 | 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 nil | 112 | 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.prefersDark | 125 | 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 { | |||
| 115 | 115 | ||
| 116 | 116 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | |
| 117 | 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 | 228 | // NewWebView creates a new WebView with the given context and settings. | |
| @@ -421,6 +529,23 @@ func (wv *WebView) Widget() *webkit.WebView { | |||
| 421 | 529 | 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 | 549 | // LoadURI loads the given URI. | |
| 425 | 550 | func (wv *WebView) LoadURI(ctx context.Context, uri string) error { | |
| 426 | 551 | if wv.destroyed.Load() { | |
| @@ -115,6 +115,114 @@ type WebView struct { | |||||
| 115 | 115 | ||||
| 116 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | 116 | // 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.inner | 529 | 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 ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "sync" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -22,6 +23,7 @@ import ( | |||
| 22 | 23 | "github.com/bnema/dumber/internal/ui/focus" | |
| 23 | 24 | "github.com/bnema/dumber/internal/ui/input" | |
| 24 | 25 | "github.com/bnema/dumber/internal/ui/layout" | |
| 26 | + | "github.com/bnema/dumber/internal/ui/theme" | |
| 25 | 27 | "github.com/bnema/dumber/internal/ui/window" | |
| 26 | 28 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 27 | 29 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| @@ -67,6 +69,8 @@ type App struct { | |||
| 67 | 69 | ||
| 68 | 70 | // Omnibox configuration (omnibox is created per workspace view) | |
| 69 | 71 | omniboxCfg component.OmniboxConfig | |
| 72 | + | // Find bar configuration (find bar is created per workspace view) | |
| 73 | + | findBarCfg component.FindBarConfig | |
| 70 | 74 | ||
| 71 | 75 | // Web content (managed by ContentCoordinator) | |
| 72 | 76 | pool *webkit.WebViewPool | |
| @@ -244,6 +248,20 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 244 | 248 | a.navCoord.SetOmniboxProvider(a) | |
| 245 | 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 | 265 | // Create an initial tab using coordinator | |
| 248 | 266 | initialURL := "dumb://home" | |
| 249 | 267 | if a.deps.InitialURL != "" { | |
| @@ -412,6 +430,22 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 412 | 430 | a.deps.CopyURLUC, | |
| 413 | 431 | ) | |
| 414 | 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 | 450 | // Wire gesture handler to dispatcher (for mouse button 8/9 navigation) | |
| 417 | 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 | 536 | // Set omnibox config for this workspace view | |
| 503 | 537 | wsView.SetOmniboxConfig(a.omniboxCfg) | |
| 538 | + | // Set find bar config for this workspace view | |
| 539 | + | wsView.SetFindBarConfig(a.findBarCfg) | |
| 504 | 540 | ||
| 505 | 541 | // Store in map | |
| 506 | 542 | 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 | 714 | // UpdateOmniboxZoom implements OmniboxProvider. | |
| 627 | 715 | // Updates the zoom indicator on the current omnibox if visible. | |
| 628 | 716 | func (a *App) UpdateOmniboxZoom(factor float64) { | |
| @@ -706,6 +794,10 @@ func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) { | |||
| 706 | 794 | // Keep injector's dark-mode flag in sync for future navigations | |
| 707 | 795 | if a.injector != nil { | |
| 708 | 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 | 803 | 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.OmniboxConfig | 71 | 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.WebViewPool | 76 | 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 coordinator | 265 | // 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 view | 536 | // 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 map | 541 | // Store in map | ||
| 506 | a.workspaceViews[tab.ID] = wsView | 542 | 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 navigations | 794 | // 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 { | |||
| 37 | 37 | omniboxPaneID entity.PaneID // Which pane has the omnibox | |
| 38 | 38 | 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 | 46 | workspace *entity.Workspace | |
| 41 | 47 | paneViews map[entity.PaneID]*PaneView | |
| 42 | 48 | activePaneID entity.PaneID | |
| @@ -181,6 +187,10 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||
| 181 | 187 | if wv.activePaneID != paneID && wv.omnibox != nil { | |
| 182 | 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 | 195 | // Deactivate current active pane | |
| 186 | 196 | if wv.activePaneID != "" { | |
| @@ -437,6 +447,13 @@ func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) { | |||
| 437 | 447 | 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 | 457 | // ShowOmnibox creates and shows the omnibox in the active pane. | |
| 441 | 458 | func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) { | |
| 442 | 459 | wv.mu.Lock() | |
| @@ -520,6 +537,111 @@ func (wv *WorkspaceView) hideOmniboxInternal() { | |||
| 520 | 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 | 645 | // GetOmnibox returns the current omnibox if visible. | |
| 524 | 646 | func (wv *WorkspaceView) GetOmnibox() *Omnibox { | |
| 525 | 647 | wv.mu.RLock() | |
| @@ -37,6 +37,12 @@ type WorkspaceView struct { | |||||
| 37 | omniboxPaneID entity.PaneID // Which pane has the omnibox | 37 | omniboxPaneID entity.PaneID // Which pane has the omnibox | ||
| 38 | omniboxCfg OmniboxConfig // Stored config for creating omniboxes | 38 | 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.Workspace | 46 | workspace *entity.Workspace | ||
| 41 | paneViews map[entity.PaneID]*PaneView | 47 | paneViews map[entity.PaneID]*PaneView | ||
| 42 | activePaneID entity.PaneID | 48 | 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 pane | 195 | // 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 = cfg | 447 | 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | |
| 16 | 16 | 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 | |
| 23 | 27 | } | |
| 24 | 28 | ||
| 25 | 29 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | |
| @@ -48,6 +52,26 @@ func (d *KeyboardDispatcher) SetOnQuit(fn func()) { | |||
| 48 | 52 | 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 | 75 | // Dispatch routes a keyboard action to the appropriate coordinator. | |
| 52 | 76 | func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { | |
| 53 | 77 | log := logging.FromContext(ctx) | |
| @@ -138,6 +162,26 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) | |||
| 138 | 162 | // UI | |
| 139 | 163 | case input.ActionOpenOmnibox: | |
| 140 | 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 | 185 | case input.ActionOpenDevTools: | |
| 142 | 186 | return d.navCoord.OpenDevTools(ctx) | |
| 143 | 187 | 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.TabCoordinator | 17 | + | tabCoord *coordinator.TabCoordinator |
| 18 | - | wsCoord *coordinator.WorkspaceCoordinator | 18 | + | wsCoord *coordinator.WorkspaceCoordinator |
| 19 | - | navCoord *coordinator.NavigationCoordinator | 19 | + | navCoord *coordinator.NavigationCoordinator |
| 20 | - | zoomUC *usecase.ManageZoomUseCase | 20 | + | zoomUC *usecase.ManageZoomUseCase |
| 21 | - | copyURLUC *usecase.CopyURLUseCase | 21 | + | 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 = fn | 52 | 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 | // UI | 162 | // 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 ( | |||
| 93 | 93 | ||
| 94 | 94 | // UI | |
| 95 | 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 | 100 | ActionOpenDevTools Action = "open_devtools" | |
| 97 | 101 | ActionToggleFullscreen Action = "toggle_fullscreen" | |
| 98 | 102 | ||
| @@ -173,6 +177,11 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work | |||
| 173 | 177 | ||
| 174 | 178 | // Standard browser shortcuts (hardcoded defaults) | |
| 175 | 179 | 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 | 185 | s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload | |
| 177 | 186 | s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload | |
| 178 | 187 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | |
| @@ -93,6 +93,10 @@ const ( | |||||
| 93 | 93 | ||||
| 94 | // UI | 94 | // 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}] = ActionOpenOmnibox | 179 | 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}] = ActionReload | 185 | s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload | ||
| 177 | s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload | 186 | s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload | ||
| 178 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | 187 | 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 { | |||
| 40 | 40 | sb.WriteString(generateOmniboxCSS(p)) | |
| 41 | 41 | sb.WriteString("\n") | |
| 42 | 42 | ||
| 43 | + | // Find bar styling | |
| 44 | + | sb.WriteString(generateFindBarCSS(p)) | |
| 45 | + | sb.WriteString("\n") | |
| 46 | + | ||
| 43 | 47 | // Pane styling | |
| 44 | 48 | sb.WriteString(generatePaneCSS(p)) | |
| 45 | 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 | 384 | // generatePaneCSS creates pane border styles. | |
| 301 | 385 | // Uses em units for scalable UI. | |
| 302 | 386 | 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 styling | 47 | // 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 | + | } | |||