Merge pull request #97 from bnema/next
Release v0.24.0
40 files · +2226 -478
M .mockery.yaml +1 -0
| @@ -26,6 +26,7 @@ packages: | |||
| 26 | 26 | TextInputTarget: {} | |
| 27 | 27 | FocusedInputProvider: {} | |
| 28 | 28 | AccentPickerUI: {} | |
| 29 | + | FontDetector: {} | |
| 29 | 30 | github.com/bnema/dumber/internal/domain/repository: | |
| 30 | 31 | config: | |
| 31 | 32 | dir: '{{.InterfaceDir}}/mocks' | |
| @@ -26,6 +26,7 @@ packages: | |||||
| 26 | TextInputTarget: {} | 26 | TextInputTarget: {} | ||
| 27 | FocusedInputProvider: {} | 27 | FocusedInputProvider: {} | ||
| 28 | AccentPickerUI: {} | 28 | AccentPickerUI: {} | ||
| 29 | + | FontDetector: {} | |||
| 29 | github.com/bnema/dumber/internal/domain/repository: | 30 | github.com/bnema/dumber/internal/domain/repository: | ||
| 30 | config: | 31 | config: | ||
| 31 | dir: '{{.InterfaceDir}}/mocks' | 32 | dir: '{{.InterfaceDir}}/mocks' | ||
M CHANGELOG.md +14 -0
| @@ -4,11 +4,25 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ## [0.24.0] - 2026-01-06 | |
| 8 | + | ||
| 7 | 9 | ### Added | |
| 10 | + | - **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83. | |
| 11 | + | - **Font availability detection**: On first run, detects available system fonts using fontconfig (`fc-list`) and selects the best font from a fallback chain instead of hardcoding `Fira Sans` / `Fira Code`. Fallback chains: sans-serif (Fira Sans, Noto Sans, DejaVu Sans, Liberation Sans, FreeSans), serif (Noto Serif, DejaVu Serif, Liberation Serif, FreeSerif), monospace (Fira Code, JetBrains Mono, Noto Sans Mono, DejaVu Sans Mono, Liberation Mono, FreeMono). Falls back to generic CSS fonts if none available. Fixes #85. | |
| 12 | + | - **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal. | |
| 13 | + | - **AUR git package CI**: Automated CI workflow to publish `dumber-browser-git` AUR package on push to main branch. | |
| 8 | 14 | ||
| 9 | 15 | ### Changed | |
| 16 | + | - **Flatpak runtime**: Bumped to GNOME 49 runtime. | |
| 17 | + | - **Focus method refactored**: `Focus()` now delegates to `ApplyFocusChange()` to ensure stack index is updated when focusing stacked panes. | |
| 10 | 18 | ||
| 11 | 19 | ### Fixed | |
| 20 | + | - **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle. | |
| 21 | + | - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane. | |
| 22 | + | - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus. Fixes #89. | |
| 23 | + | - **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down). | |
| 24 | + | - **Link status overlay auto-hide**: Link URL overlay now automatically hides after 10 seconds of inactivity instead of staying visible indefinitely. Timer resets when hovering new links. Fixes #82. | |
| 25 | + | - **Omnibox multi-word search**: History search now matches all words in query (AND logic) and searches both URL and title fields. Prefix matching enabled (e.g., "git iss" matches "github.com/issues"). FTS5 special characters are now sanitized to prevent syntax errors. Results interleave URL and title matches for better balance. Fixes #87. | |
| 12 | 26 | ||
| 13 | 27 | ## [0.23.2] - 2025-12-31 | |
| 14 | 28 | ||
| @@ -4,11 +4,25 @@ All notable changes to this project will be documented in this file. | |||||
| 4 | 4 | ||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | + | ## [0.24.0] - 2026-01-06 | |||
| 8 | + | ||||
| 7 | ### Added | 9 | ### Added | ||
| 10 | + | - **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83. | |||
| 11 | + | - **Font availability detection**: On first run, detects available system fonts using fontconfig (`fc-list`) and selects the best font from a fallback chain instead of hardcoding `Fira Sans` / `Fira Code`. Fallback chains: sans-serif (Fira Sans, Noto Sans, DejaVu Sans, Liberation Sans, FreeSans), serif (Noto Serif, DejaVu Serif, Liberation Serif, FreeSerif), monospace (Fira Code, JetBrains Mono, Noto Sans Mono, DejaVu Sans Mono, Liberation Mono, FreeMono). Falls back to generic CSS fonts if none available. Fixes #85. | |||
| 12 | + | - **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal. | |||
| 13 | + | - **AUR git package CI**: Automated CI workflow to publish `dumber-browser-git` AUR package on push to main branch. | |||
| 8 | 14 | ||||
| 9 | ### Changed | 15 | ### Changed | ||
| 16 | + | - **Flatpak runtime**: Bumped to GNOME 49 runtime. | |||
| 17 | + | - **Focus method refactored**: `Focus()` now delegates to `ApplyFocusChange()` to ensure stack index is updated when focusing stacked panes. | |||
| 10 | 18 | ||||
| 11 | ### Fixed | 19 | ### Fixed | ||
| 20 | + | - **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle. | |||
| 21 | + | - **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane. | |||
| 22 | + | - **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus. Fixes #89. | |||
| 23 | + | - **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down). | |||
| 24 | + | - **Link status overlay auto-hide**: Link URL overlay now automatically hides after 10 seconds of inactivity instead of staying visible indefinitely. Timer resets when hovering new links. Fixes #82. | |||
| 25 | + | - **Omnibox multi-word search**: History search now matches all words in query (AND logic) and searches both URL and title fields. Prefix matching enabled (e.g., "git iss" matches "github.com/issues"). FTS5 special characters are now sanitized to prevent syntax errors. Results interleave URL and title matches for better balance. Fixes #87. | |||
| 12 | 26 | ||||
| 13 | ## [0.23.2] - 2025-12-31 | 27 | ## [0.23.2] - 2025-12-31 | ||
| 14 | 28 | ||||
M docs/CONFIG.md +14 -0
| @@ -458,6 +458,20 @@ If set, dumber prepends prefix-derived paths to: | |||
| 458 | 458 | prefix = "/opt/webkitgtk" | |
| 459 | 459 | ``` | |
| 460 | 460 | ||
| 461 | + | ## Clipboard | |
| 462 | + | ||
| 463 | + | | Key | Type | Default | Description | | |
| 464 | + | |-----|------|---------|-------------| | |
| 465 | + | | `clipboard.auto_copy_on_selection` | bool | `true` | Automatically copy selected text to clipboard (zellij/tmux-style) | | |
| 466 | + | ||
| 467 | + | When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas. | |
| 468 | + | ||
| 469 | + | **Example:** | |
| 470 | + | ```toml | |
| 471 | + | [clipboard] | |
| 472 | + | auto_copy_on_selection = true # Enabled by default | |
| 473 | + | ``` | |
| 474 | + | ||
| 461 | 475 | ## Content Filtering | |
| 462 | 476 | ||
| 463 | 477 | | Key | Type | Default | Description | | |
| @@ -458,6 +458,20 @@ If set, dumber prepends prefix-derived paths to: | |||||
| 458 | prefix = "/opt/webkitgtk" | 458 | prefix = "/opt/webkitgtk" | ||
| 459 | ``` | 459 | ``` | ||
| 460 | 460 | ||||
| 461 | + | ## Clipboard | |||
| 462 | + | ||||
| 463 | + | | Key | Type | Default | Description | | |||
| 464 | + | |-----|------|---------|-------------| | |||
| 465 | + | | `clipboard.auto_copy_on_selection` | bool | `true` | Automatically copy selected text to clipboard (zellij/tmux-style) | | |||
| 466 | + | ||||
| 467 | + | When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas. | |||
| 468 | + | ||||
| 469 | + | **Example:** | |||
| 470 | + | ```toml | |||
| 471 | + | [clipboard] | |||
| 472 | + | auto_copy_on_selection = true # Enabled by default | |||
| 473 | + | ``` | |||
| 474 | + | ||||
| 461 | ## Content Filtering | 475 | ## Content Filtering | ||
| 462 | 476 | ||||
| 463 | | Key | Type | Default | Description | | 477 | | Key | Type | Default | Description | | ||
A internal/application/port/font_detector.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // FontCategory represents a category of fonts for fallback selection. | |
| 6 | + | type FontCategory string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | // FontCategorySansSerif is for sans-serif fonts (UI text). | |
| 10 | + | FontCategorySansSerif FontCategory = "sans-serif" | |
| 11 | + | // FontCategorySerif is for serif fonts (reading content). | |
| 12 | + | FontCategorySerif FontCategory = "serif" | |
| 13 | + | // FontCategoryMonospace is for monospace fonts (code, fixed-width). | |
| 14 | + | FontCategoryMonospace FontCategory = "monospace" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // FontDetector detects available system fonts and selects the best | |
| 18 | + | // font from a fallback chain. This is used during first-run config | |
| 19 | + | // creation to ensure configured fonts are actually installed. | |
| 20 | + | type FontDetector interface { | |
| 21 | + | // GetAvailableFonts returns a list of font family names installed on the system. | |
| 22 | + | // Returns an error if font detection is not available (e.g., fc-list missing). | |
| 23 | + | GetAvailableFonts(ctx context.Context) ([]string, error) | |
| 24 | + | ||
| 25 | + | // SelectBestFont returns the first available font from the fallback chain, | |
| 26 | + | // or the generic CSS fallback (e.g., "sans-serif") if none are installed. | |
| 27 | + | // The category is used to determine the generic fallback. | |
| 28 | + | SelectBestFont(ctx context.Context, category FontCategory, fallbackChain []string) string | |
| 29 | + | ||
| 30 | + | // IsAvailable returns true if font detection is available on this system. | |
| 31 | + | IsAvailable(ctx context.Context) bool | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // FontCategory represents a category of fonts for fallback selection. | |||
| 6 | + | type FontCategory string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | // FontCategorySansSerif is for sans-serif fonts (UI text). | |||
| 10 | + | FontCategorySansSerif FontCategory = "sans-serif" | |||
| 11 | + | // FontCategorySerif is for serif fonts (reading content). | |||
| 12 | + | FontCategorySerif FontCategory = "serif" | |||
| 13 | + | // FontCategoryMonospace is for monospace fonts (code, fixed-width). | |||
| 14 | + | FontCategoryMonospace FontCategory = "monospace" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // FontDetector detects available system fonts and selects the best | |||
| 18 | + | // font from a fallback chain. This is used during first-run config | |||
| 19 | + | // creation to ensure configured fonts are actually installed. | |||
| 20 | + | type FontDetector interface { | |||
| 21 | + | // GetAvailableFonts returns a list of font family names installed on the system. | |||
| 22 | + | // Returns an error if font detection is not available (e.g., fc-list missing). | |||
| 23 | + | GetAvailableFonts(ctx context.Context) ([]string, error) | |||
| 24 | + | ||||
| 25 | + | // SelectBestFont returns the first available font from the fallback chain, | |||
| 26 | + | // or the generic CSS fallback (e.g., "sans-serif") if none are installed. | |||
| 27 | + | // The category is used to determine the generic fallback. | |||
| 28 | + | SelectBestFont(ctx context.Context, category FontCategory, fallbackChain []string) string | |||
| 29 | + | ||||
| 30 | + | // IsAvailable returns true if font detection is available on this system. | |||
| 31 | + | IsAvailable(ctx context.Context) bool | |||
| 32 | + | } | |||
M internal/application/port/mocks/mocks.go +203 -0
| @@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||
| 1214 | 1214 | return _c | |
| 1215 | 1215 | } | |
| 1216 | 1216 | ||
| 1217 | + | // NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1218 | + | // The first argument is typically a *testing.T value. | |
| 1219 | + | func NewMockFontDetector(t interface { | |
| 1220 | + | mock.TestingT | |
| 1221 | + | Cleanup(func()) | |
| 1222 | + | }) *MockFontDetector { | |
| 1223 | + | mock := &MockFontDetector{} | |
| 1224 | + | mock.Mock.Test(t) | |
| 1225 | + | ||
| 1226 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1227 | + | ||
| 1228 | + | return mock | |
| 1229 | + | } | |
| 1230 | + | ||
| 1231 | + | // MockFontDetector is an autogenerated mock type for the FontDetector type | |
| 1232 | + | type MockFontDetector struct { | |
| 1233 | + | mock.Mock | |
| 1234 | + | } | |
| 1235 | + | ||
| 1236 | + | type MockFontDetector_Expecter struct { | |
| 1237 | + | mock *mock.Mock | |
| 1238 | + | } | |
| 1239 | + | ||
| 1240 | + | func (_m *MockFontDetector) EXPECT() *MockFontDetector_Expecter { | |
| 1241 | + | return &MockFontDetector_Expecter{mock: &_m.Mock} | |
| 1242 | + | } | |
| 1243 | + | ||
| 1244 | + | // GetAvailableFonts provides a mock function for the type MockFontDetector | |
| 1245 | + | func (_mock *MockFontDetector) GetAvailableFonts(ctx context.Context) ([]string, error) { | |
| 1246 | + | ret := _mock.Called(ctx) | |
| 1247 | + | ||
| 1248 | + | if len(ret) == 0 { | |
| 1249 | + | panic("no return value specified for GetAvailableFonts") | |
| 1250 | + | } | |
| 1251 | + | ||
| 1252 | + | var r0 []string | |
| 1253 | + | var r1 error | |
| 1254 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { | |
| 1255 | + | return returnFunc(ctx) | |
| 1256 | + | } | |
| 1257 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { | |
| 1258 | + | r0 = returnFunc(ctx) | |
| 1259 | + | } else { | |
| 1260 | + | if ret.Get(0) != nil { | |
| 1261 | + | r0 = ret.Get(0).([]string) | |
| 1262 | + | } | |
| 1263 | + | } | |
| 1264 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 1265 | + | r1 = returnFunc(ctx) | |
| 1266 | + | } else { | |
| 1267 | + | r1 = ret.Error(1) | |
| 1268 | + | } | |
| 1269 | + | return r0, r1 | |
| 1270 | + | } | |
| 1271 | + | ||
| 1272 | + | // MockFontDetector_GetAvailableFonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvailableFonts' | |
| 1273 | + | type MockFontDetector_GetAvailableFonts_Call struct { | |
| 1274 | + | *mock.Call | |
| 1275 | + | } | |
| 1276 | + | ||
| 1277 | + | // GetAvailableFonts is a helper method to define mock.On call | |
| 1278 | + | // - ctx context.Context | |
| 1279 | + | func (_e *MockFontDetector_Expecter) GetAvailableFonts(ctx interface{}) *MockFontDetector_GetAvailableFonts_Call { | |
| 1280 | + | return &MockFontDetector_GetAvailableFonts_Call{Call: _e.mock.On("GetAvailableFonts", ctx)} | |
| 1281 | + | } | |
| 1282 | + | ||
| 1283 | + | func (_c *MockFontDetector_GetAvailableFonts_Call) Run(run func(ctx context.Context)) *MockFontDetector_GetAvailableFonts_Call { | |
| 1284 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1285 | + | var arg0 context.Context | |
| 1286 | + | if args[0] != nil { | |
| 1287 | + | arg0 = args[0].(context.Context) | |
| 1288 | + | } | |
| 1289 | + | run( | |
| 1290 | + | arg0, | |
| 1291 | + | ) | |
| 1292 | + | }) | |
| 1293 | + | return _c | |
| 1294 | + | } | |
| 1295 | + | ||
| 1296 | + | func (_c *MockFontDetector_GetAvailableFonts_Call) Return(strings []string, err error) *MockFontDetector_GetAvailableFonts_Call { | |
| 1297 | + | _c.Call.Return(strings, err) | |
| 1298 | + | return _c | |
| 1299 | + | } | |
| 1300 | + | ||
| 1301 | + | func (_c *MockFontDetector_GetAvailableFonts_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockFontDetector_GetAvailableFonts_Call { | |
| 1302 | + | _c.Call.Return(run) | |
| 1303 | + | return _c | |
| 1304 | + | } | |
| 1305 | + | ||
| 1306 | + | // IsAvailable provides a mock function for the type MockFontDetector | |
| 1307 | + | func (_mock *MockFontDetector) IsAvailable(ctx context.Context) bool { | |
| 1308 | + | ret := _mock.Called(ctx) | |
| 1309 | + | ||
| 1310 | + | if len(ret) == 0 { | |
| 1311 | + | panic("no return value specified for IsAvailable") | |
| 1312 | + | } | |
| 1313 | + | ||
| 1314 | + | var r0 bool | |
| 1315 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok { | |
| 1316 | + | r0 = returnFunc(ctx) | |
| 1317 | + | } else { | |
| 1318 | + | r0 = ret.Get(0).(bool) | |
| 1319 | + | } | |
| 1320 | + | return r0 | |
| 1321 | + | } | |
| 1322 | + | ||
| 1323 | + | // MockFontDetector_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable' | |
| 1324 | + | type MockFontDetector_IsAvailable_Call struct { | |
| 1325 | + | *mock.Call | |
| 1326 | + | } | |
| 1327 | + | ||
| 1328 | + | // IsAvailable is a helper method to define mock.On call | |
| 1329 | + | // - ctx context.Context | |
| 1330 | + | func (_e *MockFontDetector_Expecter) IsAvailable(ctx interface{}) *MockFontDetector_IsAvailable_Call { | |
| 1331 | + | return &MockFontDetector_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)} | |
| 1332 | + | } | |
| 1333 | + | ||
| 1334 | + | func (_c *MockFontDetector_IsAvailable_Call) Run(run func(ctx context.Context)) *MockFontDetector_IsAvailable_Call { | |
| 1335 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1336 | + | var arg0 context.Context | |
| 1337 | + | if args[0] != nil { | |
| 1338 | + | arg0 = args[0].(context.Context) | |
| 1339 | + | } | |
| 1340 | + | run( | |
| 1341 | + | arg0, | |
| 1342 | + | ) | |
| 1343 | + | }) | |
| 1344 | + | return _c | |
| 1345 | + | } | |
| 1346 | + | ||
| 1347 | + | func (_c *MockFontDetector_IsAvailable_Call) Return(b bool) *MockFontDetector_IsAvailable_Call { | |
| 1348 | + | _c.Call.Return(b) | |
| 1349 | + | return _c | |
| 1350 | + | } | |
| 1351 | + | ||
| 1352 | + | func (_c *MockFontDetector_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockFontDetector_IsAvailable_Call { | |
| 1353 | + | _c.Call.Return(run) | |
| 1354 | + | return _c | |
| 1355 | + | } | |
| 1356 | + | ||
| 1357 | + | // SelectBestFont provides a mock function for the type MockFontDetector | |
| 1358 | + | func (_mock *MockFontDetector) SelectBestFont(ctx context.Context, category port.FontCategory, fallbackChain []string) string { | |
| 1359 | + | ret := _mock.Called(ctx, category, fallbackChain) | |
| 1360 | + | ||
| 1361 | + | if len(ret) == 0 { | |
| 1362 | + | panic("no return value specified for SelectBestFont") | |
| 1363 | + | } | |
| 1364 | + | ||
| 1365 | + | var r0 string | |
| 1366 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.FontCategory, []string) string); ok { | |
| 1367 | + | r0 = returnFunc(ctx, category, fallbackChain) | |
| 1368 | + | } else { | |
| 1369 | + | r0 = ret.Get(0).(string) | |
| 1370 | + | } | |
| 1371 | + | return r0 | |
| 1372 | + | } | |
| 1373 | + | ||
| 1374 | + | // MockFontDetector_SelectBestFont_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectBestFont' | |
| 1375 | + | type MockFontDetector_SelectBestFont_Call struct { | |
| 1376 | + | *mock.Call | |
| 1377 | + | } | |
| 1378 | + | ||
| 1379 | + | // SelectBestFont is a helper method to define mock.On call | |
| 1380 | + | // - ctx context.Context | |
| 1381 | + | // - category port.FontCategory | |
| 1382 | + | // - fallbackChain []string | |
| 1383 | + | func (_e *MockFontDetector_Expecter) SelectBestFont(ctx interface{}, category interface{}, fallbackChain interface{}) *MockFontDetector_SelectBestFont_Call { | |
| 1384 | + | return &MockFontDetector_SelectBestFont_Call{Call: _e.mock.On("SelectBestFont", ctx, category, fallbackChain)} | |
| 1385 | + | } | |
| 1386 | + | ||
| 1387 | + | func (_c *MockFontDetector_SelectBestFont_Call) Run(run func(ctx context.Context, category port.FontCategory, fallbackChain []string)) *MockFontDetector_SelectBestFont_Call { | |
| 1388 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1389 | + | var arg0 context.Context | |
| 1390 | + | if args[0] != nil { | |
| 1391 | + | arg0 = args[0].(context.Context) | |
| 1392 | + | } | |
| 1393 | + | var arg1 port.FontCategory | |
| 1394 | + | if args[1] != nil { | |
| 1395 | + | arg1 = args[1].(port.FontCategory) | |
| 1396 | + | } | |
| 1397 | + | var arg2 []string | |
| 1398 | + | if args[2] != nil { | |
| 1399 | + | arg2 = args[2].([]string) | |
| 1400 | + | } | |
| 1401 | + | run( | |
| 1402 | + | arg0, | |
| 1403 | + | arg1, | |
| 1404 | + | arg2, | |
| 1405 | + | ) | |
| 1406 | + | }) | |
| 1407 | + | return _c | |
| 1408 | + | } | |
| 1409 | + | ||
| 1410 | + | func (_c *MockFontDetector_SelectBestFont_Call) Return(s string) *MockFontDetector_SelectBestFont_Call { | |
| 1411 | + | _c.Call.Return(s) | |
| 1412 | + | return _c | |
| 1413 | + | } | |
| 1414 | + | ||
| 1415 | + | func (_c *MockFontDetector_SelectBestFont_Call) RunAndReturn(run func(ctx context.Context, category port.FontCategory, fallbackChain []string) string) *MockFontDetector_SelectBestFont_Call { | |
| 1416 | + | _c.Call.Return(run) | |
| 1417 | + | return _c | |
| 1418 | + | } | |
| 1419 | + | ||
| 1217 | 1420 | // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1218 | 1421 | // The first argument is typically a *testing.T value. | |
| 1219 | 1422 | func NewMockHardwareSurveyor(t interface { | |
| @@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||||
| 1214 | return _c | 1214 | return _c | ||
| 1215 | } | 1215 | } | ||
| 1216 | 1216 | ||||
| 1217 | + | // NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1218 | + | // The first argument is typically a *testing.T value. | |||
| 1219 | + | func NewMockFontDetector(t interface { | |||
| 1220 | + | mock.TestingT | |||
| 1221 | + | Cleanup(func()) | |||
| 1222 | + | }) *MockFontDetector { | |||
| 1223 | + | mock := &MockFontDetector{} | |||
| 1224 | + | mock.Mock.Test(t) | |||
| 1225 | + | ||||
| 1226 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1227 | + | ||||
| 1228 | + | return mock | |||
| 1229 | + | } | |||
| 1230 | + | ||||
| 1231 | + | // MockFontDetector is an autogenerated mock type for the FontDetector type | |||
| 1232 | + | type MockFontDetector struct { | |||
| 1233 | + | mock.Mock | |||
| 1234 | + | } | |||
| 1235 | + | ||||
| 1236 | + | type MockFontDetector_Expecter struct { | |||
| 1237 | + | mock *mock.Mock | |||
| 1238 | + | } | |||
| 1239 | + | ||||
| 1240 | + | func (_m *MockFontDetector) EXPECT() *MockFontDetector_Expecter { | |||
| 1241 | + | return &MockFontDetector_Expecter{mock: &_m.Mock} | |||
| 1242 | + | } | |||
| 1243 | + | ||||
| 1244 | + | // GetAvailableFonts provides a mock function for the type MockFontDetector | |||
| 1245 | + | func (_mock *MockFontDetector) GetAvailableFonts(ctx context.Context) ([]string, error) { | |||
| 1246 | + | ret := _mock.Called(ctx) | |||
| 1247 | + | ||||
| 1248 | + | if len(ret) == 0 { | |||
| 1249 | + | panic("no return value specified for GetAvailableFonts") | |||
| 1250 | + | } | |||
| 1251 | + | ||||
| 1252 | + | var r0 []string | |||
| 1253 | + | var r1 error | |||
| 1254 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { | |||
| 1255 | + | return returnFunc(ctx) | |||
| 1256 | + | } | |||
| 1257 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { | |||
| 1258 | + | r0 = returnFunc(ctx) | |||
| 1259 | + | } else { | |||
| 1260 | + | if ret.Get(0) != nil { | |||
| 1261 | + | r0 = ret.Get(0).([]string) | |||
| 1262 | + | } | |||
| 1263 | + | } | |||
| 1264 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 1265 | + | r1 = returnFunc(ctx) | |||
| 1266 | + | } else { | |||
| 1267 | + | r1 = ret.Error(1) | |||
| 1268 | + | } | |||
| 1269 | + | return r0, r1 | |||
| 1270 | + | } | |||
| 1271 | + | ||||
| 1272 | + | // MockFontDetector_GetAvailableFonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvailableFonts' | |||
| 1273 | + | type MockFontDetector_GetAvailableFonts_Call struct { | |||
| 1274 | + | *mock.Call | |||
| 1275 | + | } | |||
| 1276 | + | ||||
| 1277 | + | // GetAvailableFonts is a helper method to define mock.On call | |||
| 1278 | + | // - ctx context.Context | |||
| 1279 | + | func (_e *MockFontDetector_Expecter) GetAvailableFonts(ctx interface{}) *MockFontDetector_GetAvailableFonts_Call { | |||
| 1280 | + | return &MockFontDetector_GetAvailableFonts_Call{Call: _e.mock.On("GetAvailableFonts", ctx)} | |||
| 1281 | + | } | |||
| 1282 | + | ||||
| 1283 | + | func (_c *MockFontDetector_GetAvailableFonts_Call) Run(run func(ctx context.Context)) *MockFontDetector_GetAvailableFonts_Call { | |||
| 1284 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1285 | + | var arg0 context.Context | |||
| 1286 | + | if args[0] != nil { | |||
| 1287 | + | arg0 = args[0].(context.Context) | |||
| 1288 | + | } | |||
| 1289 | + | run( | |||
| 1290 | + | arg0, | |||
| 1291 | + | ) | |||
| 1292 | + | }) | |||
| 1293 | + | return _c | |||
| 1294 | + | } | |||
| 1295 | + | ||||
| 1296 | + | func (_c *MockFontDetector_GetAvailableFonts_Call) Return(strings []string, err error) *MockFontDetector_GetAvailableFonts_Call { | |||
| 1297 | + | _c.Call.Return(strings, err) | |||
| 1298 | + | return _c | |||
| 1299 | + | } | |||
| 1300 | + | ||||
| 1301 | + | func (_c *MockFontDetector_GetAvailableFonts_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockFontDetector_GetAvailableFonts_Call { | |||
| 1302 | + | _c.Call.Return(run) | |||
| 1303 | + | return _c | |||
| 1304 | + | } | |||
| 1305 | + | ||||
| 1306 | + | // IsAvailable provides a mock function for the type MockFontDetector | |||
| 1307 | + | func (_mock *MockFontDetector) IsAvailable(ctx context.Context) bool { | |||
| 1308 | + | ret := _mock.Called(ctx) | |||
| 1309 | + | ||||
| 1310 | + | if len(ret) == 0 { | |||
| 1311 | + | panic("no return value specified for IsAvailable") | |||
| 1312 | + | } | |||
| 1313 | + | ||||
| 1314 | + | var r0 bool | |||
| 1315 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok { | |||
| 1316 | + | r0 = returnFunc(ctx) | |||
| 1317 | + | } else { | |||
| 1318 | + | r0 = ret.Get(0).(bool) | |||
| 1319 | + | } | |||
| 1320 | + | return r0 | |||
| 1321 | + | } | |||
| 1322 | + | ||||
| 1323 | + | // MockFontDetector_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable' | |||
| 1324 | + | type MockFontDetector_IsAvailable_Call struct { | |||
| 1325 | + | *mock.Call | |||
| 1326 | + | } | |||
| 1327 | + | ||||
| 1328 | + | // IsAvailable is a helper method to define mock.On call | |||
| 1329 | + | // - ctx context.Context | |||
| 1330 | + | func (_e *MockFontDetector_Expecter) IsAvailable(ctx interface{}) *MockFontDetector_IsAvailable_Call { | |||
| 1331 | + | return &MockFontDetector_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)} | |||
| 1332 | + | } | |||
| 1333 | + | ||||
| 1334 | + | func (_c *MockFontDetector_IsAvailable_Call) Run(run func(ctx context.Context)) *MockFontDetector_IsAvailable_Call { | |||
| 1335 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1336 | + | var arg0 context.Context | |||
| 1337 | + | if args[0] != nil { | |||
| 1338 | + | arg0 = args[0].(context.Context) | |||
| 1339 | + | } | |||
| 1340 | + | run( | |||
| 1341 | + | arg0, | |||
| 1342 | + | ) | |||
| 1343 | + | }) | |||
| 1344 | + | return _c | |||
| 1345 | + | } | |||
| 1346 | + | ||||
| 1347 | + | func (_c *MockFontDetector_IsAvailable_Call) Return(b bool) *MockFontDetector_IsAvailable_Call { | |||
| 1348 | + | _c.Call.Return(b) | |||
| 1349 | + | return _c | |||
| 1350 | + | } | |||
| 1351 | + | ||||
| 1352 | + | func (_c *MockFontDetector_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockFontDetector_IsAvailable_Call { | |||
| 1353 | + | _c.Call.Return(run) | |||
| 1354 | + | return _c | |||
| 1355 | + | } | |||
| 1356 | + | ||||
| 1357 | + | // SelectBestFont provides a mock function for the type MockFontDetector | |||
| 1358 | + | func (_mock *MockFontDetector) SelectBestFont(ctx context.Context, category port.FontCategory, fallbackChain []string) string { | |||
| 1359 | + | ret := _mock.Called(ctx, category, fallbackChain) | |||
| 1360 | + | ||||
| 1361 | + | if len(ret) == 0 { | |||
| 1362 | + | panic("no return value specified for SelectBestFont") | |||
| 1363 | + | } | |||
| 1364 | + | ||||
| 1365 | + | var r0 string | |||
| 1366 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.FontCategory, []string) string); ok { | |||
| 1367 | + | r0 = returnFunc(ctx, category, fallbackChain) | |||
| 1368 | + | } else { | |||
| 1369 | + | r0 = ret.Get(0).(string) | |||
| 1370 | + | } | |||
| 1371 | + | return r0 | |||
| 1372 | + | } | |||
| 1373 | + | ||||
| 1374 | + | // MockFontDetector_SelectBestFont_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectBestFont' | |||
| 1375 | + | type MockFontDetector_SelectBestFont_Call struct { | |||
| 1376 | + | *mock.Call | |||
| 1377 | + | } | |||
| 1378 | + | ||||
| 1379 | + | // SelectBestFont is a helper method to define mock.On call | |||
| 1380 | + | // - ctx context.Context | |||
| 1381 | + | // - category port.FontCategory | |||
| 1382 | + | // - fallbackChain []string | |||
| 1383 | + | func (_e *MockFontDetector_Expecter) SelectBestFont(ctx interface{}, category interface{}, fallbackChain interface{}) *MockFontDetector_SelectBestFont_Call { | |||
| 1384 | + | return &MockFontDetector_SelectBestFont_Call{Call: _e.mock.On("SelectBestFont", ctx, category, fallbackChain)} | |||
| 1385 | + | } | |||
| 1386 | + | ||||
| 1387 | + | func (_c *MockFontDetector_SelectBestFont_Call) Run(run func(ctx context.Context, category port.FontCategory, fallbackChain []string)) *MockFontDetector_SelectBestFont_Call { | |||
| 1388 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1389 | + | var arg0 context.Context | |||
| 1390 | + | if args[0] != nil { | |||
| 1391 | + | arg0 = args[0].(context.Context) | |||
| 1392 | + | } | |||
| 1393 | + | var arg1 port.FontCategory | |||
| 1394 | + | if args[1] != nil { | |||
| 1395 | + | arg1 = args[1].(port.FontCategory) | |||
| 1396 | + | } | |||
| 1397 | + | var arg2 []string | |||
| 1398 | + | if args[2] != nil { | |||
| 1399 | + | arg2 = args[2].([]string) | |||
| 1400 | + | } | |||
| 1401 | + | run( | |||
| 1402 | + | arg0, | |||
| 1403 | + | arg1, | |||
| 1404 | + | arg2, | |||
| 1405 | + | ) | |||
| 1406 | + | }) | |||
| 1407 | + | return _c | |||
| 1408 | + | } | |||
| 1409 | + | ||||
| 1410 | + | func (_c *MockFontDetector_SelectBestFont_Call) Return(s string) *MockFontDetector_SelectBestFont_Call { | |||
| 1411 | + | _c.Call.Return(s) | |||
| 1412 | + | return _c | |||
| 1413 | + | } | |||
| 1414 | + | ||||
| 1415 | + | func (_c *MockFontDetector_SelectBestFont_Call) RunAndReturn(run func(ctx context.Context, category port.FontCategory, fallbackChain []string) string) *MockFontDetector_SelectBestFont_Call { | |||
| 1416 | + | _c.Call.Return(run) | |||
| 1417 | + | return _c | |||
| 1418 | + | } | |||
| 1419 | + | ||||
| 1217 | // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1420 | // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1218 | // The first argument is typically a *testing.T value. | 1421 | // The first argument is typically a *testing.T value. | ||
| 1219 | func NewMockHardwareSurveyor(t interface { | 1422 | func NewMockHardwareSurveyor(t interface { | ||
M internal/application/port/rendering_env.go +0 -4
| @@ -27,10 +27,6 @@ type RenderingEnvSettings struct { | |||
| 27 | 27 | GLRenderingMode string | |
| 28 | 28 | // GStreamerDebugLevel sets GStreamer debug verbosity (0-5). | |
| 29 | 29 | GStreamerDebugLevel int | |
| 30 | - | // VideoBufferSizeMB sets buffer size in megabytes for video streaming. | |
| 31 | - | VideoBufferSizeMB int | |
| 32 | - | // QueueBufferTimeSec sets queue buffer time in seconds for prebuffering. | |
| 33 | - | QueueBufferTimeSec int | |
| 34 | 30 | ||
| 35 | 31 | // --- WebKit compositor settings (from RenderingConfig) --- | |
| 36 | 32 | DisableDMABufRenderer bool | |
| @@ -27,10 +27,6 @@ type RenderingEnvSettings struct { | |||||
| 27 | GLRenderingMode string | 27 | GLRenderingMode string | ||
| 28 | // GStreamerDebugLevel sets GStreamer debug verbosity (0-5). | 28 | // GStreamerDebugLevel sets GStreamer debug verbosity (0-5). | ||
| 29 | GStreamerDebugLevel int | 29 | GStreamerDebugLevel int | ||
| 30 | - | // VideoBufferSizeMB sets buffer size in megabytes for video streaming. | |||
| 31 | - | VideoBufferSizeMB int | |||
| 32 | - | // QueueBufferTimeSec sets queue buffer time in seconds for prebuffering. | |||
| 33 | - | QueueBufferTimeSec int | |||
| 34 | 30 | ||||
| 35 | // --- WebKit compositor settings (from RenderingConfig) --- | 31 | // --- WebKit compositor settings (from RenderingConfig) --- | ||
| 36 | DisableDMABufRenderer bool | 32 | DisableDMABufRenderer bool | ||
M internal/application/usecase/manage_panes.go +126 -75
| @@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 112 | 112 | return nil, fmt.Errorf("target pane is required") | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | - | // If target is inside a stack and splitting left/right, split around the entire stack | |
| 115 | + | // If target is inside a stack, split around the entire stack | |
| 116 | 116 | // (i.e., create the new pane as a sibling to the stack, not inside it) | |
| 117 | + | // This applies to all directions - we don't want to nest split containers inside stacks. | |
| 117 | 118 | targetNode := input.TargetPane | |
| 118 | - | if input.Direction == SplitLeft || input.Direction == SplitRight { | |
| 119 | - | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 120 | - | // Promote to the stack container so split happens outside the stack | |
| 121 | - | targetNode = targetNode.Parent | |
| 122 | - | log.Debug().Msg("target is inside a stack, splitting around stack container") | |
| 123 | - | } | |
| 119 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 120 | + | // Promote to the stack container so split happens outside the stack | |
| 121 | + | targetNode = targetNode.Parent | |
| 122 | + | log.Debug().Msg("target is inside a stack, splitting around stack container") | |
| 124 | 123 | } | |
| 125 | 124 | ||
| 126 | 125 | // Create new pane | |
| @@ -541,31 +540,13 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p | |||
| 541 | 540 | } | |
| 542 | 541 | ||
| 543 | 542 | // Focus sets the active pane in the workspace. | |
| 543 | + | // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane. | |
| 544 | 544 | func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error { | |
| 545 | 545 | log := logging.FromContext(ctx) | |
| 546 | - | if uc == nil { | |
| 547 | - | return fmt.Errorf("manage panes use case is nil") | |
| 548 | - | } | |
| 549 | 546 | log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane") | |
| 550 | 547 | ||
| 551 | - | if ws == nil { | |
| 552 | - | return fmt.Errorf("workspace is required") | |
| 553 | - | } | |
| 554 | - | ||
| 555 | - | node := ws.FindPane(paneID) | |
| 556 | - | if node == nil { | |
| 557 | - | return fmt.Errorf("pane not found: %s", paneID) | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | oldActive := ws.ActivePaneID | |
| 561 | - | ws.ActivePaneID = paneID | |
| 562 | - | ||
| 563 | - | log.Info(). | |
| 564 | - | Str("from", string(oldActive)). | |
| 565 | - | Str("to", string(paneID)). | |
| 566 | - | Msg("focus changed") | |
| 567 | - | ||
| 568 | - | return nil | |
| 548 | + | _, err := uc.ApplyFocusChange(ctx, ws, paneID) | |
| 549 | + | return err | |
| 569 | 550 | } | |
| 570 | 551 | ||
| 571 | 552 | // NavigateFocus moves focus to an adjacent pane. | |
| @@ -619,12 +600,61 @@ type GeometricNavigationOutput struct { | |||
| 619 | 600 | Found bool | |
| 620 | 601 | } | |
| 621 | 602 | ||
| 603 | + | // ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack. | |
| 604 | + | // This is the canonical way to change focus to a pane, ensuring stack state is kept in sync. | |
| 605 | + | func (uc *ManagePanesUseCase) ApplyFocusChange( | |
| 606 | + | ctx context.Context, | |
| 607 | + | ws *entity.Workspace, | |
| 608 | + | targetPaneID entity.PaneID, | |
| 609 | + | ) (*entity.PaneNode, error) { | |
| 610 | + | log := logging.FromContext(ctx) | |
| 611 | + | if uc == nil { | |
| 612 | + | return nil, fmt.Errorf("manage panes use case is nil") | |
| 613 | + | } | |
| 614 | + | if ws == nil { | |
| 615 | + | return nil, fmt.Errorf("workspace is required") | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | targetNode := ws.FindPane(targetPaneID) | |
| 619 | + | if targetNode == nil { | |
| 620 | + | return nil, fmt.Errorf("pane not found: %s", targetPaneID) | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | oldActive := ws.ActivePaneID | |
| 624 | + | ws.ActivePaneID = targetPaneID | |
| 625 | + | ||
| 626 | + | // Update stack index if target is in a stack | |
| 627 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 628 | + | oldIndex := targetNode.Parent.ActiveStackIndex | |
| 629 | + | for i, child := range targetNode.Parent.Children { | |
| 630 | + | if child == targetNode { | |
| 631 | + | targetNode.Parent.ActiveStackIndex = i | |
| 632 | + | log.Debug(). | |
| 633 | + | Str("target_pane_id", string(targetPaneID)). | |
| 634 | + | Int("old_stack_index", oldIndex). | |
| 635 | + | Int("new_stack_index", i). | |
| 636 | + | Int("num_children", len(targetNode.Parent.Children)). | |
| 637 | + | Msg("updated stack index for focus change") | |
| 638 | + | break | |
| 639 | + | } | |
| 640 | + | } | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | log.Info(). | |
| 644 | + | Str("from", string(oldActive)). | |
| 645 | + | Str("to", string(targetPaneID)). | |
| 646 | + | Msg("focus changed") | |
| 647 | + | ||
| 648 | + | return targetNode, nil | |
| 649 | + | } | |
| 650 | + | ||
| 622 | 651 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | |
| 623 | 652 | // Algorithm: | |
| 624 | - | // 1. Get center of active pane | |
| 653 | + | // 1. Get active pane rectangle | |
| 625 | 654 | // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.) | |
| 626 | - | // 3. Score by: primary_distance * 1000 + perpendicular_distance | |
| 627 | - | // 4. Return lowest scoring candidate | |
| 655 | + | // 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down) | |
| 656 | + | // 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance | |
| 657 | + | // 5. Return lowest scoring candidate | |
| 628 | 658 | func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |
| 629 | 659 | ctx context.Context, | |
| 630 | 660 | input GeometricNavigationInput, | |
| @@ -652,50 +682,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||
| 652 | 682 | return &GeometricNavigationOutput{Found: false}, nil | |
| 653 | 683 | } | |
| 654 | 684 | ||
| 655 | - | acx, acy := activeRect.Center() | |
| 656 | - | ||
| 657 | - | type candidate struct { | |
| 658 | - | paneID entity.PaneID | |
| 659 | - | score int | |
| 660 | - | } | |
| 661 | - | var candidates []candidate | |
| 662 | - | ||
| 663 | - | for _, rect := range input.PaneRects { | |
| 664 | - | if rect.PaneID == input.ActivePaneID { | |
| 665 | - | continue | |
| 666 | - | } | |
| 667 | - | ||
| 668 | - | cx, cy := rect.Center() | |
| 669 | - | dx := cx - acx | |
| 670 | - | dy := cy - acy | |
| 671 | - | ||
| 672 | - | var inDirection bool | |
| 673 | - | var primaryDist, perpDist int | |
| 674 | - | ||
| 675 | - | switch input.Direction { | |
| 676 | - | case NavLeft: | |
| 677 | - | inDirection = dx < 0 | |
| 678 | - | primaryDist = abs(dx) | |
| 679 | - | perpDist = abs(dy) | |
| 680 | - | case NavRight: | |
| 681 | - | inDirection = dx > 0 | |
| 682 | - | primaryDist = abs(dx) | |
| 683 | - | perpDist = abs(dy) | |
| 684 | - | case NavUp: | |
| 685 | - | inDirection = dy < 0 | |
| 686 | - | primaryDist = abs(dy) | |
| 687 | - | perpDist = abs(dx) | |
| 688 | - | case NavDown: | |
| 689 | - | inDirection = dy > 0 | |
| 690 | - | primaryDist = abs(dy) | |
| 691 | - | perpDist = abs(dx) | |
| 692 | - | } | |
| 693 | - | ||
| 694 | - | if inDirection { | |
| 695 | - | score := primaryDist*1000 + perpDist | |
| 696 | - | candidates = append(candidates, candidate{rect.PaneID, score}) | |
| 697 | - | } | |
| 698 | - | } | |
| 685 | + | candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction) | |
| 699 | 686 | ||
| 700 | 687 | if len(candidates) == 0 { | |
| 701 | 688 | log.Debug().Msg("no candidates in direction") | |
| @@ -718,6 +705,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||
| 718 | 705 | }, nil | |
| 719 | 706 | } | |
| 720 | 707 | ||
| 708 | + | // navCandidate represents a pane candidate for navigation with its score. | |
| 709 | + | type navCandidate struct { | |
| 710 | + | paneID entity.PaneID | |
| 711 | + | score int | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | // scoreNavigationCandidates scores all panes in the given direction from activeRect. | |
| 715 | + | // Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down). | |
| 716 | + | func scoreNavigationCandidates( | |
| 717 | + | activeRect entity.PaneRect, | |
| 718 | + | paneRects []entity.PaneRect, | |
| 719 | + | direction NavigateDirection, | |
| 720 | + | ) []navCandidate { | |
| 721 | + | // Large penalty for panes without perpendicular overlap. | |
| 722 | + | // This ensures panes at the same level are always preferred. | |
| 723 | + | const noOverlapPenalty = 10_000_000 | |
| 724 | + | ||
| 725 | + | acx, acy := activeRect.Center() | |
| 726 | + | var candidates []navCandidate | |
| 727 | + | ||
| 728 | + | for _, rect := range paneRects { | |
| 729 | + | if rect.PaneID == activeRect.PaneID { | |
| 730 | + | continue | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | cx, cy := rect.Center() | |
| 734 | + | dx := cx - acx | |
| 735 | + | dy := cy - acy | |
| 736 | + | ||
| 737 | + | inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction) | |
| 738 | + | ||
| 739 | + | if inDirection { | |
| 740 | + | score := primaryDist*1000 + perpDist | |
| 741 | + | if !hasOverlap { | |
| 742 | + | score += noOverlapPenalty | |
| 743 | + | } | |
| 744 | + | candidates = append(candidates, navCandidate{rect.PaneID, score}) | |
| 745 | + | } | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | return candidates | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | // evalDirection determines if a candidate rect is in the given direction from activeRect. | |
| 752 | + | // Returns: inDirection, primaryDist, perpDist, hasOverlap | |
| 753 | + | func evalDirection( | |
| 754 | + | activeRect, rect entity.PaneRect, | |
| 755 | + | dx, dy int, | |
| 756 | + | direction NavigateDirection, | |
| 757 | + | ) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) { | |
| 758 | + | switch direction { | |
| 759 | + | case NavLeft: | |
| 760 | + | return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect) | |
| 761 | + | case NavRight: | |
| 762 | + | return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect) | |
| 763 | + | case NavUp: | |
| 764 | + | return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect) | |
| 765 | + | case NavDown: | |
| 766 | + | return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect) | |
| 767 | + | default: | |
| 768 | + | return false, 0, 0, false | |
| 769 | + | } | |
| 770 | + | } | |
| 771 | + | ||
| 721 | 772 | // abs returns the absolute value of an integer. | |
| 722 | 773 | func abs(x int) int { | |
| 723 | 774 | if x < 0 { | |
| @@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 112 | return nil, fmt.Errorf("target pane is required") | 112 | return nil, fmt.Errorf("target pane is required") | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | - | // If target is inside a stack and splitting left/right, split around the entire stack | 115 | + | // If target is inside a stack, split around the entire stack |
| 116 | // (i.e., create the new pane as a sibling to the stack, not inside it) | 116 | // (i.e., create the new pane as a sibling to the stack, not inside it) | ||
| 117 | + | // This applies to all directions - we don't want to nest split containers inside stacks. | |||
| 117 | targetNode := input.TargetPane | 118 | targetNode := input.TargetPane | ||
| 118 | - | if input.Direction == SplitLeft || input.Direction == SplitRight { | 119 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { |
| 119 | - | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | 120 | + | // Promote to the stack container so split happens outside the stack |
| 120 | - | // Promote to the stack container so split happens outside the stack | 121 | + | targetNode = targetNode.Parent |
| 121 | - | targetNode = targetNode.Parent | 122 | + | log.Debug().Msg("target is inside a stack, splitting around stack container") |
| 122 | - | log.Debug().Msg("target is inside a stack, splitting around stack container") | |||
| 123 | - | } | |||
| 124 | } | 123 | } | ||
| 125 | 124 | ||||
| 126 | // Create new pane | 125 | // Create new pane | ||
| @@ -541,31 +540,13 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p | |||||
| 541 | } | 540 | } | ||
| 542 | 541 | ||||
| 543 | // Focus sets the active pane in the workspace. | 542 | // Focus sets the active pane in the workspace. | ||
| 543 | + | // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane. | |||
| 544 | func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error { | 544 | func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error { | ||
| 545 | log := logging.FromContext(ctx) | 545 | log := logging.FromContext(ctx) | ||
| 546 | - | if uc == nil { | |||
| 547 | - | return fmt.Errorf("manage panes use case is nil") | |||
| 548 | - | } | |||
| 549 | log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane") | 546 | log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane") | ||
| 550 | 547 | ||||
| 551 | - | if ws == nil { | 548 | + | _, err := uc.ApplyFocusChange(ctx, ws, paneID) |
| 552 | - | return fmt.Errorf("workspace is required") | 549 | + | return err |
| 553 | - | } | |||
| 554 | - | ||||
| 555 | - | node := ws.FindPane(paneID) | |||
| 556 | - | if node == nil { | |||
| 557 | - | return fmt.Errorf("pane not found: %s", paneID) | |||
| 558 | - | } | |||
| 559 | - | ||||
| 560 | - | oldActive := ws.ActivePaneID | |||
| 561 | - | ws.ActivePaneID = paneID | |||
| 562 | - | ||||
| 563 | - | log.Info(). | |||
| 564 | - | Str("from", string(oldActive)). | |||
| 565 | - | Str("to", string(paneID)). | |||
| 566 | - | Msg("focus changed") | |||
| 567 | - | ||||
| 568 | - | return nil | |||
| 569 | } | 550 | } | ||
| 570 | 551 | ||||
| 571 | // NavigateFocus moves focus to an adjacent pane. | 552 | // NavigateFocus moves focus to an adjacent pane. | ||
| @@ -619,12 +600,61 @@ type GeometricNavigationOutput struct { | |||||
| 619 | Found bool | 600 | Found bool | ||
| 620 | } | 601 | } | ||
| 621 | 602 | ||||
| 603 | + | // ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack. | |||
| 604 | + | // This is the canonical way to change focus to a pane, ensuring stack state is kept in sync. | |||
| 605 | + | func (uc *ManagePanesUseCase) ApplyFocusChange( | |||
| 606 | + | ctx context.Context, | |||
| 607 | + | ws *entity.Workspace, | |||
| 608 | + | targetPaneID entity.PaneID, | |||
| 609 | + | ) (*entity.PaneNode, error) { | |||
| 610 | + | log := logging.FromContext(ctx) | |||
| 611 | + | if uc == nil { | |||
| 612 | + | return nil, fmt.Errorf("manage panes use case is nil") | |||
| 613 | + | } | |||
| 614 | + | if ws == nil { | |||
| 615 | + | return nil, fmt.Errorf("workspace is required") | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | targetNode := ws.FindPane(targetPaneID) | |||
| 619 | + | if targetNode == nil { | |||
| 620 | + | return nil, fmt.Errorf("pane not found: %s", targetPaneID) | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | oldActive := ws.ActivePaneID | |||
| 624 | + | ws.ActivePaneID = targetPaneID | |||
| 625 | + | ||||
| 626 | + | // Update stack index if target is in a stack | |||
| 627 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |||
| 628 | + | oldIndex := targetNode.Parent.ActiveStackIndex | |||
| 629 | + | for i, child := range targetNode.Parent.Children { | |||
| 630 | + | if child == targetNode { | |||
| 631 | + | targetNode.Parent.ActiveStackIndex = i | |||
| 632 | + | log.Debug(). | |||
| 633 | + | Str("target_pane_id", string(targetPaneID)). | |||
| 634 | + | Int("old_stack_index", oldIndex). | |||
| 635 | + | Int("new_stack_index", i). | |||
| 636 | + | Int("num_children", len(targetNode.Parent.Children)). | |||
| 637 | + | Msg("updated stack index for focus change") | |||
| 638 | + | break | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | log.Info(). | |||
| 644 | + | Str("from", string(oldActive)). | |||
| 645 | + | Str("to", string(targetPaneID)). | |||
| 646 | + | Msg("focus changed") | |||
| 647 | + | ||||
| 648 | + | return targetNode, nil | |||
| 649 | + | } | |||
| 650 | + | ||||
| 622 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | 651 | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | ||
| 623 | // Algorithm: | 652 | // Algorithm: | ||
| 624 | - | // 1. Get center of active pane | 653 | + | // 1. Get active pane rectangle |
| 625 | // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.) | 654 | // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.) | ||
| 626 | - | // 3. Score by: primary_distance * 1000 + perpendicular_distance | 655 | + | // 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down) |
| 627 | - | // 4. Return lowest scoring candidate | 656 | + | // 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance |
| 657 | + | // 5. Return lowest scoring candidate | |||
| 628 | func (uc *ManagePanesUseCase) NavigateFocusGeometric( | 658 | func (uc *ManagePanesUseCase) NavigateFocusGeometric( | ||
| 629 | ctx context.Context, | 659 | ctx context.Context, | ||
| 630 | input GeometricNavigationInput, | 660 | input GeometricNavigationInput, | ||
| @@ -652,50 +682,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||||
| 652 | return &GeometricNavigationOutput{Found: false}, nil | 682 | return &GeometricNavigationOutput{Found: false}, nil | ||
| 653 | } | 683 | } | ||
| 654 | 684 | ||||
| 655 | - | acx, acy := activeRect.Center() | 685 | + | candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction) |
| 656 | - | ||||
| 657 | - | type candidate struct { | |||
| 658 | - | paneID entity.PaneID | |||
| 659 | - | score int | |||
| 660 | - | } | |||
| 661 | - | var candidates []candidate | |||
| 662 | - | ||||
| 663 | - | for _, rect := range input.PaneRects { | |||
| 664 | - | if rect.PaneID == input.ActivePaneID { | |||
| 665 | - | continue | |||
| 666 | - | } | |||
| 667 | - | ||||
| 668 | - | cx, cy := rect.Center() | |||
| 669 | - | dx := cx - acx | |||
| 670 | - | dy := cy - acy | |||
| 671 | - | ||||
| 672 | - | var inDirection bool | |||
| 673 | - | var primaryDist, perpDist int | |||
| 674 | - | ||||
| 675 | - | switch input.Direction { | |||
| 676 | - | case NavLeft: | |||
| 677 | - | inDirection = dx < 0 | |||
| 678 | - | primaryDist = abs(dx) | |||
| 679 | - | perpDist = abs(dy) | |||
| 680 | - | case NavRight: | |||
| 681 | - | inDirection = dx > 0 | |||
| 682 | - | primaryDist = abs(dx) | |||
| 683 | - | perpDist = abs(dy) | |||
| 684 | - | case NavUp: | |||
| 685 | - | inDirection = dy < 0 | |||
| 686 | - | primaryDist = abs(dy) | |||
| 687 | - | perpDist = abs(dx) | |||
| 688 | - | case NavDown: | |||
| 689 | - | inDirection = dy > 0 | |||
| 690 | - | primaryDist = abs(dy) | |||
| 691 | - | perpDist = abs(dx) | |||
| 692 | - | } | |||
| 693 | - | ||||
| 694 | - | if inDirection { | |||
| 695 | - | score := primaryDist*1000 + perpDist | |||
| 696 | - | candidates = append(candidates, candidate{rect.PaneID, score}) | |||
| 697 | - | } | |||
| 698 | - | } | |||
| 699 | 686 | ||||
| 700 | if len(candidates) == 0 { | 687 | if len(candidates) == 0 { | ||
| 701 | log.Debug().Msg("no candidates in direction") | 688 | log.Debug().Msg("no candidates in direction") | ||
| @@ -718,6 +705,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||||
| 718 | }, nil | 705 | }, nil | ||
| 719 | } | 706 | } | ||
| 720 | 707 | ||||
| 708 | + | // navCandidate represents a pane candidate for navigation with its score. | |||
| 709 | + | type navCandidate struct { | |||
| 710 | + | paneID entity.PaneID | |||
| 711 | + | score int | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | // scoreNavigationCandidates scores all panes in the given direction from activeRect. | |||
| 715 | + | // Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down). | |||
| 716 | + | func scoreNavigationCandidates( | |||
| 717 | + | activeRect entity.PaneRect, | |||
| 718 | + | paneRects []entity.PaneRect, | |||
| 719 | + | direction NavigateDirection, | |||
| 720 | + | ) []navCandidate { | |||
| 721 | + | // Large penalty for panes without perpendicular overlap. | |||
| 722 | + | // This ensures panes at the same level are always preferred. | |||
| 723 | + | const noOverlapPenalty = 10_000_000 | |||
| 724 | + | ||||
| 725 | + | acx, acy := activeRect.Center() | |||
| 726 | + | var candidates []navCandidate | |||
| 727 | + | ||||
| 728 | + | for _, rect := range paneRects { | |||
| 729 | + | if rect.PaneID == activeRect.PaneID { | |||
| 730 | + | continue | |||
| 731 | + | } | |||
| 732 | + | ||||
| 733 | + | cx, cy := rect.Center() | |||
| 734 | + | dx := cx - acx | |||
| 735 | + | dy := cy - acy | |||
| 736 | + | ||||
| 737 | + | inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction) | |||
| 738 | + | ||||
| 739 | + | if inDirection { | |||
| 740 | + | score := primaryDist*1000 + perpDist | |||
| 741 | + | if !hasOverlap { | |||
| 742 | + | score += noOverlapPenalty | |||
| 743 | + | } | |||
| 744 | + | candidates = append(candidates, navCandidate{rect.PaneID, score}) | |||
| 745 | + | } | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | return candidates | |||
| 749 | + | } | |||
| 750 | + | ||||
| 751 | + | // evalDirection determines if a candidate rect is in the given direction from activeRect. | |||
| 752 | + | // Returns: inDirection, primaryDist, perpDist, hasOverlap | |||
| 753 | + | func evalDirection( | |||
| 754 | + | activeRect, rect entity.PaneRect, | |||
| 755 | + | dx, dy int, | |||
| 756 | + | direction NavigateDirection, | |||
| 757 | + | ) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) { | |||
| 758 | + | switch direction { | |||
| 759 | + | case NavLeft: | |||
| 760 | + | return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect) | |||
| 761 | + | case NavRight: | |||
| 762 | + | return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect) | |||
| 763 | + | case NavUp: | |||
| 764 | + | return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect) | |||
| 765 | + | case NavDown: | |||
| 766 | + | return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect) | |||
| 767 | + | default: | |||
| 768 | + | return false, 0, 0, false | |||
| 769 | + | } | |||
| 770 | + | } | |||
| 771 | + | ||||
| 721 | // abs returns the absolute value of an integer. | 772 | // abs returns the absolute value of an integer. | ||
| 722 | func abs(x int) int { | 773 | func abs(x int) int { | ||
| 723 | if x < 0 { | 774 | if x < 0 { | ||
M internal/bootstrap/webkit_stack.go +5 -2
| @@ -120,6 +120,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||
| 120 | 120 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 121 | 121 | injector := webkit.NewContentInjector(colorResolver) | |
| 122 | 122 | ||
| 123 | + | // Set up auto-copy on selection config getter | |
| 124 | + | injector.SetAutoCopyConfigGetter(func() bool { | |
| 125 | + | return config.Get().Clipboard.AutoCopyOnSelection | |
| 126 | + | }) | |
| 127 | + | ||
| 123 | 128 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | |
| 124 | 129 | themeCSSText := themeManager.GetWebUIThemeCSS() | |
| 125 | 130 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | |
| @@ -189,8 +194,6 @@ func configureRenderingEnvironment( | |||
| 189 | 194 | ForceVSync: cfg.Media.ForceVSync, | |
| 190 | 195 | GLRenderingMode: string(cfg.Media.GLRenderingMode), | |
| 191 | 196 | GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel, | |
| 192 | - | VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB, | |
| 193 | - | QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec, | |
| 194 | 197 | ||
| 195 | 198 | // WebKit compositor settings | |
| 196 | 199 | DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer, | |
| @@ -120,6 +120,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 120 | settings := webkit.NewSettingsManager(ctx, cfg) | 120 | settings := webkit.NewSettingsManager(ctx, cfg) | ||
| 121 | injector := webkit.NewContentInjector(colorResolver) | 121 | injector := webkit.NewContentInjector(colorResolver) | ||
| 122 | 122 | ||||
| 123 | + | // Set up auto-copy on selection config getter | |||
| 124 | + | injector.SetAutoCopyConfigGetter(func() bool { | |||
| 125 | + | return config.Get().Clipboard.AutoCopyOnSelection | |||
| 126 | + | }) | |||
| 127 | + | ||||
| 123 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | 128 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | ||
| 124 | themeCSSText := themeManager.GetWebUIThemeCSS() | 129 | themeCSSText := themeManager.GetWebUIThemeCSS() | ||
| 125 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | 130 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | ||
| @@ -189,8 +194,6 @@ func configureRenderingEnvironment( | |||||
| 189 | ForceVSync: cfg.Media.ForceVSync, | 194 | ForceVSync: cfg.Media.ForceVSync, | ||
| 190 | GLRenderingMode: string(cfg.Media.GLRenderingMode), | 195 | GLRenderingMode: string(cfg.Media.GLRenderingMode), | ||
| 191 | GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel, | 196 | GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel, | ||
| 192 | - | VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB, | |||
| 193 | - | QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec, | |||
| 194 | 197 | ||||
| 195 | // WebKit compositor settings | 198 | // WebKit compositor settings | ||
| 196 | DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer, | 199 | DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer, | ||
M internal/domain/entity/geometry.go +14 -0
| @@ -13,3 +13,17 @@ type PaneRect struct { | |||
| 13 | 13 | func (r PaneRect) Center() (cx, cy int) { | |
| 14 | 14 | return r.X + r.W/2, r.Y + r.H/2 | |
| 15 | 15 | } | |
| 16 | + | ||
| 17 | + | // OverlapsVertically returns true if the two rectangles overlap in the Y axis. | |
| 18 | + | // Used for left/right navigation to prefer panes at the same vertical level. | |
| 19 | + | func (r PaneRect) OverlapsVertically(other PaneRect) bool { | |
| 20 | + | // r spans [r.Y, r.Y+r.H), other spans [other.Y, other.Y+other.H) | |
| 21 | + | return r.Y < other.Y+other.H && other.Y < r.Y+r.H | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // OverlapsHorizontally returns true if the two rectangles overlap in the X axis. | |
| 25 | + | // Used for up/down navigation to prefer panes at the same horizontal level. | |
| 26 | + | func (r PaneRect) OverlapsHorizontally(other PaneRect) bool { | |
| 27 | + | // r spans [r.X, r.X+r.W), other spans [other.X, other.X+other.W) | |
| 28 | + | return r.X < other.X+other.W && other.X < r.X+r.W | |
| 29 | + | } | |
| @@ -13,3 +13,17 @@ type PaneRect struct { | |||||
| 13 | func (r PaneRect) Center() (cx, cy int) { | 13 | func (r PaneRect) Center() (cx, cy int) { | ||
| 14 | return r.X + r.W/2, r.Y + r.H/2 | 14 | return r.X + r.W/2, r.Y + r.H/2 | ||
| 15 | } | 15 | } | ||
| 16 | + | ||||
| 17 | + | // OverlapsVertically returns true if the two rectangles overlap in the Y axis. | |||
| 18 | + | // Used for left/right navigation to prefer panes at the same vertical level. | |||
| 19 | + | func (r PaneRect) OverlapsVertically(other PaneRect) bool { | |||
| 20 | + | // r spans [r.Y, r.Y+r.H), other spans [other.Y, other.Y+other.H) | |||
| 21 | + | return r.Y < other.Y+other.H && other.Y < r.Y+r.H | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // OverlapsHorizontally returns true if the two rectangles overlap in the X axis. | |||
| 25 | + | // Used for up/down navigation to prefer panes at the same horizontal level. | |||
| 26 | + | func (r PaneRect) OverlapsHorizontally(other PaneRect) bool { | |||
| 27 | + | // r spans [r.X, r.X+r.W), other spans [other.X, other.X+other.W) | |||
| 28 | + | return r.X < other.X+other.W && other.X < r.X+r.W | |||
| 29 | + | } | |||
M internal/infrastructure/config/defaults.go +3 -2
| @@ -253,6 +253,9 @@ func DefaultConfig() *Config { | |||
| 253 | 253 | Enabled: true, // Ad blocking enabled by default | |
| 254 | 254 | AutoUpdate: true, // Auto-update filters from GitHub releases | |
| 255 | 255 | }, | |
| 256 | + | Clipboard: ClipboardConfig{ | |
| 257 | + | AutoCopyOnSelection: true, // Enabled by default (zellij-style) | |
| 258 | + | }, | |
| 256 | 259 | Omnibox: OmniboxConfig{ | |
| 257 | 260 | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 258 | 261 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | |
| @@ -279,8 +282,6 @@ func DefaultConfig() *Config { | |||
| 279 | 282 | ForceVSync: false, // Let compositor handle VSync | |
| 280 | 283 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | |
| 281 | 284 | GStreamerDebugLevel: 0, // Disabled by default | |
| 282 | - | VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed | |
| 283 | - | QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed | |
| 284 | 285 | }, | |
| 285 | 286 | Runtime: RuntimeConfig{ | |
| 286 | 287 | Prefix: "", | |
| @@ -253,6 +253,9 @@ func DefaultConfig() *Config { | |||||
| 253 | Enabled: true, // Ad blocking enabled by default | 253 | Enabled: true, // Ad blocking enabled by default | ||
| 254 | AutoUpdate: true, // Auto-update filters from GitHub releases | 254 | AutoUpdate: true, // Auto-update filters from GitHub releases | ||
| 255 | }, | 255 | }, | ||
| 256 | + | Clipboard: ClipboardConfig{ | |||
| 257 | + | AutoCopyOnSelection: true, // Enabled by default (zellij-style) | |||
| 258 | + | }, | |||
| 256 | Omnibox: OmniboxConfig{ | 259 | Omnibox: OmniboxConfig{ | ||
| 257 | InitialBehavior: defaultOmniboxInitialBehavior, | 260 | InitialBehavior: defaultOmniboxInitialBehavior, | ||
| 258 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | 261 | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | ||
| @@ -279,8 +282,6 @@ func DefaultConfig() *Config { | |||||
| 279 | ForceVSync: false, // Let compositor handle VSync | 282 | ForceVSync: false, // Let compositor handle VSync | ||
| 280 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | 283 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | ||
| 281 | GStreamerDebugLevel: 0, // Disabled by default | 284 | GStreamerDebugLevel: 0, // Disabled by default | ||
| 282 | - | VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed | |||
| 283 | - | QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed | |||
| 284 | }, | 285 | }, | ||
| 285 | Runtime: RuntimeConfig{ | 286 | Runtime: RuntimeConfig{ | ||
| 286 | Prefix: "", | 287 | Prefix: "", | ||
M internal/infrastructure/config/loader.go +35 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| @@ -8,6 +9,9 @@ import ( | |||
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/fonts" | |
| 14 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | 15 | "github.com/spf13/viper" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| @@ -302,6 +306,9 @@ func (m *Manager) createDefaultConfig() error { | |||
| 302 | 306 | return err | |
| 303 | 307 | } | |
| 304 | 308 | ||
| 309 | + | // Detect available fonts and override defaults before writing config. | |
| 310 | + | m.detectAndSetFonts() | |
| 311 | + | ||
| 305 | 312 | // Ensure TOML format and write config | |
| 306 | 313 | m.viper.SetConfigType("toml") | |
| 307 | 314 | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | |
| @@ -313,6 +320,29 @@ func (m *Manager) createDefaultConfig() error { | |||
| 313 | 320 | return nil | |
| 314 | 321 | } | |
| 315 | 322 | ||
| 323 | + | // detectAndSetFonts detects available system fonts and sets the best available | |
| 324 | + | // fonts from the fallback chains. This runs only during first-run config creation. | |
| 325 | + | func (m *Manager) detectAndSetFonts() { | |
| 326 | + | // Create context with logger for debugging first-run font detection. | |
| 327 | + | logger := logging.NewFromEnv() | |
| 328 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 329 | + | ||
| 330 | + | detector := fonts.NewDetector() | |
| 331 | + | ||
| 332 | + | if !detector.IsAvailable(ctx) { | |
| 333 | + | // fc-list not available, keep hardcoded defaults | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |
| 338 | + | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |
| 339 | + | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |
| 340 | + | ||
| 341 | + | m.viper.Set("appearance.sans_font", sansFont) | |
| 342 | + | m.viper.Set("appearance.serif_font", serifFont) | |
| 343 | + | m.viper.Set("appearance.monospace_font", monoFont) | |
| 344 | + | } | |
| 345 | + | ||
| 316 | 346 | // setDefaults sets default configuration values in Viper. | |
| 317 | 347 | func (m *Manager) setDefaults() { | |
| 318 | 348 | defaults := DefaultConfig() | |
| @@ -328,6 +358,7 @@ func (m *Manager) setDefaults() { | |||
| 328 | 358 | m.setRenderingDefaults(defaults) | |
| 329 | 359 | m.setWorkspaceDefaults(defaults) | |
| 330 | 360 | m.setContentFilteringDefaults(defaults) | |
| 361 | + | m.setClipboardDefaults(defaults) | |
| 331 | 362 | m.setOmniboxDefaults(defaults) | |
| 332 | 363 | m.setMediaDefaults(defaults) | |
| 333 | 364 | m.setRuntimeDefaults(defaults) | |
| @@ -443,6 +474,10 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) { | |||
| 443 | 474 | m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate) | |
| 444 | 475 | } | |
| 445 | 476 | ||
| 477 | + | func (m *Manager) setClipboardDefaults(defaults *Config) { | |
| 478 | + | m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection) | |
| 479 | + | } | |
| 480 | + | ||
| 446 | 481 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | |
| 447 | 482 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | |
| 448 | 483 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | |
| @@ -455,8 +490,6 @@ func (m *Manager) setMediaDefaults(defaults *Config) { | |||
| 455 | 490 | m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync) | |
| 456 | 491 | m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode)) | |
| 457 | 492 | m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel) | |
| 458 | - | m.viper.SetDefault("media.video_buffer_size_mb", defaults.Media.VideoBufferSizeMB) | |
| 459 | - | m.viper.SetDefault("media.queue_buffer_time_sec", defaults.Media.QueueBufferTimeSec) | |
| 460 | 493 | } | |
| 461 | 494 | ||
| 462 | 495 | func (m *Manager) setRuntimeDefaults(defaults *Config) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "os" | 7 | "os" | ||
| @@ -8,6 +9,9 @@ import ( | |||||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "sync" | 10 | "sync" | ||
| 10 | 11 | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/fonts" | |||
| 14 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | "github.com/spf13/viper" | 15 | "github.com/spf13/viper" | ||
| 12 | ) | 16 | ) | ||
| 13 | 17 | ||||
| @@ -302,6 +306,9 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 302 | return err | 306 | return err | ||
| 303 | } | 307 | } | ||
| 304 | 308 | ||||
| 309 | + | // Detect available fonts and override defaults before writing config. | |||
| 310 | + | m.detectAndSetFonts() | |||
| 311 | + | ||||
| 305 | // Ensure TOML format and write config | 312 | // Ensure TOML format and write config | ||
| 306 | m.viper.SetConfigType("toml") | 313 | m.viper.SetConfigType("toml") | ||
| 307 | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | 314 | if err := m.viper.SafeWriteConfigAs(configFile); err != nil { | ||
| @@ -313,6 +320,29 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 313 | return nil | 320 | return nil | ||
| 314 | } | 321 | } | ||
| 315 | 322 | ||||
| 323 | + | // detectAndSetFonts detects available system fonts and sets the best available | |||
| 324 | + | // fonts from the fallback chains. This runs only during first-run config creation. | |||
| 325 | + | func (m *Manager) detectAndSetFonts() { | |||
| 326 | + | // Create context with logger for debugging first-run font detection. | |||
| 327 | + | logger := logging.NewFromEnv() | |||
| 328 | + | ctx := logging.WithContext(context.Background(), logger) | |||
| 329 | + | ||||
| 330 | + | detector := fonts.NewDetector() | |||
| 331 | + | ||||
| 332 | + | if !detector.IsAvailable(ctx) { | |||
| 333 | + | // fc-list not available, keep hardcoded defaults | |||
| 334 | + | return | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain()) | |||
| 338 | + | serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain()) | |||
| 339 | + | monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain()) | |||
| 340 | + | ||||
| 341 | + | m.viper.Set("appearance.sans_font", sansFont) | |||
| 342 | + | m.viper.Set("appearance.serif_font", serifFont) | |||
| 343 | + | m.viper.Set("appearance.monospace_font", monoFont) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 316 | // setDefaults sets default configuration values in Viper. | 346 | // setDefaults sets default configuration values in Viper. | ||
| 317 | func (m *Manager) setDefaults() { | 347 | func (m *Manager) setDefaults() { | ||
| 318 | defaults := DefaultConfig() | 348 | defaults := DefaultConfig() | ||
| @@ -328,6 +358,7 @@ func (m *Manager) setDefaults() { | |||||
| 328 | m.setRenderingDefaults(defaults) | 358 | m.setRenderingDefaults(defaults) | ||
| 329 | m.setWorkspaceDefaults(defaults) | 359 | m.setWorkspaceDefaults(defaults) | ||
| 330 | m.setContentFilteringDefaults(defaults) | 360 | m.setContentFilteringDefaults(defaults) | ||
| 361 | + | m.setClipboardDefaults(defaults) | |||
| 331 | m.setOmniboxDefaults(defaults) | 362 | m.setOmniboxDefaults(defaults) | ||
| 332 | m.setMediaDefaults(defaults) | 363 | m.setMediaDefaults(defaults) | ||
| 333 | m.setRuntimeDefaults(defaults) | 364 | m.setRuntimeDefaults(defaults) | ||
| @@ -443,6 +474,10 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) { | |||||
| 443 | m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate) | 474 | m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate) | ||
| 444 | } | 475 | } | ||
| 445 | 476 | ||||
| 477 | + | func (m *Manager) setClipboardDefaults(defaults *Config) { | |||
| 478 | + | m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection) | |||
| 479 | + | } | |||
| 480 | + | ||||
| 446 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | 481 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | ||
| 447 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | 482 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | ||
| 448 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | 483 | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | ||
| @@ -455,8 +490,6 @@ func (m *Manager) setMediaDefaults(defaults *Config) { | |||||
| 455 | m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync) | 490 | m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync) | ||
| 456 | m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode)) | 491 | m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode)) | ||
| 457 | m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel) | 492 | m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel) | ||
| 458 | - | m.viper.SetDefault("media.video_buffer_size_mb", defaults.Media.VideoBufferSizeMB) | |||
| 459 | - | m.viper.SetDefault("media.queue_buffer_time_sec", defaults.Media.QueueBufferTimeSec) | |||
| 460 | } | 493 | } | ||
| 461 | 494 | ||||
| 462 | func (m *Manager) setRuntimeDefaults(defaults *Config) { | 495 | func (m *Manager) setRuntimeDefaults(defaults *Config) { | ||
M internal/infrastructure/config/schema.go +11 -6
| @@ -21,6 +21,8 @@ type Config struct { | |||
| 21 | 21 | Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"` | |
| 22 | 22 | // ContentFiltering controls ad blocking and content filtering | |
| 23 | 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | |
| 24 | + | // Clipboard controls clipboard-related behavior | |
| 25 | + | Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"` | |
| 24 | 26 | // Omnibox controls the omnibox behavior (initial history display) | |
| 25 | 27 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | |
| 26 | 28 | // Rendering controls WebKit, GTK and compositor behavior. | |
| @@ -165,12 +167,6 @@ type MediaConfig struct { | |||
| 165 | 167 | GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"` | |
| 166 | 168 | // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity) | |
| 167 | 169 | GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"` | |
| 168 | - | // VideoBufferSizeMB sets the video buffer size in megabytes for smoother streaming | |
| 169 | - | // Higher values reduce rebuffering but use more memory. Default: 64 MB | |
| 170 | - | VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb" toml:"video_buffer_size_mb"` | |
| 171 | - | // QueueBufferTimeSec sets the queue buffer time in seconds | |
| 172 | - | // Higher values allow more prebuffering for bursty streams. Default: 20 seconds | |
| 173 | - | QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec" toml:"queue_buffer_time_sec"` | |
| 174 | 170 | } | |
| 175 | 171 | ||
| 176 | 172 | // RuntimeConfig holds optional runtime overrides. | |
| @@ -337,6 +333,15 @@ type ContentFilteringConfig struct { | |||
| 337 | 333 | // Note: Whitelist is managed via database (content_whitelist table) | |
| 338 | 334 | } | |
| 339 | 335 | ||
| 336 | + | // ClipboardConfig holds clipboard-related behavior preferences | |
| 337 | + | type ClipboardConfig struct { | |
| 338 | + | // AutoCopyOnSelection automatically copies selected text to clipboard (zellij-style). | |
| 339 | + | // When enabled, selecting text in a webview immediately copies it to clipboard. | |
| 340 | + | // Does not apply to text selection in input fields or textareas. | |
| 341 | + | // Default: true | |
| 342 | + | AutoCopyOnSelection bool `mapstructure:"auto_copy_on_selection" yaml:"auto_copy_on_selection" toml:"auto_copy_on_selection" json:"autoCopyOnSelection"` //nolint:lll // struct tags must stay on one line | |
| 343 | + | } | |
| 344 | + | ||
| 340 | 345 | // OmniboxConfig holds omnibox behavior preferences | |
| 341 | 346 | type OmniboxConfig struct { | |
| 342 | 347 | // InitialBehavior controls what to show when omnibox opens with empty input | |
| @@ -21,6 +21,8 @@ type Config struct { | |||||
| 21 | Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"` | 21 | Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"` | ||
| 22 | // ContentFiltering controls ad blocking and content filtering | 22 | // ContentFiltering controls ad blocking and content filtering | ||
| 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | ||
| 24 | + | // Clipboard controls clipboard-related behavior | |||
| 25 | + | Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"` | |||
| 24 | // Omnibox controls the omnibox behavior (initial history display) | 26 | // Omnibox controls the omnibox behavior (initial history display) | ||
| 25 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | 27 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | ||
| 26 | // Rendering controls WebKit, GTK and compositor behavior. | 28 | // Rendering controls WebKit, GTK and compositor behavior. | ||
| @@ -165,12 +167,6 @@ type MediaConfig struct { | |||||
| 165 | GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"` | 167 | GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"` | ||
| 166 | // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity) | 168 | // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity) | ||
| 167 | GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"` | 169 | GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"` | ||
| 168 | - | // VideoBufferSizeMB sets the video buffer size in megabytes for smoother streaming | |||
| 169 | - | // Higher values reduce rebuffering but use more memory. Default: 64 MB | |||
| 170 | - | VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb" toml:"video_buffer_size_mb"` | |||
| 171 | - | // QueueBufferTimeSec sets the queue buffer time in seconds | |||
| 172 | - | // Higher values allow more prebuffering for bursty streams. Default: 20 seconds | |||
| 173 | - | QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec" toml:"queue_buffer_time_sec"` | |||
| 174 | } | 170 | } | ||
| 175 | 171 | ||||
| 176 | // RuntimeConfig holds optional runtime overrides. | 172 | // RuntimeConfig holds optional runtime overrides. | ||
| @@ -337,6 +333,15 @@ type ContentFilteringConfig struct { | |||||
| 337 | // Note: Whitelist is managed via database (content_whitelist table) | 333 | // Note: Whitelist is managed via database (content_whitelist table) | ||
| 338 | } | 334 | } | ||
| 339 | 335 | ||||
| 336 | + | // ClipboardConfig holds clipboard-related behavior preferences | |||
| 337 | + | type ClipboardConfig struct { | |||
| 338 | + | // AutoCopyOnSelection automatically copies selected text to clipboard (zellij-style). | |||
| 339 | + | // When enabled, selecting text in a webview immediately copies it to clipboard. | |||
| 340 | + | // Does not apply to text selection in input fields or textareas. | |||
| 341 | + | // Default: true | |||
| 342 | + | AutoCopyOnSelection bool `mapstructure:"auto_copy_on_selection" yaml:"auto_copy_on_selection" toml:"auto_copy_on_selection" json:"autoCopyOnSelection"` //nolint:lll // struct tags must stay on one line | |||
| 343 | + | } | |||
| 344 | + | ||||
| 340 | // OmniboxConfig holds omnibox behavior preferences | 345 | // OmniboxConfig holds omnibox behavior preferences | ||
| 341 | type OmniboxConfig struct { | 346 | type OmniboxConfig struct { | ||
| 342 | // InitialBehavior controls what to show when omnibox opens with empty input | 347 | // InitialBehavior controls what to show when omnibox opens with empty input | ||
M internal/infrastructure/config/schema_provider.go +139 -0
| @@ -14,6 +14,7 @@ const ( | |||
| 14 | 14 | SectionWorkspace = "Workspace" | |
| 15 | 15 | SectionSession = "Session" | |
| 16 | 16 | SectionOmnibox = "Omnibox" | |
| 17 | + | SectionClipboard = "Clipboard" | |
| 17 | 18 | SectionContentFiltering = "Content Filtering" | |
| 18 | 19 | SectionRendering = "Rendering" | |
| 19 | 20 | SectionMedia = "Media" | |
| @@ -64,6 +65,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |||
| 64 | 65 | // Omnibox section | |
| 65 | 66 | keys = append(keys, p.getOmniboxKeys(defaults)...) | |
| 66 | 67 | ||
| 68 | + | // Clipboard section | |
| 69 | + | keys = append(keys, p.getClipboardKeys(defaults)...) | |
| 70 | + | ||
| 67 | 71 | // Content Filtering section | |
| 68 | 72 | keys = append(keys, p.getContentFilteringKeys(defaults)...) | |
| 69 | 73 | ||
| @@ -459,6 +463,34 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 459 | 463 | Description: "Global shortcut to switch to previous tab", | |
| 460 | 464 | Section: SectionWorkspace, | |
| 461 | 465 | }, | |
| 466 | + | { | |
| 467 | + | Key: "workspace.shortcuts.consume_or_expel_left", | |
| 468 | + | Type: "string", | |
| 469 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft, | |
| 470 | + | Description: "Consume into left sibling stack or expel left (alpha)", | |
| 471 | + | Section: SectionWorkspace, | |
| 472 | + | }, | |
| 473 | + | { | |
| 474 | + | Key: "workspace.shortcuts.consume_or_expel_right", | |
| 475 | + | Type: "string", | |
| 476 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight, | |
| 477 | + | Description: "Consume into right sibling stack or expel right (alpha)", | |
| 478 | + | Section: SectionWorkspace, | |
| 479 | + | }, | |
| 480 | + | { | |
| 481 | + | Key: "workspace.shortcuts.consume_or_expel_up", | |
| 482 | + | Type: "string", | |
| 483 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp, | |
| 484 | + | Description: "Consume into upper sibling stack or expel up (alpha)", | |
| 485 | + | Section: SectionWorkspace, | |
| 486 | + | }, | |
| 487 | + | { | |
| 488 | + | Key: "workspace.shortcuts.consume_or_expel_down", | |
| 489 | + | Type: "string", | |
| 490 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown, | |
| 491 | + | Description: "Consume into lower sibling stack or expel down (alpha)", | |
| 492 | + | Section: SectionWorkspace, | |
| 493 | + | }, | |
| 462 | 494 | // Popups | |
| 463 | 495 | { | |
| 464 | 496 | Key: "workspace.popups.behavior", | |
| @@ -679,6 +711,18 @@ func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.Config | |||
| 679 | 711 | } | |
| 680 | 712 | } | |
| 681 | 713 | ||
| 714 | + | func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 715 | + | return []entity.ConfigKeyInfo{ | |
| 716 | + | { | |
| 717 | + | Key: "clipboard.auto_copy_on_selection", | |
| 718 | + | Type: "bool", | |
| 719 | + | Default: fmt.Sprintf("%t", defaults.Clipboard.AutoCopyOnSelection), | |
| 720 | + | Description: "Auto-copy selected text to clipboard (zellij-style)", | |
| 721 | + | Section: SectionClipboard, | |
| 722 | + | }, | |
| 723 | + | } | |
| 724 | + | } | |
| 725 | + | ||
| 682 | 726 | func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 683 | 727 | return []entity.ConfigKeyInfo{ | |
| 684 | 728 | { | |
| @@ -853,6 +897,14 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 853 | 897 | ||
| 854 | 898 | func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 855 | 899 | return []entity.ConfigKeyInfo{ | |
| 900 | + | { | |
| 901 | + | Key: "performance.profile", | |
| 902 | + | Type: "string", | |
| 903 | + | Default: string(defaults.Performance.Profile), | |
| 904 | + | Description: "Performance profile preset (custom enables manual tuning)", | |
| 905 | + | Values: []string{"default", "lite", "max", "custom"}, | |
| 906 | + | Section: SectionPerformance, | |
| 907 | + | }, | |
| 856 | 908 | { | |
| 857 | 909 | Key: "performance.zoom_cache_size", | |
| 858 | 910 | Type: "int", | |
| @@ -869,6 +921,93 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 869 | 921 | Range: ">=0", | |
| 870 | 922 | Section: SectionPerformance, | |
| 871 | 923 | }, | |
| 924 | + | { | |
| 925 | + | Key: "performance.skia_cpu_painting_threads", | |
| 926 | + | Type: "int", | |
| 927 | + | Default: fmt.Sprintf("%d", defaults.Performance.SkiaCPUPaintingThreads), | |
| 928 | + | Description: "Skia CPU rendering threads (0=unset, custom profile only)", | |
| 929 | + | Range: ">=0", | |
| 930 | + | Section: SectionPerformance, | |
| 931 | + | }, | |
| 932 | + | { | |
| 933 | + | Key: "performance.skia_gpu_painting_threads", | |
| 934 | + | Type: "int", | |
| 935 | + | Default: fmt.Sprintf("%d", defaults.Performance.SkiaGPUPaintingThreads), | |
| 936 | + | Description: "Skia GPU rendering threads (-1=unset, 0=disable, custom profile only)", | |
| 937 | + | Range: ">=-1", | |
| 938 | + | Section: SectionPerformance, | |
| 939 | + | }, | |
| 940 | + | { | |
| 941 | + | Key: "performance.skia_enable_cpu_rendering", | |
| 942 | + | Type: "bool", | |
| 943 | + | Default: fmt.Sprintf("%t", defaults.Performance.SkiaEnableCPURendering), | |
| 944 | + | Description: "Force CPU rendering (custom profile only)", | |
| 945 | + | Section: SectionPerformance, | |
| 946 | + | }, | |
| 947 | + | { | |
| 948 | + | Key: "performance.web_process_memory_limit_mb", | |
| 949 | + | Type: "int", | |
| 950 | + | Default: fmt.Sprintf("%d", defaults.Performance.WebProcessMemoryLimitMB), | |
| 951 | + | Description: "Web process memory limit in MB (0=unset, custom profile only)", | |
| 952 | + | Range: ">=0", | |
| 953 | + | Section: SectionPerformance, | |
| 954 | + | }, | |
| 955 | + | { | |
| 956 | + | Key: "performance.web_process_memory_poll_interval_sec", | |
| 957 | + | Type: "float64", | |
| 958 | + | Default: fmt.Sprintf("%.1f", defaults.Performance.WebProcessMemoryPollIntervalSec), | |
| 959 | + | Description: "Memory check interval in seconds (0=default 30s, custom profile only)", | |
| 960 | + | Range: ">=0", | |
| 961 | + | Section: SectionPerformance, | |
| 962 | + | }, | |
| 963 | + | { | |
| 964 | + | Key: "performance.web_process_memory_conservative_threshold", | |
| 965 | + | Type: "float64", | |
| 966 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryConservativeThreshold), | |
| 967 | + | Description: "Conservative memory cleanup threshold (0-1, custom profile only)", | |
| 968 | + | Range: "0-1", | |
| 969 | + | Section: SectionPerformance, | |
| 970 | + | }, | |
| 971 | + | { | |
| 972 | + | Key: "performance.web_process_memory_strict_threshold", | |
| 973 | + | Type: "float64", | |
| 974 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryStrictThreshold), | |
| 975 | + | Description: "Strict memory cleanup threshold (0-1, custom profile only)", | |
| 976 | + | Range: "0-1", | |
| 977 | + | Section: SectionPerformance, | |
| 978 | + | }, | |
| 979 | + | { | |
| 980 | + | Key: "performance.network_process_memory_limit_mb", | |
| 981 | + | Type: "int", | |
| 982 | + | Default: fmt.Sprintf("%d", defaults.Performance.NetworkProcessMemoryLimitMB), | |
| 983 | + | Description: "Network process memory limit in MB (0=unset, custom profile only)", | |
| 984 | + | Range: ">=0", | |
| 985 | + | Section: SectionPerformance, | |
| 986 | + | }, | |
| 987 | + | { | |
| 988 | + | Key: "performance.network_process_memory_poll_interval_sec", | |
| 989 | + | Type: "float64", | |
| 990 | + | Default: fmt.Sprintf("%.1f", defaults.Performance.NetworkProcessMemoryPollIntervalSec), | |
| 991 | + | Description: "Network memory check interval in seconds (custom profile only)", | |
| 992 | + | Range: ">=0", | |
| 993 | + | Section: SectionPerformance, | |
| 994 | + | }, | |
| 995 | + | { | |
| 996 | + | Key: "performance.network_process_memory_conservative_threshold", | |
| 997 | + | Type: "float64", | |
| 998 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryConservativeThreshold), | |
| 999 | + | Description: "Network conservative memory threshold (0-1, custom profile only)", | |
| 1000 | + | Range: "0-1", | |
| 1001 | + | Section: SectionPerformance, | |
| 1002 | + | }, | |
| 1003 | + | { | |
| 1004 | + | Key: "performance.network_process_memory_strict_threshold", | |
| 1005 | + | Type: "float64", | |
| 1006 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryStrictThreshold), | |
| 1007 | + | Description: "Network strict memory threshold (0-1, custom profile only)", | |
| 1008 | + | Range: "0-1", | |
| 1009 | + | Section: SectionPerformance, | |
| 1010 | + | }, | |
| 872 | 1011 | } | |
| 873 | 1012 | } | |
| 874 | 1013 | ||
| @@ -14,6 +14,7 @@ const ( | |||||
| 14 | SectionWorkspace = "Workspace" | 14 | SectionWorkspace = "Workspace" | ||
| 15 | SectionSession = "Session" | 15 | SectionSession = "Session" | ||
| 16 | SectionOmnibox = "Omnibox" | 16 | SectionOmnibox = "Omnibox" | ||
| 17 | + | SectionClipboard = "Clipboard" | |||
| 17 | SectionContentFiltering = "Content Filtering" | 18 | SectionContentFiltering = "Content Filtering" | ||
| 18 | SectionRendering = "Rendering" | 19 | SectionRendering = "Rendering" | ||
| 19 | SectionMedia = "Media" | 20 | SectionMedia = "Media" | ||
| @@ -64,6 +65,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |||||
| 64 | // Omnibox section | 65 | // Omnibox section | ||
| 65 | keys = append(keys, p.getOmniboxKeys(defaults)...) | 66 | keys = append(keys, p.getOmniboxKeys(defaults)...) | ||
| 66 | 67 | ||||
| 68 | + | // Clipboard section | |||
| 69 | + | keys = append(keys, p.getClipboardKeys(defaults)...) | |||
| 70 | + | ||||
| 67 | // Content Filtering section | 71 | // Content Filtering section | ||
| 68 | keys = append(keys, p.getContentFilteringKeys(defaults)...) | 72 | keys = append(keys, p.getContentFilteringKeys(defaults)...) | ||
| 69 | 73 | ||||
| @@ -459,6 +463,34 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 459 | Description: "Global shortcut to switch to previous tab", | 463 | Description: "Global shortcut to switch to previous tab", | ||
| 460 | Section: SectionWorkspace, | 464 | Section: SectionWorkspace, | ||
| 461 | }, | 465 | }, | ||
| 466 | + | { | |||
| 467 | + | Key: "workspace.shortcuts.consume_or_expel_left", | |||
| 468 | + | Type: "string", | |||
| 469 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft, | |||
| 470 | + | Description: "Consume into left sibling stack or expel left (alpha)", | |||
| 471 | + | Section: SectionWorkspace, | |||
| 472 | + | }, | |||
| 473 | + | { | |||
| 474 | + | Key: "workspace.shortcuts.consume_or_expel_right", | |||
| 475 | + | Type: "string", | |||
| 476 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight, | |||
| 477 | + | Description: "Consume into right sibling stack or expel right (alpha)", | |||
| 478 | + | Section: SectionWorkspace, | |||
| 479 | + | }, | |||
| 480 | + | { | |||
| 481 | + | Key: "workspace.shortcuts.consume_or_expel_up", | |||
| 482 | + | Type: "string", | |||
| 483 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp, | |||
| 484 | + | Description: "Consume into upper sibling stack or expel up (alpha)", | |||
| 485 | + | Section: SectionWorkspace, | |||
| 486 | + | }, | |||
| 487 | + | { | |||
| 488 | + | Key: "workspace.shortcuts.consume_or_expel_down", | |||
| 489 | + | Type: "string", | |||
| 490 | + | Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown, | |||
| 491 | + | Description: "Consume into lower sibling stack or expel down (alpha)", | |||
| 492 | + | Section: SectionWorkspace, | |||
| 493 | + | }, | |||
| 462 | // Popups | 494 | // Popups | ||
| 463 | { | 495 | { | ||
| 464 | Key: "workspace.popups.behavior", | 496 | Key: "workspace.popups.behavior", | ||
| @@ -679,6 +711,18 @@ func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.Config | |||||
| 679 | } | 711 | } | ||
| 680 | } | 712 | } | ||
| 681 | 713 | ||||
| 714 | + | func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 715 | + | return []entity.ConfigKeyInfo{ | |||
| 716 | + | { | |||
| 717 | + | Key: "clipboard.auto_copy_on_selection", | |||
| 718 | + | Type: "bool", | |||
| 719 | + | Default: fmt.Sprintf("%t", defaults.Clipboard.AutoCopyOnSelection), | |||
| 720 | + | Description: "Auto-copy selected text to clipboard (zellij-style)", | |||
| 721 | + | Section: SectionClipboard, | |||
| 722 | + | }, | |||
| 723 | + | } | |||
| 724 | + | } | |||
| 725 | + | ||||
| 682 | func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo { | 726 | func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo { | ||
| 683 | return []entity.ConfigKeyInfo{ | 727 | return []entity.ConfigKeyInfo{ | ||
| 684 | { | 728 | { | ||
| @@ -853,6 +897,14 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||||
| 853 | 897 | ||||
| 854 | func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo { | 898 | func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo { | ||
| 855 | return []entity.ConfigKeyInfo{ | 899 | return []entity.ConfigKeyInfo{ | ||
| 900 | + | { | |||
| 901 | + | Key: "performance.profile", | |||
| 902 | + | Type: "string", | |||
| 903 | + | Default: string(defaults.Performance.Profile), | |||
| 904 | + | Description: "Performance profile preset (custom enables manual tuning)", | |||
| 905 | + | Values: []string{"default", "lite", "max", "custom"}, | |||
| 906 | + | Section: SectionPerformance, | |||
| 907 | + | }, | |||
| 856 | { | 908 | { | ||
| 857 | Key: "performance.zoom_cache_size", | 909 | Key: "performance.zoom_cache_size", | ||
| 858 | Type: "int", | 910 | Type: "int", | ||
| @@ -869,6 +921,93 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 869 | Range: ">=0", | 921 | Range: ">=0", | ||
| 870 | Section: SectionPerformance, | 922 | Section: SectionPerformance, | ||
| 871 | }, | 923 | }, | ||
| 924 | + | { | |||
| 925 | + | Key: "performance.skia_cpu_painting_threads", | |||
| 926 | + | Type: "int", | |||
| 927 | + | Default: fmt.Sprintf("%d", defaults.Performance.SkiaCPUPaintingThreads), | |||
| 928 | + | Description: "Skia CPU rendering threads (0=unset, custom profile only)", | |||
| 929 | + | Range: ">=0", | |||
| 930 | + | Section: SectionPerformance, | |||
| 931 | + | }, | |||
| 932 | + | { | |||
| 933 | + | Key: "performance.skia_gpu_painting_threads", | |||
| 934 | + | Type: "int", | |||
| 935 | + | Default: fmt.Sprintf("%d", defaults.Performance.SkiaGPUPaintingThreads), | |||
| 936 | + | Description: "Skia GPU rendering threads (-1=unset, 0=disable, custom profile only)", | |||
| 937 | + | Range: ">=-1", | |||
| 938 | + | Section: SectionPerformance, | |||
| 939 | + | }, | |||
| 940 | + | { | |||
| 941 | + | Key: "performance.skia_enable_cpu_rendering", | |||
| 942 | + | Type: "bool", | |||
| 943 | + | Default: fmt.Sprintf("%t", defaults.Performance.SkiaEnableCPURendering), | |||
| 944 | + | Description: "Force CPU rendering (custom profile only)", | |||
| 945 | + | Section: SectionPerformance, | |||
| 946 | + | }, | |||
| 947 | + | { | |||
| 948 | + | Key: "performance.web_process_memory_limit_mb", | |||
| 949 | + | Type: "int", | |||
| 950 | + | Default: fmt.Sprintf("%d", defaults.Performance.WebProcessMemoryLimitMB), | |||
| 951 | + | Description: "Web process memory limit in MB (0=unset, custom profile only)", | |||
| 952 | + | Range: ">=0", | |||
| 953 | + | Section: SectionPerformance, | |||
| 954 | + | }, | |||
| 955 | + | { | |||
| 956 | + | Key: "performance.web_process_memory_poll_interval_sec", | |||
| 957 | + | Type: "float64", | |||
| 958 | + | Default: fmt.Sprintf("%.1f", defaults.Performance.WebProcessMemoryPollIntervalSec), | |||
| 959 | + | Description: "Memory check interval in seconds (0=default 30s, custom profile only)", | |||
| 960 | + | Range: ">=0", | |||
| 961 | + | Section: SectionPerformance, | |||
| 962 | + | }, | |||
| 963 | + | { | |||
| 964 | + | Key: "performance.web_process_memory_conservative_threshold", | |||
| 965 | + | Type: "float64", | |||
| 966 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryConservativeThreshold), | |||
| 967 | + | Description: "Conservative memory cleanup threshold (0-1, custom profile only)", | |||
| 968 | + | Range: "0-1", | |||
| 969 | + | Section: SectionPerformance, | |||
| 970 | + | }, | |||
| 971 | + | { | |||
| 972 | + | Key: "performance.web_process_memory_strict_threshold", | |||
| 973 | + | Type: "float64", | |||
| 974 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryStrictThreshold), | |||
| 975 | + | Description: "Strict memory cleanup threshold (0-1, custom profile only)", | |||
| 976 | + | Range: "0-1", | |||
| 977 | + | Section: SectionPerformance, | |||
| 978 | + | }, | |||
| 979 | + | { | |||
| 980 | + | Key: "performance.network_process_memory_limit_mb", | |||
| 981 | + | Type: "int", | |||
| 982 | + | Default: fmt.Sprintf("%d", defaults.Performance.NetworkProcessMemoryLimitMB), | |||
| 983 | + | Description: "Network process memory limit in MB (0=unset, custom profile only)", | |||
| 984 | + | Range: ">=0", | |||
| 985 | + | Section: SectionPerformance, | |||
| 986 | + | }, | |||
| 987 | + | { | |||
| 988 | + | Key: "performance.network_process_memory_poll_interval_sec", | |||
| 989 | + | Type: "float64", | |||
| 990 | + | Default: fmt.Sprintf("%.1f", defaults.Performance.NetworkProcessMemoryPollIntervalSec), | |||
| 991 | + | Description: "Network memory check interval in seconds (custom profile only)", | |||
| 992 | + | Range: ">=0", | |||
| 993 | + | Section: SectionPerformance, | |||
| 994 | + | }, | |||
| 995 | + | { | |||
| 996 | + | Key: "performance.network_process_memory_conservative_threshold", | |||
| 997 | + | Type: "float64", | |||
| 998 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryConservativeThreshold), | |||
| 999 | + | Description: "Network conservative memory threshold (0-1, custom profile only)", | |||
| 1000 | + | Range: "0-1", | |||
| 1001 | + | Section: SectionPerformance, | |||
| 1002 | + | }, | |||
| 1003 | + | { | |||
| 1004 | + | Key: "performance.network_process_memory_strict_threshold", | |||
| 1005 | + | Type: "float64", | |||
| 1006 | + | Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryStrictThreshold), | |||
| 1007 | + | Description: "Network strict memory threshold (0-1, custom profile only)", | |||
| 1008 | + | Range: "0-1", | |||
| 1009 | + | Section: SectionPerformance, | |||
| 1010 | + | }, | |||
| 872 | } | 1011 | } | ||
| 873 | } | 1012 | } | ||
| 874 | 1013 | ||||
A internal/infrastructure/fonts/detector.go +217 -0
| @@ -0,0 +1,217 @@ | |||
| 1 | + | package fonts | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "os/exec" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Fallback chains for each font category (unexported to prevent modification). | |
| 15 | + | var ( | |
| 16 | + | sansSerifFallbackChain = []string{ | |
| 17 | + | "Fira Sans", | |
| 18 | + | "Noto Sans", | |
| 19 | + | "DejaVu Sans", | |
| 20 | + | "Liberation Sans", | |
| 21 | + | "FreeSans", | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | serifFallbackChain = []string{ | |
| 25 | + | "Noto Serif", | |
| 26 | + | "DejaVu Serif", | |
| 27 | + | "Liberation Serif", | |
| 28 | + | "FreeSerif", | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | monospaceFallbackChain = []string{ | |
| 32 | + | "Fira Code", | |
| 33 | + | "JetBrains Mono", | |
| 34 | + | "Noto Sans Mono", | |
| 35 | + | "DejaVu Sans Mono", | |
| 36 | + | "Liberation Mono", | |
| 37 | + | "FreeMono", | |
| 38 | + | } | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // SansSerifFallbackChain returns the fallback chain for sans-serif fonts. | |
| 42 | + | func SansSerifFallbackChain() []string { | |
| 43 | + | result := make([]string, len(sansSerifFallbackChain)) | |
| 44 | + | copy(result, sansSerifFallbackChain) | |
| 45 | + | return result | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // SerifFallbackChain returns the fallback chain for serif fonts. | |
| 49 | + | func SerifFallbackChain() []string { | |
| 50 | + | result := make([]string, len(serifFallbackChain)) | |
| 51 | + | copy(result, serifFallbackChain) | |
| 52 | + | return result | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // MonospaceFallbackChain returns the fallback chain for monospace fonts. | |
| 56 | + | func MonospaceFallbackChain() []string { | |
| 57 | + | result := make([]string, len(monospaceFallbackChain)) | |
| 58 | + | copy(result, monospaceFallbackChain) | |
| 59 | + | return result | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Generic CSS fallbacks when no fonts from the chain are found. | |
| 63 | + | const ( | |
| 64 | + | genericSansSerif = "sans-serif" | |
| 65 | + | genericSerif = "serif" | |
| 66 | + | genericMonospace = "monospace" | |
| 67 | + | ) | |
| 68 | + | ||
| 69 | + | // Detector implements port.FontDetector using fontconfig's fc-list command. | |
| 70 | + | type Detector struct { | |
| 71 | + | mu sync.RWMutex | |
| 72 | + | cachedFonts []string | |
| 73 | + | cachePopulated bool | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // NewDetector creates a new font detector. | |
| 77 | + | func NewDetector() *Detector { | |
| 78 | + | return &Detector{} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // IsAvailable implements port.FontDetector. | |
| 82 | + | // Returns true if fc-list command is available on the system. | |
| 83 | + | func (*Detector) IsAvailable(_ context.Context) bool { | |
| 84 | + | _, err := exec.LookPath("fc-list") | |
| 85 | + | return err == nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // GetAvailableFonts implements port.FontDetector. | |
| 89 | + | // Returns a list of font family names installed on the system. | |
| 90 | + | func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) { | |
| 91 | + | log := logging.FromContext(ctx) | |
| 92 | + | ||
| 93 | + | d.mu.RLock() | |
| 94 | + | if d.cachePopulated { | |
| 95 | + | fonts := d.cachedFonts | |
| 96 | + | d.mu.RUnlock() | |
| 97 | + | return fonts, nil | |
| 98 | + | } | |
| 99 | + | d.mu.RUnlock() | |
| 100 | + | ||
| 101 | + | d.mu.Lock() | |
| 102 | + | defer d.mu.Unlock() | |
| 103 | + | ||
| 104 | + | // Double-check after acquiring write lock. | |
| 105 | + | if d.cachePopulated { | |
| 106 | + | return d.cachedFonts, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | fonts, err := d.queryFonts(ctx) | |
| 110 | + | if err != nil { | |
| 111 | + | log.Debug().Err(err).Msg("failed to query system fonts") | |
| 112 | + | return nil, err | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | d.cachedFonts = fonts | |
| 116 | + | d.cachePopulated = true | |
| 117 | + | log.Debug().Int("count", len(fonts)).Msg("cached system fonts") | |
| 118 | + | ||
| 119 | + | return fonts, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // SelectBestFont implements port.FontDetector. | |
| 123 | + | // Returns the first available font from the fallback chain for the given category. | |
| 124 | + | func (d *Detector) SelectBestFont( | |
| 125 | + | ctx context.Context, | |
| 126 | + | category port.FontCategory, | |
| 127 | + | fallbackChain []string, | |
| 128 | + | ) string { | |
| 129 | + | log := logging.FromContext(ctx) | |
| 130 | + | ||
| 131 | + | availableFonts, err := d.GetAvailableFonts(ctx) | |
| 132 | + | if err != nil { | |
| 133 | + | log.Debug(). | |
| 134 | + | Str("category", string(category)). | |
| 135 | + | Err(err). | |
| 136 | + | Msg("font detection unavailable, using generic fallback") | |
| 137 | + | return d.genericFallback(category) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Build a set for O(1) lookup. | |
| 141 | + | fontSet := make(map[string]struct{}, len(availableFonts)) | |
| 142 | + | for _, f := range availableFonts { | |
| 143 | + | fontSet[f] = struct{}{} | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Find the first available font in the chain. | |
| 147 | + | for _, font := range fallbackChain { | |
| 148 | + | if _, exists := fontSet[font]; exists { | |
| 149 | + | log.Debug(). | |
| 150 | + | Str("category", string(category)). | |
| 151 | + | Str("font", font). | |
| 152 | + | Msg("selected font from fallback chain") | |
| 153 | + | return font | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // No fonts from chain found, use generic fallback. | |
| 158 | + | generic := d.genericFallback(category) | |
| 159 | + | log.Debug(). | |
| 160 | + | Str("category", string(category)). | |
| 161 | + | Str("fallback", generic). | |
| 162 | + | Msg("no fonts from fallback chain available, using generic") | |
| 163 | + | return generic | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // queryFonts executes fc-list and parses the output. | |
| 167 | + | func (*Detector) queryFonts(ctx context.Context) ([]string, error) { | |
| 168 | + | cmd := exec.CommandContext(ctx, "fc-list", ":", "family") | |
| 169 | + | output, err := cmd.Output() | |
| 170 | + | if err != nil { | |
| 171 | + | return nil, err | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | fontSet := make(map[string]struct{}) | |
| 175 | + | scanner := bufio.NewScanner(strings.NewReader(string(output))) | |
| 176 | + | for scanner.Scan() { | |
| 177 | + | line := strings.TrimSpace(scanner.Text()) | |
| 178 | + | if line == "" { | |
| 179 | + | continue | |
| 180 | + | } | |
| 181 | + | // fc-list may return comma-separated families for fonts with aliases. | |
| 182 | + | // e.g., "DejaVu Sans,DejaVu Sans Light" | |
| 183 | + | families := strings.Split(line, ",") | |
| 184 | + | for _, family := range families { | |
| 185 | + | family = strings.TrimSpace(family) | |
| 186 | + | if family != "" { | |
| 187 | + | fontSet[family] = struct{}{} | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | if err := scanner.Err(); err != nil { | |
| 193 | + | return nil, err | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Convert set to slice. | |
| 197 | + | fonts := make([]string, 0, len(fontSet)) | |
| 198 | + | for font := range fontSet { | |
| 199 | + | fonts = append(fonts, font) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | return fonts, nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // genericFallback returns the CSS generic fallback for the given category. | |
| 206 | + | func (*Detector) genericFallback(category port.FontCategory) string { | |
| 207 | + | switch category { | |
| 208 | + | case port.FontCategorySansSerif: | |
| 209 | + | return genericSansSerif | |
| 210 | + | case port.FontCategorySerif: | |
| 211 | + | return genericSerif | |
| 212 | + | case port.FontCategoryMonospace: | |
| 213 | + | return genericMonospace | |
| 214 | + | default: | |
| 215 | + | return genericSansSerif | |
| 216 | + | } | |
| 217 | + | } | |
| @@ -0,0 +1,217 @@ | |||||
| 1 | + | package fonts | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Fallback chains for each font category (unexported to prevent modification). | |||
| 15 | + | var ( | |||
| 16 | + | sansSerifFallbackChain = []string{ | |||
| 17 | + | "Fira Sans", | |||
| 18 | + | "Noto Sans", | |||
| 19 | + | "DejaVu Sans", | |||
| 20 | + | "Liberation Sans", | |||
| 21 | + | "FreeSans", | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | serifFallbackChain = []string{ | |||
| 25 | + | "Noto Serif", | |||
| 26 | + | "DejaVu Serif", | |||
| 27 | + | "Liberation Serif", | |||
| 28 | + | "FreeSerif", | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | monospaceFallbackChain = []string{ | |||
| 32 | + | "Fira Code", | |||
| 33 | + | "JetBrains Mono", | |||
| 34 | + | "Noto Sans Mono", | |||
| 35 | + | "DejaVu Sans Mono", | |||
| 36 | + | "Liberation Mono", | |||
| 37 | + | "FreeMono", | |||
| 38 | + | } | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // SansSerifFallbackChain returns the fallback chain for sans-serif fonts. | |||
| 42 | + | func SansSerifFallbackChain() []string { | |||
| 43 | + | result := make([]string, len(sansSerifFallbackChain)) | |||
| 44 | + | copy(result, sansSerifFallbackChain) | |||
| 45 | + | return result | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // SerifFallbackChain returns the fallback chain for serif fonts. | |||
| 49 | + | func SerifFallbackChain() []string { | |||
| 50 | + | result := make([]string, len(serifFallbackChain)) | |||
| 51 | + | copy(result, serifFallbackChain) | |||
| 52 | + | return result | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // MonospaceFallbackChain returns the fallback chain for monospace fonts. | |||
| 56 | + | func MonospaceFallbackChain() []string { | |||
| 57 | + | result := make([]string, len(monospaceFallbackChain)) | |||
| 58 | + | copy(result, monospaceFallbackChain) | |||
| 59 | + | return result | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Generic CSS fallbacks when no fonts from the chain are found. | |||
| 63 | + | const ( | |||
| 64 | + | genericSansSerif = "sans-serif" | |||
| 65 | + | genericSerif = "serif" | |||
| 66 | + | genericMonospace = "monospace" | |||
| 67 | + | ) | |||
| 68 | + | ||||
| 69 | + | // Detector implements port.FontDetector using fontconfig's fc-list command. | |||
| 70 | + | type Detector struct { | |||
| 71 | + | mu sync.RWMutex | |||
| 72 | + | cachedFonts []string | |||
| 73 | + | cachePopulated bool | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // NewDetector creates a new font detector. | |||
| 77 | + | func NewDetector() *Detector { | |||
| 78 | + | return &Detector{} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // IsAvailable implements port.FontDetector. | |||
| 82 | + | // Returns true if fc-list command is available on the system. | |||
| 83 | + | func (*Detector) IsAvailable(_ context.Context) bool { | |||
| 84 | + | _, err := exec.LookPath("fc-list") | |||
| 85 | + | return err == nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // GetAvailableFonts implements port.FontDetector. | |||
| 89 | + | // Returns a list of font family names installed on the system. | |||
| 90 | + | func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) { | |||
| 91 | + | log := logging.FromContext(ctx) | |||
| 92 | + | ||||
| 93 | + | d.mu.RLock() | |||
| 94 | + | if d.cachePopulated { | |||
| 95 | + | fonts := d.cachedFonts | |||
| 96 | + | d.mu.RUnlock() | |||
| 97 | + | return fonts, nil | |||
| 98 | + | } | |||
| 99 | + | d.mu.RUnlock() | |||
| 100 | + | ||||
| 101 | + | d.mu.Lock() | |||
| 102 | + | defer d.mu.Unlock() | |||
| 103 | + | ||||
| 104 | + | // Double-check after acquiring write lock. | |||
| 105 | + | if d.cachePopulated { | |||
| 106 | + | return d.cachedFonts, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | fonts, err := d.queryFonts(ctx) | |||
| 110 | + | if err != nil { | |||
| 111 | + | log.Debug().Err(err).Msg("failed to query system fonts") | |||
| 112 | + | return nil, err | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | d.cachedFonts = fonts | |||
| 116 | + | d.cachePopulated = true | |||
| 117 | + | log.Debug().Int("count", len(fonts)).Msg("cached system fonts") | |||
| 118 | + | ||||
| 119 | + | return fonts, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // SelectBestFont implements port.FontDetector. | |||
| 123 | + | // Returns the first available font from the fallback chain for the given category. | |||
| 124 | + | func (d *Detector) SelectBestFont( | |||
| 125 | + | ctx context.Context, | |||
| 126 | + | category port.FontCategory, | |||
| 127 | + | fallbackChain []string, | |||
| 128 | + | ) string { | |||
| 129 | + | log := logging.FromContext(ctx) | |||
| 130 | + | ||||
| 131 | + | availableFonts, err := d.GetAvailableFonts(ctx) | |||
| 132 | + | if err != nil { | |||
| 133 | + | log.Debug(). | |||
| 134 | + | Str("category", string(category)). | |||
| 135 | + | Err(err). | |||
| 136 | + | Msg("font detection unavailable, using generic fallback") | |||
| 137 | + | return d.genericFallback(category) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Build a set for O(1) lookup. | |||
| 141 | + | fontSet := make(map[string]struct{}, len(availableFonts)) | |||
| 142 | + | for _, f := range availableFonts { | |||
| 143 | + | fontSet[f] = struct{}{} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Find the first available font in the chain. | |||
| 147 | + | for _, font := range fallbackChain { | |||
| 148 | + | if _, exists := fontSet[font]; exists { | |||
| 149 | + | log.Debug(). | |||
| 150 | + | Str("category", string(category)). | |||
| 151 | + | Str("font", font). | |||
| 152 | + | Msg("selected font from fallback chain") | |||
| 153 | + | return font | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // No fonts from chain found, use generic fallback. | |||
| 158 | + | generic := d.genericFallback(category) | |||
| 159 | + | log.Debug(). | |||
| 160 | + | Str("category", string(category)). | |||
| 161 | + | Str("fallback", generic). | |||
| 162 | + | Msg("no fonts from fallback chain available, using generic") | |||
| 163 | + | return generic | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // queryFonts executes fc-list and parses the output. | |||
| 167 | + | func (*Detector) queryFonts(ctx context.Context) ([]string, error) { | |||
| 168 | + | cmd := exec.CommandContext(ctx, "fc-list", ":", "family") | |||
| 169 | + | output, err := cmd.Output() | |||
| 170 | + | if err != nil { | |||
| 171 | + | return nil, err | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | fontSet := make(map[string]struct{}) | |||
| 175 | + | scanner := bufio.NewScanner(strings.NewReader(string(output))) | |||
| 176 | + | for scanner.Scan() { | |||
| 177 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 178 | + | if line == "" { | |||
| 179 | + | continue | |||
| 180 | + | } | |||
| 181 | + | // fc-list may return comma-separated families for fonts with aliases. | |||
| 182 | + | // e.g., "DejaVu Sans,DejaVu Sans Light" | |||
| 183 | + | families := strings.Split(line, ",") | |||
| 184 | + | for _, family := range families { | |||
| 185 | + | family = strings.TrimSpace(family) | |||
| 186 | + | if family != "" { | |||
| 187 | + | fontSet[family] = struct{}{} | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | if err := scanner.Err(); err != nil { | |||
| 193 | + | return nil, err | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Convert set to slice. | |||
| 197 | + | fonts := make([]string, 0, len(fontSet)) | |||
| 198 | + | for font := range fontSet { | |||
| 199 | + | fonts = append(fonts, font) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | return fonts, nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // genericFallback returns the CSS generic fallback for the given category. | |||
| 206 | + | func (*Detector) genericFallback(category port.FontCategory) string { | |||
| 207 | + | switch category { | |||
| 208 | + | case port.FontCategorySansSerif: | |||
| 209 | + | return genericSansSerif | |||
| 210 | + | case port.FontCategorySerif: | |||
| 211 | + | return genericSerif | |||
| 212 | + | case port.FontCategoryMonospace: | |||
| 213 | + | return genericMonospace | |||
| 214 | + | default: | |||
| 215 | + | return genericSansSerif | |||
| 216 | + | } | |||
| 217 | + | } | |||
A internal/infrastructure/fonts/detector_test.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | package fonts | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/rs/zerolog" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func testContext() context.Context { | |
| 14 | + | logger := zerolog.Nop() | |
| 15 | + | return logger.WithContext(context.Background()) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestDetector_IsAvailable(t *testing.T) { | |
| 19 | + | ctx := testContext() | |
| 20 | + | detector := NewDetector() | |
| 21 | + | ||
| 22 | + | // fc-list should be available on most Linux systems | |
| 23 | + | // This test documents the behavior rather than asserting a specific value | |
| 24 | + | available := detector.IsAvailable(ctx) | |
| 25 | + | t.Logf("fc-list available: %v", available) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestDetector_GetAvailableFonts(t *testing.T) { | |
| 29 | + | ctx := testContext() | |
| 30 | + | detector := NewDetector() | |
| 31 | + | ||
| 32 | + | if !detector.IsAvailable(ctx) { | |
| 33 | + | t.Skip("fc-list not available on this system") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | fonts, err := detector.GetAvailableFonts(ctx) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | assert.NotEmpty(t, fonts, "expected at least some fonts to be installed") | |
| 39 | + | ||
| 40 | + | t.Logf("Found %d font families", len(fonts)) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestDetector_GetAvailableFonts_Caching(t *testing.T) { | |
| 44 | + | ctx := testContext() | |
| 45 | + | detector := NewDetector() | |
| 46 | + | ||
| 47 | + | if !detector.IsAvailable(ctx) { | |
| 48 | + | t.Skip("fc-list not available on this system") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // First call populates cache | |
| 52 | + | fonts1, err := detector.GetAvailableFonts(ctx) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | ||
| 55 | + | // Second call should return cached result | |
| 56 | + | fonts2, err := detector.GetAvailableFonts(ctx) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | ||
| 59 | + | assert.Len(t, fonts2, len(fonts1), "cached result should match original") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestDetector_SelectBestFont_SansSerif(t *testing.T) { | |
| 63 | + | ctx := testContext() | |
| 64 | + | detector := NewDetector() | |
| 65 | + | ||
| 66 | + | if !detector.IsAvailable(ctx) { | |
| 67 | + | t.Skip("fc-list not available on this system") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, SansSerifFallbackChain()) | |
| 71 | + | assert.NotEmpty(t, font) | |
| 72 | + | ||
| 73 | + | // Should either be a font from the chain or the generic fallback | |
| 74 | + | isFromChain := false | |
| 75 | + | for _, f := range SansSerifFallbackChain() { | |
| 76 | + | if font == f { | |
| 77 | + | isFromChain = true | |
| 78 | + | break | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | if !isFromChain { | |
| 82 | + | assert.Equal(t, "sans-serif", font, "should fall back to generic if no chain fonts available") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | t.Logf("Selected sans-serif font: %s", font) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestDetector_SelectBestFont_Serif(t *testing.T) { | |
| 89 | + | ctx := testContext() | |
| 90 | + | detector := NewDetector() | |
| 91 | + | ||
| 92 | + | if !detector.IsAvailable(ctx) { | |
| 93 | + | t.Skip("fc-list not available on this system") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | font := detector.SelectBestFont(ctx, port.FontCategorySerif, SerifFallbackChain()) | |
| 97 | + | assert.NotEmpty(t, font) | |
| 98 | + | ||
| 99 | + | isFromChain := false | |
| 100 | + | for _, f := range SerifFallbackChain() { | |
| 101 | + | if font == f { | |
| 102 | + | isFromChain = true | |
| 103 | + | break | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | if !isFromChain { | |
| 107 | + | assert.Equal(t, "serif", font, "should fall back to generic if no chain fonts available") | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | t.Logf("Selected serif font: %s", font) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestDetector_SelectBestFont_Monospace(t *testing.T) { | |
| 114 | + | ctx := testContext() | |
| 115 | + | detector := NewDetector() | |
| 116 | + | ||
| 117 | + | if !detector.IsAvailable(ctx) { | |
| 118 | + | t.Skip("fc-list not available on this system") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | font := detector.SelectBestFont(ctx, port.FontCategoryMonospace, MonospaceFallbackChain()) | |
| 122 | + | assert.NotEmpty(t, font) | |
| 123 | + | ||
| 124 | + | isFromChain := false | |
| 125 | + | for _, f := range MonospaceFallbackChain() { | |
| 126 | + | if font == f { | |
| 127 | + | isFromChain = true | |
| 128 | + | break | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if !isFromChain { | |
| 132 | + | assert.Equal(t, "monospace", font, "should fall back to generic if no chain fonts available") | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | t.Logf("Selected monospace font: %s", font) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestDetector_SelectBestFont_EmptyChain(t *testing.T) { | |
| 139 | + | ctx := testContext() | |
| 140 | + | detector := NewDetector() | |
| 141 | + | ||
| 142 | + | if !detector.IsAvailable(ctx) { | |
| 143 | + | t.Skip("fc-list not available on this system") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Empty chain should return generic fallback | |
| 147 | + | font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, []string{}) | |
| 148 | + | assert.Equal(t, "sans-serif", font) | |
| 149 | + | ||
| 150 | + | font = detector.SelectBestFont(ctx, port.FontCategorySerif, []string{}) | |
| 151 | + | assert.Equal(t, "serif", font) | |
| 152 | + | ||
| 153 | + | font = detector.SelectBestFont(ctx, port.FontCategoryMonospace, []string{}) | |
| 154 | + | assert.Equal(t, "monospace", font) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestDetector_SelectBestFont_NoMatchingFonts(t *testing.T) { | |
| 158 | + | ctx := testContext() | |
| 159 | + | detector := NewDetector() | |
| 160 | + | ||
| 161 | + | if !detector.IsAvailable(ctx) { | |
| 162 | + | t.Skip("fc-list not available on this system") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Chain with fonts that definitely don't exist | |
| 166 | + | chain := []string{ | |
| 167 | + | "NonExistentFont1234", | |
| 168 | + | "AnotherFakeFont5678", | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, chain) | |
| 172 | + | assert.Equal(t, "sans-serif", font, "should fall back to generic when no fonts match") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestDetector_genericFallback(t *testing.T) { | |
| 176 | + | detector := NewDetector() | |
| 177 | + | ||
| 178 | + | tests := []struct { | |
| 179 | + | category port.FontCategory | |
| 180 | + | expected string | |
| 181 | + | }{ | |
| 182 | + | {port.FontCategorySansSerif, "sans-serif"}, | |
| 183 | + | {port.FontCategorySerif, "serif"}, | |
| 184 | + | {port.FontCategoryMonospace, "monospace"}, | |
| 185 | + | {port.FontCategory("unknown"), "sans-serif"}, // default case | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | for _, tt := range tests { | |
| 189 | + | t.Run(string(tt.category), func(t *testing.T) { | |
| 190 | + | result := detector.genericFallback(tt.category) | |
| 191 | + | assert.Equal(t, tt.expected, result) | |
| 192 | + | }) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestFallbackChains_NotEmpty(t *testing.T) { | |
| 197 | + | assert.NotEmpty(t, SansSerifFallbackChain(), "sans-serif fallback chain should not be empty") | |
| 198 | + | assert.NotEmpty(t, SerifFallbackChain(), "serif fallback chain should not be empty") | |
| 199 | + | assert.NotEmpty(t, MonospaceFallbackChain(), "monospace fallback chain should not be empty") | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestFallbackChains_PreferredFontsFirst(t *testing.T) { | |
| 203 | + | // Verify preferred fonts are first in chain | |
| 204 | + | assert.Equal(t, "Fira Sans", SansSerifFallbackChain()[0], "Fira Sans should be first in sans-serif chain") | |
| 205 | + | assert.Equal(t, "Noto Serif", SerifFallbackChain()[0], "Noto Serif should be first in serif chain") | |
| 206 | + | assert.Equal(t, "Fira Code", MonospaceFallbackChain()[0], "Fira Code should be first in monospace chain") | |
| 207 | + | } | |
| @@ -0,0 +1,207 @@ | |||||
| 1 | + | package fonts | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/rs/zerolog" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func testContext() context.Context { | |||
| 14 | + | logger := zerolog.Nop() | |||
| 15 | + | return logger.WithContext(context.Background()) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestDetector_IsAvailable(t *testing.T) { | |||
| 19 | + | ctx := testContext() | |||
| 20 | + | detector := NewDetector() | |||
| 21 | + | ||||
| 22 | + | // fc-list should be available on most Linux systems | |||
| 23 | + | // This test documents the behavior rather than asserting a specific value | |||
| 24 | + | available := detector.IsAvailable(ctx) | |||
| 25 | + | t.Logf("fc-list available: %v", available) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestDetector_GetAvailableFonts(t *testing.T) { | |||
| 29 | + | ctx := testContext() | |||
| 30 | + | detector := NewDetector() | |||
| 31 | + | ||||
| 32 | + | if !detector.IsAvailable(ctx) { | |||
| 33 | + | t.Skip("fc-list not available on this system") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | fonts, err := detector.GetAvailableFonts(ctx) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | assert.NotEmpty(t, fonts, "expected at least some fonts to be installed") | |||
| 39 | + | ||||
| 40 | + | t.Logf("Found %d font families", len(fonts)) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestDetector_GetAvailableFonts_Caching(t *testing.T) { | |||
| 44 | + | ctx := testContext() | |||
| 45 | + | detector := NewDetector() | |||
| 46 | + | ||||
| 47 | + | if !detector.IsAvailable(ctx) { | |||
| 48 | + | t.Skip("fc-list not available on this system") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // First call populates cache | |||
| 52 | + | fonts1, err := detector.GetAvailableFonts(ctx) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | ||||
| 55 | + | // Second call should return cached result | |||
| 56 | + | fonts2, err := detector.GetAvailableFonts(ctx) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | ||||
| 59 | + | assert.Len(t, fonts2, len(fonts1), "cached result should match original") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestDetector_SelectBestFont_SansSerif(t *testing.T) { | |||
| 63 | + | ctx := testContext() | |||
| 64 | + | detector := NewDetector() | |||
| 65 | + | ||||
| 66 | + | if !detector.IsAvailable(ctx) { | |||
| 67 | + | t.Skip("fc-list not available on this system") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, SansSerifFallbackChain()) | |||
| 71 | + | assert.NotEmpty(t, font) | |||
| 72 | + | ||||
| 73 | + | // Should either be a font from the chain or the generic fallback | |||
| 74 | + | isFromChain := false | |||
| 75 | + | for _, f := range SansSerifFallbackChain() { | |||
| 76 | + | if font == f { | |||
| 77 | + | isFromChain = true | |||
| 78 | + | break | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | if !isFromChain { | |||
| 82 | + | assert.Equal(t, "sans-serif", font, "should fall back to generic if no chain fonts available") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | t.Logf("Selected sans-serif font: %s", font) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestDetector_SelectBestFont_Serif(t *testing.T) { | |||
| 89 | + | ctx := testContext() | |||
| 90 | + | detector := NewDetector() | |||
| 91 | + | ||||
| 92 | + | if !detector.IsAvailable(ctx) { | |||
| 93 | + | t.Skip("fc-list not available on this system") | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | font := detector.SelectBestFont(ctx, port.FontCategorySerif, SerifFallbackChain()) | |||
| 97 | + | assert.NotEmpty(t, font) | |||
| 98 | + | ||||
| 99 | + | isFromChain := false | |||
| 100 | + | for _, f := range SerifFallbackChain() { | |||
| 101 | + | if font == f { | |||
| 102 | + | isFromChain = true | |||
| 103 | + | break | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if !isFromChain { | |||
| 107 | + | assert.Equal(t, "serif", font, "should fall back to generic if no chain fonts available") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | t.Logf("Selected serif font: %s", font) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestDetector_SelectBestFont_Monospace(t *testing.T) { | |||
| 114 | + | ctx := testContext() | |||
| 115 | + | detector := NewDetector() | |||
| 116 | + | ||||
| 117 | + | if !detector.IsAvailable(ctx) { | |||
| 118 | + | t.Skip("fc-list not available on this system") | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | font := detector.SelectBestFont(ctx, port.FontCategoryMonospace, MonospaceFallbackChain()) | |||
| 122 | + | assert.NotEmpty(t, font) | |||
| 123 | + | ||||
| 124 | + | isFromChain := false | |||
| 125 | + | for _, f := range MonospaceFallbackChain() { | |||
| 126 | + | if font == f { | |||
| 127 | + | isFromChain = true | |||
| 128 | + | break | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | if !isFromChain { | |||
| 132 | + | assert.Equal(t, "monospace", font, "should fall back to generic if no chain fonts available") | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | t.Logf("Selected monospace font: %s", font) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestDetector_SelectBestFont_EmptyChain(t *testing.T) { | |||
| 139 | + | ctx := testContext() | |||
| 140 | + | detector := NewDetector() | |||
| 141 | + | ||||
| 142 | + | if !detector.IsAvailable(ctx) { | |||
| 143 | + | t.Skip("fc-list not available on this system") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // Empty chain should return generic fallback | |||
| 147 | + | font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, []string{}) | |||
| 148 | + | assert.Equal(t, "sans-serif", font) | |||
| 149 | + | ||||
| 150 | + | font = detector.SelectBestFont(ctx, port.FontCategorySerif, []string{}) | |||
| 151 | + | assert.Equal(t, "serif", font) | |||
| 152 | + | ||||
| 153 | + | font = detector.SelectBestFont(ctx, port.FontCategoryMonospace, []string{}) | |||
| 154 | + | assert.Equal(t, "monospace", font) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestDetector_SelectBestFont_NoMatchingFonts(t *testing.T) { | |||
| 158 | + | ctx := testContext() | |||
| 159 | + | detector := NewDetector() | |||
| 160 | + | ||||
| 161 | + | if !detector.IsAvailable(ctx) { | |||
| 162 | + | t.Skip("fc-list not available on this system") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Chain with fonts that definitely don't exist | |||
| 166 | + | chain := []string{ | |||
| 167 | + | "NonExistentFont1234", | |||
| 168 | + | "AnotherFakeFont5678", | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, chain) | |||
| 172 | + | assert.Equal(t, "sans-serif", font, "should fall back to generic when no fonts match") | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestDetector_genericFallback(t *testing.T) { | |||
| 176 | + | detector := NewDetector() | |||
| 177 | + | ||||
| 178 | + | tests := []struct { | |||
| 179 | + | category port.FontCategory | |||
| 180 | + | expected string | |||
| 181 | + | }{ | |||
| 182 | + | {port.FontCategorySansSerif, "sans-serif"}, | |||
| 183 | + | {port.FontCategorySerif, "serif"}, | |||
| 184 | + | {port.FontCategoryMonospace, "monospace"}, | |||
| 185 | + | {port.FontCategory("unknown"), "sans-serif"}, // default case | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | for _, tt := range tests { | |||
| 189 | + | t.Run(string(tt.category), func(t *testing.T) { | |||
| 190 | + | result := detector.genericFallback(tt.category) | |||
| 191 | + | assert.Equal(t, tt.expected, result) | |||
| 192 | + | }) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestFallbackChains_NotEmpty(t *testing.T) { | |||
| 197 | + | assert.NotEmpty(t, SansSerifFallbackChain(), "sans-serif fallback chain should not be empty") | |||
| 198 | + | assert.NotEmpty(t, SerifFallbackChain(), "serif fallback chain should not be empty") | |||
| 199 | + | assert.NotEmpty(t, MonospaceFallbackChain(), "monospace fallback chain should not be empty") | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestFallbackChains_PreferredFontsFirst(t *testing.T) { | |||
| 203 | + | // Verify preferred fonts are first in chain | |||
| 204 | + | assert.Equal(t, "Fira Sans", SansSerifFallbackChain()[0], "Fira Sans should be first in sans-serif chain") | |||
| 205 | + | assert.Equal(t, "Noto Serif", SerifFallbackChain()[0], "Noto Serif should be first in serif chain") | |||
| 206 | + | assert.Equal(t, "Fira Code", MonospaceFallbackChain()[0], "Fira Code should be first in monospace chain") | |||
| 207 | + | } | |||
M internal/infrastructure/persistence/sqlite/history_repo.go +77 -15
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "database/sql" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "strings" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -47,31 +48,92 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||
| 47 | 48 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 48 | 49 | log := logging.FromContext(ctx) | |
| 49 | 50 | ||
| 50 | - | // Use FTS5 full-text search for better accuracy | |
| 51 | - | // FTS5 query syntax: use * for prefix matching | |
| 52 | - | ftsQuery := query + "*" | |
| 51 | + | // Split query into words and add prefix matching to each | |
| 52 | + | // This enables multi-word searches like "github issues" -> "github* issues*" | |
| 53 | + | words := strings.Fields(query) | |
| 54 | + | if len(words) == 0 { | |
| 55 | + | return []entity.HistoryMatch{}, nil | |
| 56 | + | } | |
| 53 | 57 | ||
| 54 | - | rows, err := r.queries.SearchHistoryFTS(ctx, sqlc.SearchHistoryFTSParams{ | |
| 55 | - | Url: ftsQuery, | |
| 58 | + | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | |
| 59 | + | // Sanitize each word to remove FTS5 special characters | |
| 60 | + | parts := make([]string, len(words)) | |
| 61 | + | for i, word := range words { | |
| 62 | + | parts[i] = sanitizeFTS5Word(word) + "*" | |
| 63 | + | } | |
| 64 | + | ftsQuery := strings.Join(parts, " ") | |
| 65 | + | ||
| 66 | + | // Search both URL and title columns, then merge results | |
| 67 | + | urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{ | |
| 68 | + | Query: ftsQuery, | |
| 56 | 69 | Limit: int64(limit), | |
| 57 | 70 | }) | |
| 58 | - | if err != nil { | |
| 59 | - | log.Debug().Err(err).Str("query", query).Msg("FTS search failed, no results") | |
| 60 | - | // FTS5 returns error on no match or invalid query, return empty results | |
| 61 | - | return []entity.HistoryMatch{}, nil | |
| 71 | + | if urlErr != nil { | |
| 72 | + | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") | |
| 73 | + | urlRows = []sqlc.History{} | |
| 62 | 74 | } | |
| 63 | 75 | ||
| 64 | - | matches := make([]entity.HistoryMatch, len(rows)) | |
| 65 | - | for i := range rows { | |
| 66 | - | row := rows[i] | |
| 67 | - | matches[i] = entity.HistoryMatch{ | |
| 68 | - | Entry: historyFromRow(row), | |
| 69 | - | Score: 1.0, // FTS5 already ranked by bm25 | |
| 76 | + | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ | |
| 77 | + | Query: ftsQuery, | |
| 78 | + | Limit: int64(limit), | |
| 79 | + | }) | |
| 80 | + | if titleErr != nil { | |
| 81 | + | log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed") | |
| 82 | + | titleRows = []sqlc.History{} | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Merge results by interleaving URL and title matches for balanced results | |
| 86 | + | seen := make(map[int64]bool) | |
| 87 | + | var matches []entity.HistoryMatch | |
| 88 | + | ||
| 89 | + | i, j := 0, 0 | |
| 90 | + | for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) { | |
| 91 | + | // Take next URL match if available | |
| 92 | + | if i < len(urlRows) { | |
| 93 | + | row := urlRows[i] | |
| 94 | + | i++ | |
| 95 | + | if !seen[row.ID] { | |
| 96 | + | seen[row.ID] = true | |
| 97 | + | matches = append(matches, entity.HistoryMatch{ | |
| 98 | + | Entry: historyFromRow(row), | |
| 99 | + | Score: 1.0, | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Take next title match if available | |
| 105 | + | if j < len(titleRows) && len(matches) < limit { | |
| 106 | + | row := titleRows[j] | |
| 107 | + | j++ | |
| 108 | + | if !seen[row.ID] { | |
| 109 | + | seen[row.ID] = true | |
| 110 | + | matches = append(matches, entity.HistoryMatch{ | |
| 111 | + | Entry: historyFromRow(row), | |
| 112 | + | Score: 1.0, | |
| 113 | + | }) | |
| 114 | + | } | |
| 70 | 115 | } | |
| 71 | 116 | } | |
| 117 | + | ||
| 72 | 118 | return matches, nil | |
| 73 | 119 | } | |
| 74 | 120 | ||
| 121 | + | // sanitizeFTS5Word removes FTS5 special characters from a search word. | |
| 122 | + | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |
| 123 | + | func sanitizeFTS5Word(word string) string { | |
| 124 | + | // Remove characters that have special meaning in FTS5 | |
| 125 | + | var result strings.Builder | |
| 126 | + | for _, r := range word { | |
| 127 | + | switch r { | |
| 128 | + | case '"', '*', '(', ')', ':', '^', '-': | |
| 129 | + | // Skip FTS5 special characters | |
| 130 | + | default: | |
| 131 | + | result.WriteRune(r) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return result.String() | |
| 135 | + | } | |
| 136 | + | ||
| 75 | 137 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 76 | 138 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 77 | 139 | Limit: int64(limit), | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "strings" | |||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -47,31 +48,92 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||||
| 47 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 48 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 48 | log := logging.FromContext(ctx) | 49 | log := logging.FromContext(ctx) | ||
| 49 | 50 | ||||
| 50 | - | // Use FTS5 full-text search for better accuracy | 51 | + | // Split query into words and add prefix matching to each |
| 51 | - | // FTS5 query syntax: use * for prefix matching | 52 | + | // This enables multi-word searches like "github issues" -> "github* issues*" |
| 52 | - | ftsQuery := query + "*" | 53 | + | words := strings.Fields(query) |
| 54 | + | if len(words) == 0 { | |||
| 55 | + | return []entity.HistoryMatch{}, nil | |||
| 56 | + | } | |||
| 53 | 57 | ||||
| 54 | - | rows, err := r.queries.SearchHistoryFTS(ctx, sqlc.SearchHistoryFTSParams{ | 58 | + | // Build FTS5 query: "word1* word2*" (implicit AND between terms) |
| 55 | - | Url: ftsQuery, | 59 | + | // Sanitize each word to remove FTS5 special characters |
| 60 | + | parts := make([]string, len(words)) | |||
| 61 | + | for i, word := range words { | |||
| 62 | + | parts[i] = sanitizeFTS5Word(word) + "*" | |||
| 63 | + | } | |||
| 64 | + | ftsQuery := strings.Join(parts, " ") | |||
| 65 | + | ||||
| 66 | + | // Search both URL and title columns, then merge results | |||
| 67 | + | urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{ | |||
| 68 | + | Query: ftsQuery, | |||
| 56 | Limit: int64(limit), | 69 | Limit: int64(limit), | ||
| 57 | }) | 70 | }) | ||
| 58 | - | if err != nil { | 71 | + | if urlErr != nil { |
| 59 | - | log.Debug().Err(err).Str("query", query).Msg("FTS search failed, no results") | 72 | + | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") |
| 60 | - | // FTS5 returns error on no match or invalid query, return empty results | 73 | + | urlRows = []sqlc.History{} |
| 61 | - | return []entity.HistoryMatch{}, nil | |||
| 62 | } | 74 | } | ||
| 63 | 75 | ||||
| 64 | - | matches := make([]entity.HistoryMatch, len(rows)) | 76 | + | titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{ |
| 65 | - | for i := range rows { | 77 | + | Query: ftsQuery, |
| 66 | - | row := rows[i] | 78 | + | Limit: int64(limit), |
| 67 | - | matches[i] = entity.HistoryMatch{ | 79 | + | }) |
| 68 | - | Entry: historyFromRow(row), | 80 | + | if titleErr != nil { |
| 69 | - | Score: 1.0, // FTS5 already ranked by bm25 | 81 | + | log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed") |
| 82 | + | titleRows = []sqlc.History{} | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Merge results by interleaving URL and title matches for balanced results | |||
| 86 | + | seen := make(map[int64]bool) | |||
| 87 | + | var matches []entity.HistoryMatch | |||
| 88 | + | ||||
| 89 | + | i, j := 0, 0 | |||
| 90 | + | for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) { | |||
| 91 | + | // Take next URL match if available | |||
| 92 | + | if i < len(urlRows) { | |||
| 93 | + | row := urlRows[i] | |||
| 94 | + | i++ | |||
| 95 | + | if !seen[row.ID] { | |||
| 96 | + | seen[row.ID] = true | |||
| 97 | + | matches = append(matches, entity.HistoryMatch{ | |||
| 98 | + | Entry: historyFromRow(row), | |||
| 99 | + | Score: 1.0, | |||
| 100 | + | }) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Take next title match if available | |||
| 105 | + | if j < len(titleRows) && len(matches) < limit { | |||
| 106 | + | row := titleRows[j] | |||
| 107 | + | j++ | |||
| 108 | + | if !seen[row.ID] { | |||
| 109 | + | seen[row.ID] = true | |||
| 110 | + | matches = append(matches, entity.HistoryMatch{ | |||
| 111 | + | Entry: historyFromRow(row), | |||
| 112 | + | Score: 1.0, | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 70 | } | 115 | } | ||
| 71 | } | 116 | } | ||
| 117 | + | ||||
| 72 | return matches, nil | 118 | return matches, nil | ||
| 73 | } | 119 | } | ||
| 74 | 120 | ||||
| 121 | + | // sanitizeFTS5Word removes FTS5 special characters from a search word. | |||
| 122 | + | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |||
| 123 | + | func sanitizeFTS5Word(word string) string { | |||
| 124 | + | // Remove characters that have special meaning in FTS5 | |||
| 125 | + | var result strings.Builder | |||
| 126 | + | for _, r := range word { | |||
| 127 | + | switch r { | |||
| 128 | + | case '"', '*', '(', ')', ':', '^', '-': | |||
| 129 | + | // Skip FTS5 special characters | |||
| 130 | + | default: | |||
| 131 | + | result.WriteRune(r) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return result.String() | |||
| 135 | + | } | |||
| 136 | + | ||||
| 75 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 137 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 76 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | 138 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | ||
| 77 | Limit: int64(limit), | 139 | Limit: int64(limit), | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +161 -0
| @@ -323,3 +323,164 @@ func TestHistoryRepository_GetAllMostVisited(t *testing.T) { | |||
| 323 | 323 | assert.Equal(t, "https://github.com", results[0].URL) | |
| 324 | 324 | assert.Equal(t, int64(4), results[0].VisitCount) | |
| 325 | 325 | } | |
| 326 | + | ||
| 327 | + | func TestHistoryRepository_Search_SingleWord(t *testing.T) { | |
| 328 | + | ctx := historyTestCtx() | |
| 329 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 330 | + | ||
| 331 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 332 | + | require.NoError(t, err) | |
| 333 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 334 | + | ||
| 335 | + | repo := sqlite.NewHistoryRepository(db) | |
| 336 | + | ||
| 337 | + | // Save entries | |
| 338 | + | entries := []*entity.HistoryEntry{ | |
| 339 | + | {URL: "https://github.com/user/repo", Title: "My GitHub Repository"}, | |
| 340 | + | {URL: "https://gitlab.com/project", Title: "GitLab Project"}, | |
| 341 | + | {URL: "https://example.com", Title: "Example Site"}, | |
| 342 | + | } | |
| 343 | + | for _, e := range entries { | |
| 344 | + | require.NoError(t, repo.Save(ctx, e)) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // Search for "github" - should find the github entry | |
| 348 | + | results, err := repo.Search(ctx, "github", 10) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | require.Len(t, results, 1) | |
| 351 | + | assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestHistoryRepository_Search_MultiWord(t *testing.T) { | |
| 355 | + | ctx := historyTestCtx() | |
| 356 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 357 | + | ||
| 358 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 361 | + | ||
| 362 | + | repo := sqlite.NewHistoryRepository(db) | |
| 363 | + | ||
| 364 | + | // Save entries | |
| 365 | + | entries := []*entity.HistoryEntry{ | |
| 366 | + | {URL: "https://github.com/issues", Title: "GitHub Issues"}, | |
| 367 | + | {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"}, | |
| 368 | + | {URL: "https://jira.com/issues", Title: "Jira Issues"}, | |
| 369 | + | } | |
| 370 | + | for _, e := range entries { | |
| 371 | + | require.NoError(t, repo.Save(ctx, e)) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Search for "github issues" - should find only the github issues entry | |
| 375 | + | results, err := repo.Search(ctx, "github issues", 10) | |
| 376 | + | require.NoError(t, err) | |
| 377 | + | require.Len(t, results, 1) | |
| 378 | + | assert.Equal(t, "https://github.com/issues", results[0].Entry.URL) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestHistoryRepository_Search_MatchesTitle(t *testing.T) { | |
| 382 | + | ctx := historyTestCtx() | |
| 383 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 384 | + | ||
| 385 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 388 | + | ||
| 389 | + | repo := sqlite.NewHistoryRepository(db) | |
| 390 | + | ||
| 391 | + | // Save entry with specific title that doesn't appear in URL | |
| 392 | + | entry := &entity.HistoryEntry{ | |
| 393 | + | URL: "https://docs.example.com/guide", | |
| 394 | + | Title: "Comprehensive Documentation Guide", | |
| 395 | + | } | |
| 396 | + | require.NoError(t, repo.Save(ctx, entry)) | |
| 397 | + | ||
| 398 | + | // Search for "Documentation" - should find by title | |
| 399 | + | results, err := repo.Search(ctx, "Documentation", 10) | |
| 400 | + | require.NoError(t, err) | |
| 401 | + | require.Len(t, results, 1) | |
| 402 | + | assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func TestHistoryRepository_Search_EmptyQuery(t *testing.T) { | |
| 406 | + | ctx := historyTestCtx() | |
| 407 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 408 | + | ||
| 409 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 410 | + | require.NoError(t, err) | |
| 411 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 412 | + | ||
| 413 | + | repo := sqlite.NewHistoryRepository(db) | |
| 414 | + | ||
| 415 | + | // Save an entry | |
| 416 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 417 | + | URL: "https://example.com", | |
| 418 | + | Title: "Example", | |
| 419 | + | })) | |
| 420 | + | ||
| 421 | + | // Empty query should return empty results, no error | |
| 422 | + | results, err := repo.Search(ctx, "", 10) | |
| 423 | + | require.NoError(t, err) | |
| 424 | + | assert.Empty(t, results) | |
| 425 | + | ||
| 426 | + | // Whitespace-only query should also return empty results | |
| 427 | + | results, err = repo.Search(ctx, " ", 10) | |
| 428 | + | require.NoError(t, err) | |
| 429 | + | assert.Empty(t, results) | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func TestHistoryRepository_Search_NoResults(t *testing.T) { | |
| 433 | + | ctx := historyTestCtx() | |
| 434 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 435 | + | ||
| 436 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 437 | + | require.NoError(t, err) | |
| 438 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 439 | + | ||
| 440 | + | repo := sqlite.NewHistoryRepository(db) | |
| 441 | + | ||
| 442 | + | // Save an entry | |
| 443 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |
| 444 | + | URL: "https://example.com", | |
| 445 | + | Title: "Example", | |
| 446 | + | })) | |
| 447 | + | ||
| 448 | + | // Search for something that doesn't exist | |
| 449 | + | results, err := repo.Search(ctx, "nonexistent", 10) | |
| 450 | + | require.NoError(t, err) | |
| 451 | + | assert.Empty(t, results) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |
| 455 | + | ctx := historyTestCtx() | |
| 456 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 457 | + | ||
| 458 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 459 | + | require.NoError(t, err) | |
| 460 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 461 | + | ||
| 462 | + | repo := sqlite.NewHistoryRepository(db) | |
| 463 | + | ||
| 464 | + | // Save entries | |
| 465 | + | entries := []*entity.HistoryEntry{ | |
| 466 | + | {URL: "https://github.com", Title: "GitHub"}, | |
| 467 | + | {URL: "https://gitlab.com", Title: "GitLab"}, | |
| 468 | + | {URL: "https://example.com", Title: "Example"}, | |
| 469 | + | } | |
| 470 | + | for _, e := range entries { | |
| 471 | + | require.NoError(t, repo.Save(ctx, e)) | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // Search for "git" - should find both github and gitlab (prefix match) | |
| 475 | + | results, err := repo.Search(ctx, "git", 10) | |
| 476 | + | require.NoError(t, err) | |
| 477 | + | require.Len(t, results, 2) | |
| 478 | + | ||
| 479 | + | // Verify both git* entries are found | |
| 480 | + | urls := make(map[string]bool) | |
| 481 | + | for _, r := range results { | |
| 482 | + | urls[r.Entry.URL] = true | |
| 483 | + | } | |
| 484 | + | assert.True(t, urls["https://github.com"]) | |
| 485 | + | assert.True(t, urls["https://gitlab.com"]) | |
| 486 | + | } | |
| @@ -323,3 +323,164 @@ func TestHistoryRepository_GetAllMostVisited(t *testing.T) { | |||||
| 323 | assert.Equal(t, "https://github.com", results[0].URL) | 323 | assert.Equal(t, "https://github.com", results[0].URL) | ||
| 324 | assert.Equal(t, int64(4), results[0].VisitCount) | 324 | assert.Equal(t, int64(4), results[0].VisitCount) | ||
| 325 | } | 325 | } | ||
| 326 | + | ||||
| 327 | + | func TestHistoryRepository_Search_SingleWord(t *testing.T) { | |||
| 328 | + | ctx := historyTestCtx() | |||
| 329 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 330 | + | ||||
| 331 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 332 | + | require.NoError(t, err) | |||
| 333 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 334 | + | ||||
| 335 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 336 | + | ||||
| 337 | + | // Save entries | |||
| 338 | + | entries := []*entity.HistoryEntry{ | |||
| 339 | + | {URL: "https://github.com/user/repo", Title: "My GitHub Repository"}, | |||
| 340 | + | {URL: "https://gitlab.com/project", Title: "GitLab Project"}, | |||
| 341 | + | {URL: "https://example.com", Title: "Example Site"}, | |||
| 342 | + | } | |||
| 343 | + | for _, e := range entries { | |||
| 344 | + | require.NoError(t, repo.Save(ctx, e)) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // Search for "github" - should find the github entry | |||
| 348 | + | results, err := repo.Search(ctx, "github", 10) | |||
| 349 | + | require.NoError(t, err) | |||
| 350 | + | require.Len(t, results, 1) | |||
| 351 | + | assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestHistoryRepository_Search_MultiWord(t *testing.T) { | |||
| 355 | + | ctx := historyTestCtx() | |||
| 356 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 357 | + | ||||
| 358 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 359 | + | require.NoError(t, err) | |||
| 360 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 361 | + | ||||
| 362 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 363 | + | ||||
| 364 | + | // Save entries | |||
| 365 | + | entries := []*entity.HistoryEntry{ | |||
| 366 | + | {URL: "https://github.com/issues", Title: "GitHub Issues"}, | |||
| 367 | + | {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"}, | |||
| 368 | + | {URL: "https://jira.com/issues", Title: "Jira Issues"}, | |||
| 369 | + | } | |||
| 370 | + | for _, e := range entries { | |||
| 371 | + | require.NoError(t, repo.Save(ctx, e)) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // Search for "github issues" - should find only the github issues entry | |||
| 375 | + | results, err := repo.Search(ctx, "github issues", 10) | |||
| 376 | + | require.NoError(t, err) | |||
| 377 | + | require.Len(t, results, 1) | |||
| 378 | + | assert.Equal(t, "https://github.com/issues", results[0].Entry.URL) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func TestHistoryRepository_Search_MatchesTitle(t *testing.T) { | |||
| 382 | + | ctx := historyTestCtx() | |||
| 383 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 384 | + | ||||
| 385 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 386 | + | require.NoError(t, err) | |||
| 387 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 388 | + | ||||
| 389 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 390 | + | ||||
| 391 | + | // Save entry with specific title that doesn't appear in URL | |||
| 392 | + | entry := &entity.HistoryEntry{ | |||
| 393 | + | URL: "https://docs.example.com/guide", | |||
| 394 | + | Title: "Comprehensive Documentation Guide", | |||
| 395 | + | } | |||
| 396 | + | require.NoError(t, repo.Save(ctx, entry)) | |||
| 397 | + | ||||
| 398 | + | // Search for "Documentation" - should find by title | |||
| 399 | + | results, err := repo.Search(ctx, "Documentation", 10) | |||
| 400 | + | require.NoError(t, err) | |||
| 401 | + | require.Len(t, results, 1) | |||
| 402 | + | assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func TestHistoryRepository_Search_EmptyQuery(t *testing.T) { | |||
| 406 | + | ctx := historyTestCtx() | |||
| 407 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 408 | + | ||||
| 409 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 410 | + | require.NoError(t, err) | |||
| 411 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 412 | + | ||||
| 413 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 414 | + | ||||
| 415 | + | // Save an entry | |||
| 416 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 417 | + | URL: "https://example.com", | |||
| 418 | + | Title: "Example", | |||
| 419 | + | })) | |||
| 420 | + | ||||
| 421 | + | // Empty query should return empty results, no error | |||
| 422 | + | results, err := repo.Search(ctx, "", 10) | |||
| 423 | + | require.NoError(t, err) | |||
| 424 | + | assert.Empty(t, results) | |||
| 425 | + | ||||
| 426 | + | // Whitespace-only query should also return empty results | |||
| 427 | + | results, err = repo.Search(ctx, " ", 10) | |||
| 428 | + | require.NoError(t, err) | |||
| 429 | + | assert.Empty(t, results) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestHistoryRepository_Search_NoResults(t *testing.T) { | |||
| 433 | + | ctx := historyTestCtx() | |||
| 434 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 435 | + | ||||
| 436 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 437 | + | require.NoError(t, err) | |||
| 438 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 439 | + | ||||
| 440 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 441 | + | ||||
| 442 | + | // Save an entry | |||
| 443 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 444 | + | URL: "https://example.com", | |||
| 445 | + | Title: "Example", | |||
| 446 | + | })) | |||
| 447 | + | ||||
| 448 | + | // Search for something that doesn't exist | |||
| 449 | + | results, err := repo.Search(ctx, "nonexistent", 10) | |||
| 450 | + | require.NoError(t, err) | |||
| 451 | + | assert.Empty(t, results) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func TestHistoryRepository_Search_PrefixMatching(t *testing.T) { | |||
| 455 | + | ctx := historyTestCtx() | |||
| 456 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 457 | + | ||||
| 458 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 459 | + | require.NoError(t, err) | |||
| 460 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 461 | + | ||||
| 462 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 463 | + | ||||
| 464 | + | // Save entries | |||
| 465 | + | entries := []*entity.HistoryEntry{ | |||
| 466 | + | {URL: "https://github.com", Title: "GitHub"}, | |||
| 467 | + | {URL: "https://gitlab.com", Title: "GitLab"}, | |||
| 468 | + | {URL: "https://example.com", Title: "Example"}, | |||
| 469 | + | } | |||
| 470 | + | for _, e := range entries { | |||
| 471 | + | require.NoError(t, repo.Save(ctx, e)) | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | // Search for "git" - should find both github and gitlab (prefix match) | |||
| 475 | + | results, err := repo.Search(ctx, "git", 10) | |||
| 476 | + | require.NoError(t, err) | |||
| 477 | + | require.Len(t, results, 2) | |||
| 478 | + | ||||
| 479 | + | // Verify both git* entries are found | |||
| 480 | + | urls := make(map[string]bool) | |||
| 481 | + | for _, r := range results { | |||
| 482 | + | urls[r.Entry.URL] = true | |||
| 483 | + | } | |||
| 484 | + | assert.True(t, urls["https://github.com"]) | |||
| 485 | + | assert.True(t, urls["https://gitlab.com"]) | |||
| 486 | + | } | |||
M internal/infrastructure/persistence/sqlite/queries/history.sql +12 -4
| @@ -49,13 +49,21 @@ SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visit | |||
| 49 | 49 | -- name: DeleteHistoryByDomain :exec | |
| 50 | 50 | DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?; | |
| 51 | 51 | ||
| 52 | - | -- name: SearchHistoryFTS :many | |
| 52 | + | -- name: SearchHistoryFTSUrl :many | |
| 53 | 53 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 54 | 54 | FROM history_fts fts | |
| 55 | 55 | JOIN history h ON fts.rowid = h.id | |
| 56 | - | WHERE fts.url MATCH ? | |
| 57 | - | ORDER BY bm25(history_fts) | |
| 58 | - | LIMIT ?; | |
| 56 | + | WHERE fts.url MATCH @query | |
| 57 | + | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 58 | + | LIMIT @limit; | |
| 59 | + | ||
| 60 | + | -- name: SearchHistoryFTSTitle :many | |
| 61 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 62 | + | FROM history_fts fts | |
| 63 | + | JOIN history h ON fts.rowid = h.id | |
| 64 | + | WHERE fts.title MATCH @query | |
| 65 | + | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 66 | + | LIMIT @limit; | |
| 59 | 67 | ||
| 60 | 68 | -- name: GetRecentHistorySince :many | |
| 61 | 69 | SELECT * FROM history | |
| @@ -49,13 +49,21 @@ SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visit | |||||
| 49 | -- name: DeleteHistoryByDomain :exec | 49 | -- name: DeleteHistoryByDomain :exec | ||
| 50 | DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?; | 50 | DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?; | ||
| 51 | 51 | ||||
| 52 | - | -- name: SearchHistoryFTS :many | 52 | + | -- name: SearchHistoryFTSUrl :many |
| 53 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 53 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | ||
| 54 | FROM history_fts fts | 54 | FROM history_fts fts | ||
| 55 | JOIN history h ON fts.rowid = h.id | 55 | JOIN history h ON fts.rowid = h.id | ||
| 56 | - | WHERE fts.url MATCH ? | 56 | + | WHERE fts.url MATCH @query |
| 57 | - | ORDER BY bm25(history_fts) | 57 | + | ORDER BY h.visit_count DESC, h.last_visited DESC |
| 58 | - | LIMIT ?; | 58 | + | LIMIT @limit; |
| 59 | + | ||||
| 60 | + | -- name: SearchHistoryFTSTitle :many | |||
| 61 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |||
| 62 | + | FROM history_fts fts | |||
| 63 | + | JOIN history h ON fts.rowid = h.id | |||
| 64 | + | WHERE fts.title MATCH @query | |||
| 65 | + | ORDER BY h.visit_count DESC, h.last_visited DESC | |||
| 66 | + | LIMIT @limit; | |||
| 59 | 67 | ||||
| 60 | -- name: GetRecentHistorySince :many | 68 | -- name: GetRecentHistorySince :many | ||
| 61 | SELECT * FROM history | 69 | SELECT * FROM history | ||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +53 -8
| @@ -439,22 +439,67 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||
| 439 | 439 | return items, nil | |
| 440 | 440 | } | |
| 441 | 441 | ||
| 442 | - | const SearchHistoryFTS = `-- name: SearchHistoryFTS :many | |
| 442 | + | const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many | |
| 443 | 443 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 444 | 444 | FROM history_fts fts | |
| 445 | 445 | JOIN history h ON fts.rowid = h.id | |
| 446 | - | WHERE fts.url MATCH ? | |
| 447 | - | ORDER BY bm25(history_fts) | |
| 448 | - | LIMIT ? | |
| 446 | + | WHERE fts.title MATCH ?1 | |
| 447 | + | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 448 | + | LIMIT ?2 | |
| 449 | + | ` | |
| 450 | + | ||
| 451 | + | type SearchHistoryFTSTitleParams struct { | |
| 452 | + | Query string `json:"query"` | |
| 453 | + | Limit int64 `json:"limit"` | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) { | |
| 457 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit) | |
| 458 | + | if err != nil { | |
| 459 | + | return nil, err | |
| 460 | + | } | |
| 461 | + | defer rows.Close() | |
| 462 | + | items := []History{} | |
| 463 | + | for rows.Next() { | |
| 464 | + | var i History | |
| 465 | + | if err := rows.Scan( | |
| 466 | + | &i.ID, | |
| 467 | + | &i.Url, | |
| 468 | + | &i.Title, | |
| 469 | + | &i.FaviconUrl, | |
| 470 | + | &i.VisitCount, | |
| 471 | + | &i.LastVisited, | |
| 472 | + | &i.CreatedAt, | |
| 473 | + | ); err != nil { | |
| 474 | + | return nil, err | |
| 475 | + | } | |
| 476 | + | items = append(items, i) | |
| 477 | + | } | |
| 478 | + | if err := rows.Close(); err != nil { | |
| 479 | + | return nil, err | |
| 480 | + | } | |
| 481 | + | if err := rows.Err(); err != nil { | |
| 482 | + | return nil, err | |
| 483 | + | } | |
| 484 | + | return items, nil | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many | |
| 488 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 489 | + | FROM history_fts fts | |
| 490 | + | JOIN history h ON fts.rowid = h.id | |
| 491 | + | WHERE fts.url MATCH ?1 | |
| 492 | + | ORDER BY h.visit_count DESC, h.last_visited DESC | |
| 493 | + | LIMIT ?2 | |
| 449 | 494 | ` | |
| 450 | 495 | ||
| 451 | - | type SearchHistoryFTSParams struct { | |
| 452 | - | Url string `json:"url"` | |
| 496 | + | type SearchHistoryFTSUrlParams struct { | |
| 497 | + | Query string `json:"query"` | |
| 453 | 498 | Limit int64 `json:"limit"` | |
| 454 | 499 | } | |
| 455 | 500 | ||
| 456 | - | func (q *Queries) SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) { | |
| 457 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTS, arg.Url, arg.Limit) | |
| 501 | + | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) { | |
| 502 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) | |
| 458 | 503 | if err != nil { | |
| 459 | 504 | return nil, err | |
| 460 | 505 | } | |
| @@ -439,22 +439,67 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||||
| 439 | return items, nil | 439 | return items, nil | ||
| 440 | } | 440 | } | ||
| 441 | 441 | ||||
| 442 | - | const SearchHistoryFTS = `-- name: SearchHistoryFTS :many | 442 | + | const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many |
| 443 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 443 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | ||
| 444 | FROM history_fts fts | 444 | FROM history_fts fts | ||
| 445 | JOIN history h ON fts.rowid = h.id | 445 | JOIN history h ON fts.rowid = h.id | ||
| 446 | - | WHERE fts.url MATCH ? | 446 | + | WHERE fts.title MATCH ?1 |
| 447 | - | ORDER BY bm25(history_fts) | 447 | + | ORDER BY h.visit_count DESC, h.last_visited DESC |
| 448 | - | LIMIT ? | 448 | + | LIMIT ?2 |
| 449 | + | ` | |||
| 450 | + | ||||
| 451 | + | type SearchHistoryFTSTitleParams struct { | |||
| 452 | + | Query string `json:"query"` | |||
| 453 | + | Limit int64 `json:"limit"` | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) { | |||
| 457 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit) | |||
| 458 | + | if err != nil { | |||
| 459 | + | return nil, err | |||
| 460 | + | } | |||
| 461 | + | defer rows.Close() | |||
| 462 | + | items := []History{} | |||
| 463 | + | for rows.Next() { | |||
| 464 | + | var i History | |||
| 465 | + | if err := rows.Scan( | |||
| 466 | + | &i.ID, | |||
| 467 | + | &i.Url, | |||
| 468 | + | &i.Title, | |||
| 469 | + | &i.FaviconUrl, | |||
| 470 | + | &i.VisitCount, | |||
| 471 | + | &i.LastVisited, | |||
| 472 | + | &i.CreatedAt, | |||
| 473 | + | ); err != nil { | |||
| 474 | + | return nil, err | |||
| 475 | + | } | |||
| 476 | + | items = append(items, i) | |||
| 477 | + | } | |||
| 478 | + | if err := rows.Close(); err != nil { | |||
| 479 | + | return nil, err | |||
| 480 | + | } | |||
| 481 | + | if err := rows.Err(); err != nil { | |||
| 482 | + | return nil, err | |||
| 483 | + | } | |||
| 484 | + | return items, nil | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many | |||
| 488 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |||
| 489 | + | FROM history_fts fts | |||
| 490 | + | JOIN history h ON fts.rowid = h.id | |||
| 491 | + | WHERE fts.url MATCH ?1 | |||
| 492 | + | ORDER BY h.visit_count DESC, h.last_visited DESC | |||
| 493 | + | LIMIT ?2 | |||
| 449 | ` | 494 | ` | ||
| 450 | 495 | ||||
| 451 | - | type SearchHistoryFTSParams struct { | 496 | + | type SearchHistoryFTSUrlParams struct { |
| 452 | - | Url string `json:"url"` | 497 | + | Query string `json:"query"` |
| 453 | Limit int64 `json:"limit"` | 498 | Limit int64 `json:"limit"` | ||
| 454 | } | 499 | } | ||
| 455 | 500 | ||||
| 456 | - | func (q *Queries) SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) { | 501 | + | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) { |
| 457 | - | rows, err := q.db.QueryContext(ctx, SearchHistoryFTS, arg.Url, arg.Limit) | 502 | + | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) |
| 458 | if err != nil { | 503 | if err != nil { | ||
| 459 | return nil, err | 504 | return nil, err | ||
| 460 | } | 505 | } | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
| @@ -70,7 +70,8 @@ type Querier interface { | |||
| 70 | 70 | RemoveFromWhitelist(ctx context.Context, domain string) error | |
| 71 | 71 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | |
| 72 | 72 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | |
| 73 | - | SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) | |
| 73 | + | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | |
| 74 | + | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | |
| 74 | 75 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | |
| 75 | 76 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | |
| 76 | 77 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | |
| @@ -70,7 +70,8 @@ type Querier interface { | |||||
| 70 | RemoveFromWhitelist(ctx context.Context, domain string) error | 70 | RemoveFromWhitelist(ctx context.Context, domain string) error | ||
| 71 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | 71 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | ||
| 72 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | 72 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | ||
| 73 | - | SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) | 73 | + | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) |
| 74 | + | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | |||
| 74 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | 75 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | ||
| 75 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | 76 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | ||
| 76 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | 77 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | ||
A internal/infrastructure/webkit/handlers/clipboard.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ClipboardHandler handles clipboard-related messages from webviews. | |
| 14 | + | type ClipboardHandler struct { | |
| 15 | + | clipboard port.Clipboard | |
| 16 | + | configGetter func() *config.Config | |
| 17 | + | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewClipboardHandler creates a new ClipboardHandler. | |
| 21 | + | func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler { | |
| 22 | + | return &ClipboardHandler{ | |
| 23 | + | clipboard: clipboard, | |
| 24 | + | configGetter: configGetter, | |
| 25 | + | onCopied: onCopied, | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // autoCopyRequest represents the payload for auto-copy selection messages. | |
| 30 | + | type autoCopyRequest struct { | |
| 31 | + | Text string `json:"text"` | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | |
| 35 | + | // It copies the selected text to the clipboard if the feature is enabled. | |
| 36 | + | func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler { | |
| 37 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 38 | + | log := logging.FromContext(ctx) | |
| 39 | + | ||
| 40 | + | // Check if feature is enabled | |
| 41 | + | cfg := h.configGetter() | |
| 42 | + | if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection { | |
| 43 | + | return nil, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | var req autoCopyRequest | |
| 47 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 48 | + | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | |
| 49 | + | return nil, nil // Silently ignore malformed requests | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Skip empty or very short selections (< 2 chars) | |
| 53 | + | if len(req.Text) < 2 { | |
| 54 | + | return nil, nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | |
| 58 | + | log.Debug().Err(err).Msg("failed to write selection to clipboard") | |
| 59 | + | return nil, nil // Don't propagate error to JS | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard") | |
| 63 | + | ||
| 64 | + | // Notify UI for toast feedback (if callback is set) | |
| 65 | + | if h.onCopied != nil { | |
| 66 | + | h.onCopied(len(req.Text)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | return nil, nil | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // RegisterClipboardHandlers registers clipboard handlers with the router. | |
| 74 | + | func RegisterClipboardHandlers( | |
| 75 | + | ctx context.Context, | |
| 76 | + | router *webkit.MessageRouter, | |
| 77 | + | clipboard port.Clipboard, | |
| 78 | + | configGetter func() *config.Config, | |
| 79 | + | onCopied func(textLen int), | |
| 80 | + | ) error { | |
| 81 | + | handler := NewClipboardHandler(clipboard, configGetter, onCopied) | |
| 82 | + | ||
| 83 | + | // Register auto_copy_selection handler | |
| 84 | + | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | log := logging.FromContext(ctx) | |
| 89 | + | log.Info().Msg("registered clipboard handlers") | |
| 90 | + | ||
| 91 | + | return nil | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // ClipboardHandler handles clipboard-related messages from webviews. | |||
| 14 | + | type ClipboardHandler struct { | |||
| 15 | + | clipboard port.Clipboard | |||
| 16 | + | configGetter func() *config.Config | |||
| 17 | + | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // NewClipboardHandler creates a new ClipboardHandler. | |||
| 21 | + | func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler { | |||
| 22 | + | return &ClipboardHandler{ | |||
| 23 | + | clipboard: clipboard, | |||
| 24 | + | configGetter: configGetter, | |||
| 25 | + | onCopied: onCopied, | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // autoCopyRequest represents the payload for auto-copy selection messages. | |||
| 30 | + | type autoCopyRequest struct { | |||
| 31 | + | Text string `json:"text"` | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | |||
| 35 | + | // It copies the selected text to the clipboard if the feature is enabled. | |||
| 36 | + | func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler { | |||
| 37 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 38 | + | log := logging.FromContext(ctx) | |||
| 39 | + | ||||
| 40 | + | // Check if feature is enabled | |||
| 41 | + | cfg := h.configGetter() | |||
| 42 | + | if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection { | |||
| 43 | + | return nil, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | var req autoCopyRequest | |||
| 47 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 48 | + | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | |||
| 49 | + | return nil, nil // Silently ignore malformed requests | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Skip empty or very short selections (< 2 chars) | |||
| 53 | + | if len(req.Text) < 2 { | |||
| 54 | + | return nil, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | |||
| 58 | + | log.Debug().Err(err).Msg("failed to write selection to clipboard") | |||
| 59 | + | return nil, nil // Don't propagate error to JS | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard") | |||
| 63 | + | ||||
| 64 | + | // Notify UI for toast feedback (if callback is set) | |||
| 65 | + | if h.onCopied != nil { | |||
| 66 | + | h.onCopied(len(req.Text)) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | return nil, nil | |||
| 70 | + | }) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // RegisterClipboardHandlers registers clipboard handlers with the router. | |||
| 74 | + | func RegisterClipboardHandlers( | |||
| 75 | + | ctx context.Context, | |||
| 76 | + | router *webkit.MessageRouter, | |||
| 77 | + | clipboard port.Clipboard, | |||
| 78 | + | configGetter func() *config.Config, | |||
| 79 | + | onCopied func(textLen int), | |||
| 80 | + | ) error { | |||
| 81 | + | handler := NewClipboardHandler(clipboard, configGetter, onCopied) | |||
| 82 | + | ||||
| 83 | + | // Register auto_copy_selection handler | |||
| 84 | + | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | |||
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | log := logging.FromContext(ctx) | |||
| 89 | + | log.Info().Msg("registered clipboard handlers") | |||
| 90 | + | ||||
| 91 | + | return nil | |||
| 92 | + | } | |||
M internal/infrastructure/webkit/handlers/register.go +14 -2
| @@ -3,15 +3,20 @@ package handlers | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 6 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 8 | 10 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // Config holds all dependencies for message handlers. | |
| 12 | 14 | type Config struct { | |
| 13 | - | HistoryUC *usecase.SearchHistoryUseCase | |
| 14 | - | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 15 | + | HistoryUC *usecase.SearchHistoryUseCase | |
| 16 | + | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 17 | + | Clipboard port.Clipboard | |
| 18 | + | ConfigGetter func() *config.Config | |
| 19 | + | OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification) | |
| 15 | 20 | } | |
| 16 | 21 | ||
| 17 | 22 | // RegisterAll registers all message handlers with the router. | |
| @@ -31,6 +36,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 31 | 36 | return err | |
| 32 | 37 | } | |
| 33 | 38 | ||
| 39 | + | // Clipboard handlers (for auto-copy on selection feature) | |
| 40 | + | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | |
| 41 | + | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 34 | 46 | // Future handler groups go here: | |
| 35 | 47 | // - settings handlers | |
| 36 | 48 | // - downloads handlers | |
| @@ -3,15 +3,20 @@ package handlers | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 6 | "github.com/bnema/dumber/internal/application/usecase" | 7 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 7 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | 10 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | // Config holds all dependencies for message handlers. | 13 | // Config holds all dependencies for message handlers. | ||
| 12 | type Config struct { | 14 | type Config struct { | ||
| 13 | - | HistoryUC *usecase.SearchHistoryUseCase | 15 | + | HistoryUC *usecase.SearchHistoryUseCase |
| 14 | - | FavoritesUC *usecase.ManageFavoritesUseCase | 16 | + | FavoritesUC *usecase.ManageFavoritesUseCase |
| 17 | + | Clipboard port.Clipboard | |||
| 18 | + | ConfigGetter func() *config.Config | |||
| 19 | + | OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification) | |||
| 15 | } | 20 | } | ||
| 16 | 21 | ||||
| 17 | // RegisterAll registers all message handlers with the router. | 22 | // RegisterAll registers all message handlers with the router. | ||
| @@ -31,6 +36,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 31 | return err | 36 | return err | ||
| 32 | } | 37 | } | ||
| 33 | 38 | ||||
| 39 | + | // Clipboard handlers (for auto-copy on selection feature) | |||
| 40 | + | if cfg.Clipboard != nil && cfg.ConfigGetter != nil { | |||
| 41 | + | if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 34 | // Future handler groups go here: | 46 | // Future handler groups go here: | ||
| 35 | // - settings handlers | 47 | // - settings handlers | ||
| 36 | // - downloads handlers | 48 | // - downloads handlers | ||
M internal/infrastructure/webkit/injector.go +54 -4
| @@ -114,15 +114,43 @@ const internalDarkModeScriptTemplate = `(function() { | |||
| 114 | 114 | // internalPageAllowList restricts script injection to internal dumb:// pages only. | |
| 115 | 115 | var internalPageAllowList = []string{"dumb://*"} | |
| 116 | 116 | ||
| 117 | + | // autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style). | |
| 118 | + | // It listens for selection changes, debounces them, and posts to the Go message handler. | |
| 119 | + | // Skips selections in input fields/textareas and requires minimum 2 characters. | |
| 120 | + | const autoCopySelectionScript = `(function() { | |
| 121 | + | var debounceTimer = null; | |
| 122 | + | var MIN_LENGTH = 2; | |
| 123 | + | ||
| 124 | + | document.addEventListener('selectionchange', function() { | |
| 125 | + | // Skip if selection is inside input/textarea/contenteditable | |
| 126 | + | var activeEl = document.activeElement; | |
| 127 | + | if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) { | |
| 128 | + | return; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | clearTimeout(debounceTimer); | |
| 132 | + | debounceTimer = setTimeout(function() { | |
| 133 | + | var text = window.getSelection().toString().trim(); | |
| 134 | + | if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) { | |
| 135 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 136 | + | type: 'auto_copy_selection', | |
| 137 | + | payload: { text: text } | |
| 138 | + | }); | |
| 139 | + | } | |
| 140 | + | }, 300); | |
| 141 | + | }); | |
| 142 | + | })();` | |
| 143 | + | ||
| 117 | 144 | // ContentInjector encapsulates script injection into WebViews. | |
| 118 | 145 | // It injects dark mode detection scripts for internal pages (dumb://) | |
| 119 | 146 | // and theme CSS variables for WebUI styling. | |
| 120 | 147 | // External pages receive dark mode preference via libadwaita's StyleManager. | |
| 121 | 148 | // Implements port.ContentInjector interface. | |
| 122 | 149 | type ContentInjector struct { | |
| 123 | - | colorResolver port.ColorSchemeResolver | |
| 124 | - | themeCSSVars string // CSS custom property declarations for WebUI | |
| 125 | - | findCSS string // CSS for find-in-page highlight styling | |
| 150 | + | colorResolver port.ColorSchemeResolver | |
| 151 | + | themeCSSVars string // CSS custom property declarations for WebUI | |
| 152 | + | findCSS string // CSS for find-in-page highlight styling | |
| 153 | + | autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config | |
| 126 | 154 | } | |
| 127 | 155 | ||
| 128 | 156 | // NewContentInjector creates a new injector instance. | |
| @@ -133,6 +161,12 @@ func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector { | |||
| 133 | 161 | } | |
| 134 | 162 | } | |
| 135 | 163 | ||
| 164 | + | // SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled. | |
| 165 | + | // This is called during script injection to determine whether to inject the selection listener. | |
| 166 | + | func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) { | |
| 167 | + | ci.autoCopyConfigGetter = getter | |
| 168 | + | } | |
| 169 | + | ||
| 136 | 170 | // InjectThemeCSS stores CSS variables for injection into internal pages. | |
| 137 | 171 | // Implements port.ContentInjector interface. | |
| 138 | 172 | // The CSS will be injected when InjectScripts is called on WebView creation. | |
| @@ -252,5 +286,21 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 252 | 286 | } | |
| 253 | 287 | } | |
| 254 | 288 | ||
| 255 | - | log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected") | |
| 289 | + | // 5. Inject auto-copy selection script for all pages (if enabled) | |
| 290 | + | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | |
| 291 | + | if autoCopyEnabled { | |
| 292 | + | addScript( | |
| 293 | + | webkit.NewUserScript( | |
| 294 | + | autoCopySelectionScript, | |
| 295 | + | webkit.UserContentInjectTopFrameValue, | |
| 296 | + | webkit.UserScriptInjectAtDocumentEndValue, | |
| 297 | + | nil, // All pages | |
| 298 | + | nil, | |
| 299 | + | ), | |
| 300 | + | "auto-copy-selection", | |
| 301 | + | ) | |
| 302 | + | log.Debug().Msg("auto-copy selection script injected") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | |
| 256 | 306 | } | |
| @@ -114,15 +114,43 @@ const internalDarkModeScriptTemplate = `(function() { | |||||
| 114 | // internalPageAllowList restricts script injection to internal dumb:// pages only. | 114 | // internalPageAllowList restricts script injection to internal dumb:// pages only. | ||
| 115 | var internalPageAllowList = []string{"dumb://*"} | 115 | var internalPageAllowList = []string{"dumb://*"} | ||
| 116 | 116 | ||||
| 117 | + | // autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style). | |||
| 118 | + | // It listens for selection changes, debounces them, and posts to the Go message handler. | |||
| 119 | + | // Skips selections in input fields/textareas and requires minimum 2 characters. | |||
| 120 | + | const autoCopySelectionScript = `(function() { | |||
| 121 | + | var debounceTimer = null; | |||
| 122 | + | var MIN_LENGTH = 2; | |||
| 123 | + | ||||
| 124 | + | document.addEventListener('selectionchange', function() { | |||
| 125 | + | // Skip if selection is inside input/textarea/contenteditable | |||
| 126 | + | var activeEl = document.activeElement; | |||
| 127 | + | if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) { | |||
| 128 | + | return; | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | clearTimeout(debounceTimer); | |||
| 132 | + | debounceTimer = setTimeout(function() { | |||
| 133 | + | var text = window.getSelection().toString().trim(); | |||
| 134 | + | if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) { | |||
| 135 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 136 | + | type: 'auto_copy_selection', | |||
| 137 | + | payload: { text: text } | |||
| 138 | + | }); | |||
| 139 | + | } | |||
| 140 | + | }, 300); | |||
| 141 | + | }); | |||
| 142 | + | })();` | |||
| 143 | + | ||||
| 117 | // ContentInjector encapsulates script injection into WebViews. | 144 | // ContentInjector encapsulates script injection into WebViews. | ||
| 118 | // It injects dark mode detection scripts for internal pages (dumb://) | 145 | // It injects dark mode detection scripts for internal pages (dumb://) | ||
| 119 | // and theme CSS variables for WebUI styling. | 146 | // and theme CSS variables for WebUI styling. | ||
| 120 | // External pages receive dark mode preference via libadwaita's StyleManager. | 147 | // External pages receive dark mode preference via libadwaita's StyleManager. | ||
| 121 | // Implements port.ContentInjector interface. | 148 | // Implements port.ContentInjector interface. | ||
| 122 | type ContentInjector struct { | 149 | type ContentInjector struct { | ||
| 123 | - | colorResolver port.ColorSchemeResolver | 150 | + | colorResolver port.ColorSchemeResolver |
| 124 | - | themeCSSVars string // CSS custom property declarations for WebUI | 151 | + | themeCSSVars string // CSS custom property declarations for WebUI |
| 125 | - | findCSS string // CSS for find-in-page highlight styling | 152 | + | findCSS string // CSS for find-in-page highlight styling |
| 153 | + | autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config | |||
| 126 | } | 154 | } | ||
| 127 | 155 | ||||
| 128 | // NewContentInjector creates a new injector instance. | 156 | // NewContentInjector creates a new injector instance. | ||
| @@ -133,6 +161,12 @@ func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector { | |||||
| 133 | } | 161 | } | ||
| 134 | } | 162 | } | ||
| 135 | 163 | ||||
| 164 | + | // SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled. | |||
| 165 | + | // This is called during script injection to determine whether to inject the selection listener. | |||
| 166 | + | func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) { | |||
| 167 | + | ci.autoCopyConfigGetter = getter | |||
| 168 | + | } | |||
| 169 | + | ||||
| 136 | // InjectThemeCSS stores CSS variables for injection into internal pages. | 170 | // InjectThemeCSS stores CSS variables for injection into internal pages. | ||
| 137 | // Implements port.ContentInjector interface. | 171 | // Implements port.ContentInjector interface. | ||
| 138 | // The CSS will be injected when InjectScripts is called on WebView creation. | 172 | // The CSS will be injected when InjectScripts is called on WebView creation. | ||
| @@ -252,5 +286,21 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 252 | } | 286 | } | ||
| 253 | } | 287 | } | ||
| 254 | 288 | ||||
| 255 | - | log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected") | 289 | + | // 5. Inject auto-copy selection script for all pages (if enabled) |
| 290 | + | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | |||
| 291 | + | if autoCopyEnabled { | |||
| 292 | + | addScript( | |||
| 293 | + | webkit.NewUserScript( | |||
| 294 | + | autoCopySelectionScript, | |||
| 295 | + | webkit.UserContentInjectTopFrameValue, | |||
| 296 | + | webkit.UserScriptInjectAtDocumentEndValue, | |||
| 297 | + | nil, // All pages | |||
| 298 | + | nil, | |||
| 299 | + | ), | |||
| 300 | + | "auto-copy-selection", | |||
| 301 | + | ) | |||
| 302 | + | log.Debug().Msg("auto-copy selection script injected") | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | |||
| 256 | } | 306 | } | ||
M internal/ui/app.go +18 -2
| @@ -146,8 +146,24 @@ func New(deps *Dependencies) (*App, error) { | |||
| 146 | 146 | // Register message handlers | |
| 147 | 147 | if app.router != nil { | |
| 148 | 148 | if err := handlers.RegisterAll(ctx, app.router, handlers.Config{ | |
| 149 | - | HistoryUC: deps.HistoryUC, | |
| 150 | - | FavoritesUC: deps.FavoritesUC, | |
| 149 | + | HistoryUC: deps.HistoryUC, | |
| 150 | + | FavoritesUC: deps.FavoritesUC, | |
| 151 | + | Clipboard: deps.Clipboard, | |
| 152 | + | ConfigGetter: config.Get, | |
| 153 | + | OnClipboardCopied: func(textLen int) { | |
| 154 | + | // Show brief toast on auto-copy (similar to zellij footer notification) | |
| 155 | + | // Must schedule on GTK main thread since this is called from WebKit handler | |
| 156 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 157 | + | if app.appToaster != nil { | |
| 158 | + | app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo, | |
| 159 | + | component.WithDuration(component.ToastBriefDurationMs), | |
| 160 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 161 | + | ) | |
| 162 | + | } | |
| 163 | + | return false | |
| 164 | + | }) | |
| 165 | + | glib.IdleAdd(&cb, 0) | |
| 166 | + | }, | |
| 151 | 167 | }); err != nil { | |
| 152 | 168 | return nil, err | |
| 153 | 169 | } | |
| @@ -146,8 +146,24 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 146 | // Register message handlers | 146 | // Register message handlers | ||
| 147 | if app.router != nil { | 147 | if app.router != nil { | ||
| 148 | if err := handlers.RegisterAll(ctx, app.router, handlers.Config{ | 148 | if err := handlers.RegisterAll(ctx, app.router, handlers.Config{ | ||
| 149 | - | HistoryUC: deps.HistoryUC, | 149 | + | HistoryUC: deps.HistoryUC, |
| 150 | - | FavoritesUC: deps.FavoritesUC, | 150 | + | FavoritesUC: deps.FavoritesUC, |
| 151 | + | Clipboard: deps.Clipboard, | |||
| 152 | + | ConfigGetter: config.Get, | |||
| 153 | + | OnClipboardCopied: func(textLen int) { | |||
| 154 | + | // Show brief toast on auto-copy (similar to zellij footer notification) | |||
| 155 | + | // Must schedule on GTK main thread since this is called from WebKit handler | |||
| 156 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 157 | + | if app.appToaster != nil { | |||
| 158 | + | app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo, | |||
| 159 | + | component.WithDuration(component.ToastBriefDurationMs), | |||
| 160 | + | component.WithPosition(component.ToastPositionBottomRight), | |||
| 161 | + | ) | |||
| 162 | + | } | |||
| 163 | + | return false | |||
| 164 | + | }) | |||
| 165 | + | glib.IdleAdd(&cb, 0) | |||
| 166 | + | }, | |||
| 151 | }); err != nil { | 167 | }); err != nil { | ||
| 152 | return nil, err | 168 | return nil, err | ||
| 153 | } | 169 | } | ||
M internal/ui/component/link_status.go +40 -4
| @@ -13,6 +13,8 @@ import ( | |||
| 13 | 13 | const ( | |
| 14 | 14 | // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker. | |
| 15 | 15 | linkStatusShowDelayMs = 100 | |
| 16 | + | // linkStatusAutoHideMs is the delay before auto-hiding the overlay (e.g., during fullscreen video). | |
| 17 | + | linkStatusAutoHideMs = 10000 | |
| 16 | 18 | // linkStatusMaxChars is the maximum characters before truncation. | |
| 17 | 19 | linkStatusMaxChars = 80 | |
| 18 | 20 | ) | |
| @@ -24,10 +26,11 @@ type LinkStatusOverlay struct { | |||
| 24 | 26 | container layout.BoxWidget | |
| 25 | 27 | label layout.LabelWidget | |
| 26 | 28 | ||
| 27 | - | pendingURI string // URI waiting to be shown after delay | |
| 28 | - | showTimer uint // GLib timer for delayed show | |
| 29 | - | visible bool // Whether overlay is currently visible (has .visible class) | |
| 30 | - | mu sync.Mutex | |
| 29 | + | pendingURI string // URI waiting to be shown after delay | |
| 30 | + | showTimer uint // GLib timer for delayed show | |
| 31 | + | autoHideTimer uint // GLib timer for auto-hide after timeout | |
| 32 | + | visible bool // Whether overlay is currently visible (has .visible class) | |
| 33 | + | mu sync.Mutex | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // NewLinkStatusOverlay creates a new link status overlay component. | |
| @@ -96,6 +99,8 @@ func (l *LinkStatusOverlay) Show(uri string) { | |||
| 96 | 99 | l.visible = true | |
| 97 | 100 | l.container.AddCssClass("visible") | |
| 98 | 101 | } | |
| 102 | + | // Start or reset auto-hide timer | |
| 103 | + | l.resetAutoHideTimerLocked() | |
| 99 | 104 | } | |
| 100 | 105 | l.showTimer = 0 | |
| 101 | 106 | return false // Don't repeat | |
| @@ -118,6 +123,11 @@ func (l *LinkStatusOverlay) hide() { | |||
| 118 | 123 | glib.SourceRemove(l.showTimer) | |
| 119 | 124 | l.showTimer = 0 | |
| 120 | 125 | } | |
| 126 | + | // Cancel auto-hide timer | |
| 127 | + | if l.autoHideTimer != 0 { | |
| 128 | + | glib.SourceRemove(l.autoHideTimer) | |
| 129 | + | l.autoHideTimer = 0 | |
| 130 | + | } | |
| 121 | 131 | l.pendingURI = "" | |
| 122 | 132 | ||
| 123 | 133 | if l.visible { | |
| @@ -126,6 +136,27 @@ func (l *LinkStatusOverlay) hide() { | |||
| 126 | 136 | } | |
| 127 | 137 | } | |
| 128 | 138 | ||
| 139 | + | // resetAutoHideTimerLocked starts or resets the auto-hide timer (must be called with lock held). | |
| 140 | + | func (l *LinkStatusOverlay) resetAutoHideTimerLocked() { | |
| 141 | + | // Cancel existing auto-hide timer | |
| 142 | + | if l.autoHideTimer != 0 { | |
| 143 | + | glib.SourceRemove(l.autoHideTimer) | |
| 144 | + | l.autoHideTimer = 0 | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Start new auto-hide timer | |
| 148 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 149 | + | l.mu.Lock() | |
| 150 | + | defer l.mu.Unlock() | |
| 151 | + | ||
| 152 | + | l.autoHideTimer = 0 | |
| 153 | + | l.hide() | |
| 154 | + | return false // Don't repeat | |
| 155 | + | }) | |
| 156 | + | ||
| 157 | + | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0) | |
| 158 | + | } | |
| 159 | + | ||
| 129 | 160 | // Widget returns the underlying widget for embedding in overlays. | |
| 130 | 161 | func (l *LinkStatusOverlay) Widget() layout.Widget { | |
| 131 | 162 | return l.container | |
| @@ -149,6 +180,11 @@ func (l *LinkStatusOverlay) Cleanup() { | |||
| 149 | 180 | glib.SourceRemove(l.showTimer) | |
| 150 | 181 | l.showTimer = 0 | |
| 151 | 182 | } | |
| 183 | + | // Cancel auto-hide timer | |
| 184 | + | if l.autoHideTimer != 0 { | |
| 185 | + | glib.SourceRemove(l.autoHideTimer) | |
| 186 | + | l.autoHideTimer = 0 | |
| 187 | + | } | |
| 152 | 188 | l.pendingURI = "" | |
| 153 | 189 | l.visible = false | |
| 154 | 190 | } | |
| @@ -13,6 +13,8 @@ import ( | |||||
| 13 | const ( | 13 | const ( | ||
| 14 | // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker. | 14 | // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker. | ||
| 15 | linkStatusShowDelayMs = 100 | 15 | linkStatusShowDelayMs = 100 | ||
| 16 | + | // linkStatusAutoHideMs is the delay before auto-hiding the overlay (e.g., during fullscreen video). | |||
| 17 | + | linkStatusAutoHideMs = 10000 | |||
| 16 | // linkStatusMaxChars is the maximum characters before truncation. | 18 | // linkStatusMaxChars is the maximum characters before truncation. | ||
| 17 | linkStatusMaxChars = 80 | 19 | linkStatusMaxChars = 80 | ||
| 18 | ) | 20 | ) | ||
| @@ -24,10 +26,11 @@ type LinkStatusOverlay struct { | |||||
| 24 | container layout.BoxWidget | 26 | container layout.BoxWidget | ||
| 25 | label layout.LabelWidget | 27 | label layout.LabelWidget | ||
| 26 | 28 | ||||
| 27 | - | pendingURI string // URI waiting to be shown after delay | 29 | + | pendingURI string // URI waiting to be shown after delay |
| 28 | - | showTimer uint // GLib timer for delayed show | 30 | + | showTimer uint // GLib timer for delayed show |
| 29 | - | visible bool // Whether overlay is currently visible (has .visible class) | 31 | + | autoHideTimer uint // GLib timer for auto-hide after timeout |
| 30 | - | mu sync.Mutex | 32 | + | visible bool // Whether overlay is currently visible (has .visible class) |
| 33 | + | mu sync.Mutex | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // NewLinkStatusOverlay creates a new link status overlay component. | 36 | // NewLinkStatusOverlay creates a new link status overlay component. | ||
| @@ -96,6 +99,8 @@ func (l *LinkStatusOverlay) Show(uri string) { | |||||
| 96 | l.visible = true | 99 | l.visible = true | ||
| 97 | l.container.AddCssClass("visible") | 100 | l.container.AddCssClass("visible") | ||
| 98 | } | 101 | } | ||
| 102 | + | // Start or reset auto-hide timer | |||
| 103 | + | l.resetAutoHideTimerLocked() | |||
| 99 | } | 104 | } | ||
| 100 | l.showTimer = 0 | 105 | l.showTimer = 0 | ||
| 101 | return false // Don't repeat | 106 | return false // Don't repeat | ||
| @@ -118,6 +123,11 @@ func (l *LinkStatusOverlay) hide() { | |||||
| 118 | glib.SourceRemove(l.showTimer) | 123 | glib.SourceRemove(l.showTimer) | ||
| 119 | l.showTimer = 0 | 124 | l.showTimer = 0 | ||
| 120 | } | 125 | } | ||
| 126 | + | // Cancel auto-hide timer | |||
| 127 | + | if l.autoHideTimer != 0 { | |||
| 128 | + | glib.SourceRemove(l.autoHideTimer) | |||
| 129 | + | l.autoHideTimer = 0 | |||
| 130 | + | } | |||
| 121 | l.pendingURI = "" | 131 | l.pendingURI = "" | ||
| 122 | 132 | ||||
| 123 | if l.visible { | 133 | if l.visible { | ||
| @@ -126,6 +136,27 @@ func (l *LinkStatusOverlay) hide() { | |||||
| 126 | } | 136 | } | ||
| 127 | } | 137 | } | ||
| 128 | 138 | ||||
| 139 | + | // resetAutoHideTimerLocked starts or resets the auto-hide timer (must be called with lock held). | |||
| 140 | + | func (l *LinkStatusOverlay) resetAutoHideTimerLocked() { | |||
| 141 | + | // Cancel existing auto-hide timer | |||
| 142 | + | if l.autoHideTimer != 0 { | |||
| 143 | + | glib.SourceRemove(l.autoHideTimer) | |||
| 144 | + | l.autoHideTimer = 0 | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Start new auto-hide timer | |||
| 148 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 149 | + | l.mu.Lock() | |||
| 150 | + | defer l.mu.Unlock() | |||
| 151 | + | ||||
| 152 | + | l.autoHideTimer = 0 | |||
| 153 | + | l.hide() | |||
| 154 | + | return false // Don't repeat | |||
| 155 | + | }) | |||
| 156 | + | ||||
| 157 | + | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 129 | // Widget returns the underlying widget for embedding in overlays. | 160 | // Widget returns the underlying widget for embedding in overlays. | ||
| 130 | func (l *LinkStatusOverlay) Widget() layout.Widget { | 161 | func (l *LinkStatusOverlay) Widget() layout.Widget { | ||
| 131 | return l.container | 162 | return l.container | ||
| @@ -149,6 +180,11 @@ func (l *LinkStatusOverlay) Cleanup() { | |||||
| 149 | glib.SourceRemove(l.showTimer) | 180 | glib.SourceRemove(l.showTimer) | ||
| 150 | l.showTimer = 0 | 181 | l.showTimer = 0 | ||
| 151 | } | 182 | } | ||
| 183 | + | // Cancel auto-hide timer | |||
| 184 | + | if l.autoHideTimer != 0 { | |||
| 185 | + | glib.SourceRemove(l.autoHideTimer) | |||
| 186 | + | l.autoHideTimer = 0 | |||
| 187 | + | } | |||
| 152 | l.pendingURI = "" | 188 | l.pendingURI = "" | ||
| 153 | l.visible = false | 189 | l.visible = false | ||
| 154 | } | 190 | } | ||
M internal/ui/component/pane_view.go +12 -0
| @@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | // CancelPendingHover cancels any pending hover focus timer. | |
| 248 | + | // Called when keyboard navigation occurs to prevent hover from overriding keyboard focus. | |
| 249 | + | func (pv *PaneView) CancelPendingHover() { | |
| 250 | + | pv.mu.RLock() | |
| 251 | + | handler := pv.hoverHandler | |
| 252 | + | pv.mu.RUnlock() | |
| 253 | + | ||
| 254 | + | if handler != nil { | |
| 255 | + | handler.Cancel() | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 247 | 259 | // Widget returns the underlying overlay widget for embedding in containers. | |
| 248 | 260 | func (pv *PaneView) Widget() layout.Widget { | |
| 249 | 261 | return pv.overlay | |
| @@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||||
| 244 | } | 244 | } | ||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | + | // CancelPendingHover cancels any pending hover focus timer. | |||
| 248 | + | // Called when keyboard navigation occurs to prevent hover from overriding keyboard focus. | |||
| 249 | + | func (pv *PaneView) CancelPendingHover() { | |||
| 250 | + | pv.mu.RLock() | |||
| 251 | + | handler := pv.hoverHandler | |||
| 252 | + | pv.mu.RUnlock() | |||
| 253 | + | ||||
| 254 | + | if handler != nil { | |||
| 255 | + | handler.Cancel() | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 247 | // Widget returns the underlying overlay widget for embedding in containers. | 259 | // Widget returns the underlying overlay widget for embedding in containers. | ||
| 248 | func (pv *PaneView) Widget() layout.Widget { | 260 | func (pv *PaneView) Widget() layout.Widget { | ||
| 249 | return pv.overlay | 261 | return pv.overlay | ||
M internal/ui/component/toaster.go +2 -0
| @@ -16,6 +16,8 @@ import ( | |||
| 16 | 16 | const ( | |
| 17 | 17 | // Default auto-dismiss timeout in milliseconds. | |
| 18 | 18 | toastDismissTimeoutMs = 1500 | |
| 19 | + | // ToastBriefDurationMs is the duration for brief notifications (e.g., clipboard copy). | |
| 20 | + | ToastBriefDurationMs = 800 | |
| 19 | 21 | ) | |
| 20 | 22 | ||
| 21 | 23 | // ToastLevel indicates the visual style of a toast notification. | |
| @@ -16,6 +16,8 @@ import ( | |||||
| 16 | const ( | 16 | const ( | ||
| 17 | // Default auto-dismiss timeout in milliseconds. | 17 | // Default auto-dismiss timeout in milliseconds. | ||
| 18 | toastDismissTimeoutMs = 1500 | 18 | toastDismissTimeoutMs = 1500 | ||
| 19 | + | // ToastBriefDurationMs is the duration for brief notifications (e.g., clipboard copy). | |||
| 20 | + | ToastBriefDurationMs = 800 | |||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| 21 | // ToastLevel indicates the visual style of a toast notification. | 23 | // ToastLevel indicates the visual style of a toast notification. | ||
M internal/ui/component/workspace_view.go +54 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sync" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| @@ -50,6 +51,10 @@ type WorkspaceView struct { | |||
| 50 | 51 | onPaneFocused func(paneID entity.PaneID) | |
| 51 | 52 | onSplitRatioDragged func(nodeID string, ratio float64) | |
| 52 | 53 | ||
| 54 | + | // Hover suppression for keyboard navigation (Issue #89) | |
| 55 | + | // Prevents hover focus from overriding keyboard-initiated focus changes | |
| 56 | + | hoverSuppressedUntil time.Time | |
| 57 | + | ||
| 53 | 58 | mu sync.RWMutex | |
| 54 | 59 | } | |
| 55 | 60 | ||
| @@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||
| 86 | 91 | ||
| 87 | 92 | // Set up hover callback for focus-follows-mouse | |
| 88 | 93 | pv.SetOnHover(func(paneID entity.PaneID) { | |
| 94 | + | // Skip if hover is suppressed (keyboard navigation just happened) | |
| 95 | + | if a.wv.IsHoverSuppressed() { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 89 | 99 | // Skip if this pane is already active | |
| 90 | 100 | if a.wv.GetActivePaneID() == paneID { | |
| 91 | 101 | return | |
| @@ -347,6 +357,18 @@ func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool { | |||
| 347 | 357 | return pv.GrabFocus() | |
| 348 | 358 | } | |
| 349 | 359 | ||
| 360 | + | // DeactivatePane removes the active visual indicator from a pane. | |
| 361 | + | // This is used when keyboard navigation overrides mouse hover focus. | |
| 362 | + | func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) { | |
| 363 | + | wv.mu.RLock() | |
| 364 | + | pv, ok := wv.paneViews[paneID] | |
| 365 | + | wv.mu.RUnlock() | |
| 366 | + | ||
| 367 | + | if ok && pv != nil { | |
| 368 | + | pv.SetActive(false) | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 350 | 372 | // SetWebViewWidget attaches a WebView widget to a specific pane. | |
| 351 | 373 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | |
| 352 | 374 | wv.mu.RLock() | |
| @@ -755,3 +777,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView { | |||
| 755 | 777 | defer wv.mu.RUnlock() | |
| 756 | 778 | return wv.paneViews[wv.getActivePaneIDInternal()] | |
| 757 | 779 | } | |
| 780 | + | ||
| 781 | + | // KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation. | |
| 782 | + | const KeyboardFocusSuppressDuration = 300 * time.Millisecond | |
| 783 | + | ||
| 784 | + | // SuppressHover temporarily blocks hover focus changes after keyboard navigation. | |
| 785 | + | // This prevents the race condition where a pending hover timer overrides | |
| 786 | + | // an explicit keyboard focus change (Issue #89). | |
| 787 | + | func (wv *WorkspaceView) SuppressHover(duration time.Duration) { | |
| 788 | + | wv.mu.Lock() | |
| 789 | + | wv.hoverSuppressedUntil = time.Now().Add(duration) | |
| 790 | + | wv.mu.Unlock() | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | // IsHoverSuppressed returns true if hover focus is temporarily suppressed. | |
| 794 | + | func (wv *WorkspaceView) IsHoverSuppressed() bool { | |
| 795 | + | wv.mu.RLock() | |
| 796 | + | defer wv.mu.RUnlock() | |
| 797 | + | return time.Now().Before(wv.hoverSuppressedUntil) | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // CancelAllPendingHovers cancels any pending hover timers on all pane views. | |
| 801 | + | // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus. | |
| 802 | + | func (wv *WorkspaceView) CancelAllPendingHovers() { | |
| 803 | + | wv.mu.RLock() | |
| 804 | + | defer wv.mu.RUnlock() | |
| 805 | + | ||
| 806 | + | for _, pv := range wv.paneViews { | |
| 807 | + | if pv != nil { | |
| 808 | + | pv.CancelPendingHover() | |
| 809 | + | } | |
| 810 | + | } | |
| 811 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -50,6 +51,10 @@ type WorkspaceView struct { | |||||
| 50 | onPaneFocused func(paneID entity.PaneID) | 51 | onPaneFocused func(paneID entity.PaneID) | ||
| 51 | onSplitRatioDragged func(nodeID string, ratio float64) | 52 | onSplitRatioDragged func(nodeID string, ratio float64) | ||
| 52 | 53 | ||||
| 54 | + | // Hover suppression for keyboard navigation (Issue #89) | |||
| 55 | + | // Prevents hover focus from overriding keyboard-initiated focus changes | |||
| 56 | + | hoverSuppressedUntil time.Time | |||
| 57 | + | ||||
| 53 | mu sync.RWMutex | 58 | mu sync.RWMutex | ||
| 54 | } | 59 | } | ||
| 55 | 60 | ||||
| @@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||||
| 86 | 91 | ||||
| 87 | // Set up hover callback for focus-follows-mouse | 92 | // Set up hover callback for focus-follows-mouse | ||
| 88 | pv.SetOnHover(func(paneID entity.PaneID) { | 93 | pv.SetOnHover(func(paneID entity.PaneID) { | ||
| 94 | + | // Skip if hover is suppressed (keyboard navigation just happened) | |||
| 95 | + | if a.wv.IsHoverSuppressed() { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | ||||
| 89 | // Skip if this pane is already active | 99 | // Skip if this pane is already active | ||
| 90 | if a.wv.GetActivePaneID() == paneID { | 100 | if a.wv.GetActivePaneID() == paneID { | ||
| 91 | return | 101 | return | ||
| @@ -347,6 +357,18 @@ func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool { | |||||
| 347 | return pv.GrabFocus() | 357 | return pv.GrabFocus() | ||
| 348 | } | 358 | } | ||
| 349 | 359 | ||||
| 360 | + | // DeactivatePane removes the active visual indicator from a pane. | |||
| 361 | + | // This is used when keyboard navigation overrides mouse hover focus. | |||
| 362 | + | func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) { | |||
| 363 | + | wv.mu.RLock() | |||
| 364 | + | pv, ok := wv.paneViews[paneID] | |||
| 365 | + | wv.mu.RUnlock() | |||
| 366 | + | ||||
| 367 | + | if ok && pv != nil { | |||
| 368 | + | pv.SetActive(false) | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | ||||
| 350 | // SetWebViewWidget attaches a WebView widget to a specific pane. | 372 | // SetWebViewWidget attaches a WebView widget to a specific pane. | ||
| 351 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | 373 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | ||
| 352 | wv.mu.RLock() | 374 | wv.mu.RLock() | ||
| @@ -755,3 +777,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView { | |||||
| 755 | defer wv.mu.RUnlock() | 777 | defer wv.mu.RUnlock() | ||
| 756 | return wv.paneViews[wv.getActivePaneIDInternal()] | 778 | return wv.paneViews[wv.getActivePaneIDInternal()] | ||
| 757 | } | 779 | } | ||
| 780 | + | ||||
| 781 | + | // KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation. | |||
| 782 | + | const KeyboardFocusSuppressDuration = 300 * time.Millisecond | |||
| 783 | + | ||||
| 784 | + | // SuppressHover temporarily blocks hover focus changes after keyboard navigation. | |||
| 785 | + | // This prevents the race condition where a pending hover timer overrides | |||
| 786 | + | // an explicit keyboard focus change (Issue #89). | |||
| 787 | + | func (wv *WorkspaceView) SuppressHover(duration time.Duration) { | |||
| 788 | + | wv.mu.Lock() | |||
| 789 | + | wv.hoverSuppressedUntil = time.Now().Add(duration) | |||
| 790 | + | wv.mu.Unlock() | |||
| 791 | + | } | |||
| 792 | + | ||||
| 793 | + | // IsHoverSuppressed returns true if hover focus is temporarily suppressed. | |||
| 794 | + | func (wv *WorkspaceView) IsHoverSuppressed() bool { | |||
| 795 | + | wv.mu.RLock() | |||
| 796 | + | defer wv.mu.RUnlock() | |||
| 797 | + | return time.Now().Before(wv.hoverSuppressedUntil) | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | // CancelAllPendingHovers cancels any pending hover timers on all pane views. | |||
| 801 | + | // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus. | |||
| 802 | + | func (wv *WorkspaceView) CancelAllPendingHovers() { | |||
| 803 | + | wv.mu.RLock() | |||
| 804 | + | defer wv.mu.RUnlock() | |||
| 805 | + | ||||
| 806 | + | for _, pv := range wv.paneViews { | |||
| 807 | + | if pv != nil { | |||
| 808 | + | pv.CancelPendingHover() | |||
| 809 | + | } | |||
| 810 | + | } | |||
| 811 | + | } | |||
M internal/ui/component/workspace_view_test.go +25 -10
| @@ -128,15 +128,18 @@ func setupStackedLeafMocks( | |||
| 128 | 128 | mockTitleBar := mocks.NewMockBoxWidget(t) | |
| 129 | 129 | mockFavicon := mocks.NewMockImageWidget(t) | |
| 130 | 130 | mockLabel := mocks.NewMockLabelWidget(t) | |
| 131 | - | mockButton := mocks.NewMockButtonWidget(t) | |
| 131 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |
| 132 | 132 | ||
| 133 | 133 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once() | |
| 134 | 134 | mockStackBox.EXPECT().SetHexpand(true).Once() | |
| 135 | 135 | mockStackBox.EXPECT().SetVexpand(true).Once() | |
| 136 | 136 | ||
| 137 | + | // Title bar creation - now directly used without button wrapper | |
| 137 | 138 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | |
| 138 | 139 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | |
| 140 | + | mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |
| 139 | 141 | mockTitleBar.EXPECT().SetVexpand(false).Once() | |
| 142 | + | mockTitleBar.EXPECT().SetHexpand(true).Once() | |
| 140 | 143 | ||
| 141 | 144 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | |
| 142 | 145 | mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once() | |
| @@ -150,18 +153,30 @@ func setupStackedLeafMocks( | |||
| 150 | 153 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | |
| 151 | 154 | mockTitleBar.EXPECT().Append(mockLabel).Once() | |
| 152 | 155 | ||
| 153 | - | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | |
| 154 | - | mockButton.EXPECT().SetChild(mockTitleBar).Once() | |
| 155 | - | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | |
| 156 | - | mockButton.EXPECT().SetFocusOnClick(false).Once() | |
| 157 | - | mockButton.EXPECT().SetVexpand(false).Once() | |
| 158 | - | mockButton.EXPECT().SetHexpand(true).Once() | |
| 159 | - | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | |
| 156 | + | // Close button (uses SetIconName directly instead of child image) | |
| 157 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |
| 158 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |
| 159 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |
| 160 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |
| 161 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |
| 162 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |
| 163 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |
| 160 | 164 | ||
| 161 | - | mockStackBox.EXPECT().Append(mockButton).Once() | |
| 165 | + | // GestureClick is added to titleBar via AddController | |
| 166 | + | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |
| 167 | + | ||
| 168 | + | // Close button click handler | |
| 169 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |
| 170 | + | ||
| 171 | + | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |
| 172 | + | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |
| 173 | + | ||
| 174 | + | // Adding to main box - now titleBar is added directly (no button wrapper) | |
| 175 | + | mockStackBox.EXPECT().Append(mockTitleBar).Once() | |
| 162 | 176 | mockStackBox.EXPECT().Append(container).Once() | |
| 163 | 177 | ||
| 164 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 178 | + | // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it | |
| 179 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 165 | 180 | container.EXPECT().SetVisible(true).Once() | |
| 166 | 181 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 167 | 182 | ||
| @@ -128,15 +128,18 @@ func setupStackedLeafMocks( | |||||
| 128 | mockTitleBar := mocks.NewMockBoxWidget(t) | 128 | mockTitleBar := mocks.NewMockBoxWidget(t) | ||
| 129 | mockFavicon := mocks.NewMockImageWidget(t) | 129 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| 130 | mockLabel := mocks.NewMockLabelWidget(t) | 130 | mockLabel := mocks.NewMockLabelWidget(t) | ||
| 131 | - | mockButton := mocks.NewMockButtonWidget(t) | 131 | + | mockCloseButton := mocks.NewMockButtonWidget(t) |
| 132 | 132 | ||||
| 133 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once() | 133 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once() | ||
| 134 | mockStackBox.EXPECT().SetHexpand(true).Once() | 134 | mockStackBox.EXPECT().SetHexpand(true).Once() | ||
| 135 | mockStackBox.EXPECT().SetVexpand(true).Once() | 135 | mockStackBox.EXPECT().SetVexpand(true).Once() | ||
| 136 | 136 | ||||
| 137 | + | // Title bar creation - now directly used without button wrapper | |||
| 137 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | 138 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | ||
| 138 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | 139 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | ||
| 140 | + | mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |||
| 139 | mockTitleBar.EXPECT().SetVexpand(false).Once() | 141 | mockTitleBar.EXPECT().SetVexpand(false).Once() | ||
| 142 | + | mockTitleBar.EXPECT().SetHexpand(true).Once() | |||
| 140 | 143 | ||||
| 141 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | 144 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | ||
| 142 | mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once() | 145 | mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once() | ||
| @@ -150,18 +153,30 @@ func setupStackedLeafMocks( | |||||
| 150 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | 153 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | ||
| 151 | mockTitleBar.EXPECT().Append(mockLabel).Once() | 154 | mockTitleBar.EXPECT().Append(mockLabel).Once() | ||
| 152 | 155 | ||||
| 153 | - | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | 156 | + | // Close button (uses SetIconName directly instead of child image) |
| 154 | - | mockButton.EXPECT().SetChild(mockTitleBar).Once() | 157 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() |
| 155 | - | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | 158 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() |
| 156 | - | mockButton.EXPECT().SetFocusOnClick(false).Once() | 159 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() |
| 157 | - | mockButton.EXPECT().SetVexpand(false).Once() | 160 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() |
| 158 | - | mockButton.EXPECT().SetHexpand(true).Once() | 161 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() |
| 159 | - | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | 162 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() |
| 163 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |||
| 160 | 164 | ||||
| 161 | - | mockStackBox.EXPECT().Append(mockButton).Once() | 165 | + | // GestureClick is added to titleBar via AddController |
| 166 | + | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |||
| 167 | + | ||||
| 168 | + | // Close button click handler | |||
| 169 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |||
| 170 | + | ||||
| 171 | + | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |||
| 172 | + | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |||
| 173 | + | ||||
| 174 | + | // Adding to main box - now titleBar is added directly (no button wrapper) | |||
| 175 | + | mockStackBox.EXPECT().Append(mockTitleBar).Once() | |||
| 162 | mockStackBox.EXPECT().Append(container).Once() | 176 | mockStackBox.EXPECT().Append(container).Once() | ||
| 163 | 177 | ||||
| 164 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 178 | + | // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it |
| 179 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |||
| 165 | container.EXPECT().SetVisible(true).Once() | 180 | container.EXPECT().SetVisible(true).Once() | ||
| 166 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 181 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 167 | 182 | ||||
M internal/ui/coordinator/workspace.go +53 -16
| @@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||
| 179 | 179 | return nil, false | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | |
| 183 | - | activePane.Parent != nil && activePane.Parent.IsStacked | |
| 182 | + | // Check if active pane is inside a stack | |
| 183 | + | isInStack := activePane.Parent != nil && activePane.Parent.IsStacked | |
| 184 | + | // isStackSplit is only true for horizontal splits - these use a special code path | |
| 185 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack | |
| 184 | 186 | ||
| 185 | 187 | var existingWidget layout.Widget | |
| 186 | 188 | if wsView != nil { | |
| 187 | - | existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit) | |
| 189 | + | existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack) | |
| 188 | 190 | } | |
| 189 | 191 | ||
| 190 | 192 | return &splitContext{ | |
| @@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||
| 199 | 201 | func (c *WorkspaceCoordinator) resolveSplitWidget( | |
| 200 | 202 | wsView *component.WorkspaceView, | |
| 201 | 203 | activePane *entity.PaneNode, | |
| 202 | - | isStackSplit bool, | |
| 204 | + | isInStack bool, | |
| 203 | 205 | ) layout.Widget { | |
| 204 | 206 | if wsView == nil { | |
| 205 | 207 | return nil | |
| 206 | 208 | } | |
| 207 | 209 | ||
| 208 | - | if !isStackSplit { | |
| 209 | - | return wsView.GetRootWidget() | |
| 210 | - | } | |
| 210 | + | // When splitting from inside a stack (any direction), we need the stack widget | |
| 211 | + | // because the domain model promotes the split to happen around the stack container | |
| 212 | + | if isInStack { | |
| 213 | + | tr := wsView.TreeRenderer() | |
| 214 | + | if tr == nil { | |
| 215 | + | return nil | |
| 216 | + | } | |
| 211 | 217 | ||
| 212 | - | tr := wsView.TreeRenderer() | |
| 213 | - | if tr == nil { | |
| 214 | - | return nil | |
| 215 | - | } | |
| 218 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |
| 219 | + | if stackedView == nil { | |
| 220 | + | return nil | |
| 221 | + | } | |
| 216 | 222 | ||
| 217 | - | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |
| 218 | - | if stackedView == nil { | |
| 219 | - | return nil | |
| 223 | + | return stackedView.Widget() | |
| 220 | 224 | } | |
| 221 | 225 | ||
| 222 | - | return stackedView.Widget() | |
| 226 | + | return wsView.GetRootWidget() | |
| 223 | 227 | } | |
| 224 | 228 | ||
| 225 | 229 | func (c *WorkspaceCoordinator) applySplitToView( | |
| @@ -1137,6 +1141,9 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase. | |||
| 1137 | 1141 | return nil | |
| 1138 | 1142 | } | |
| 1139 | 1143 | ||
| 1144 | + | // Save old active pane ID before navigation updates the domain model | |
| 1145 | + | oldActivePaneID := ws.ActivePaneID | |
| 1146 | + | ||
| 1140 | 1147 | // Use geometric navigation if focus manager is available | |
| 1141 | 1148 | var newPane *entity.PaneNode | |
| 1142 | 1149 | var err error | |
| @@ -1161,6 +1168,15 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase. | |||
| 1161 | 1168 | return nil | |
| 1162 | 1169 | } | |
| 1163 | 1170 | ||
| 1171 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |
| 1172 | + | wsView.CancelAllPendingHovers() | |
| 1173 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |
| 1174 | + | ||
| 1175 | + | // Deactivate old pane explicitly (domain model already updated by NavigateGeometric) | |
| 1176 | + | if oldActivePaneID != "" && oldActivePaneID != newPane.Pane.ID { | |
| 1177 | + | wsView.DeactivatePane(oldActivePaneID) | |
| 1178 | + | } | |
| 1179 | + | ||
| 1164 | 1180 | // Update the workspace view's active pane | |
| 1165 | 1181 | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | |
| 1166 | 1182 | log.Warn().Err(err).Msg("failed to update active pane in view") | |
| @@ -1277,7 +1293,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1277 | 1293 | stackCtx.wsView.ShowOmnibox(ctx, "") | |
| 1278 | 1294 | } | |
| 1279 | 1295 | ||
| 1280 | - | // Set up title bar click callback | |
| 1296 | + | // Set up title bar click and close callbacks | |
| 1281 | 1297 | tr := stackCtx.wsView.TreeRenderer() | |
| 1282 | 1298 | if tr != nil { | |
| 1283 | 1299 | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) | |
| @@ -1286,6 +1302,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1286 | 1302 | stackedView.SetOnActivate(func(index int) { | |
| 1287 | 1303 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | |
| 1288 | 1304 | }) | |
| 1305 | + | stackedView.SetOnClosePane(func(paneID string) { | |
| 1306 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |
| 1307 | + | }) | |
| 1289 | 1308 | } | |
| 1290 | 1309 | } | |
| 1291 | 1310 | ||
| @@ -1531,6 +1550,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||
| 1531 | 1550 | // Update workspace view (also updates domain model) | |
| 1532 | 1551 | _, wsView := c.getActiveWS() | |
| 1533 | 1552 | if wsView != nil { | |
| 1553 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |
| 1554 | + | wsView.CancelAllPendingHovers() | |
| 1555 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |
| 1556 | + | ||
| 1534 | 1557 | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | |
| 1535 | 1558 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 1536 | 1559 | } | |
| @@ -1543,6 +1566,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||
| 1543 | 1566 | Msg("switched active pane via title bar click") | |
| 1544 | 1567 | } | |
| 1545 | 1568 | ||
| 1569 | + | // onStackedPaneClose handles close button clicks on stacked pane title bars. | |
| 1570 | + | func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) { | |
| 1571 | + | log := logging.FromContext(ctx) | |
| 1572 | + | ||
| 1573 | + | log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button") | |
| 1574 | + | ||
| 1575 | + | if err := c.ClosePaneByID(ctx, paneID); err != nil { | |
| 1576 | + | log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane") | |
| 1577 | + | } | |
| 1578 | + | } | |
| 1579 | + | ||
| 1546 | 1580 | // --- Popup Insertion --- | |
| 1547 | 1581 | ||
| 1548 | 1582 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | |
| @@ -1836,6 +1870,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||
| 1836 | 1870 | stackedView.SetOnActivate(func(index int) { | |
| 1837 | 1871 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | |
| 1838 | 1872 | }) | |
| 1873 | + | stackedView.SetOnClosePane(func(paneID string) { | |
| 1874 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |
| 1875 | + | }) | |
| 1839 | 1876 | } | |
| 1840 | 1877 | } | |
| 1841 | 1878 | return nil | |
| @@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||||
| 179 | return nil, false | 179 | return nil, false | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | 182 | + | // Check if active pane is inside a stack |
| 183 | - | activePane.Parent != nil && activePane.Parent.IsStacked | 183 | + | isInStack := activePane.Parent != nil && activePane.Parent.IsStacked |
| 184 | + | // isStackSplit is only true for horizontal splits - these use a special code path | |||
| 185 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack | |||
| 184 | 186 | ||||
| 185 | var existingWidget layout.Widget | 187 | var existingWidget layout.Widget | ||
| 186 | if wsView != nil { | 188 | if wsView != nil { | ||
| 187 | - | existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit) | 189 | + | existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack) |
| 188 | } | 190 | } | ||
| 189 | 191 | ||||
| 190 | return &splitContext{ | 192 | return &splitContext{ | ||
| @@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||||
| 199 | func (c *WorkspaceCoordinator) resolveSplitWidget( | 201 | func (c *WorkspaceCoordinator) resolveSplitWidget( | ||
| 200 | wsView *component.WorkspaceView, | 202 | wsView *component.WorkspaceView, | ||
| 201 | activePane *entity.PaneNode, | 203 | activePane *entity.PaneNode, | ||
| 202 | - | isStackSplit bool, | 204 | + | isInStack bool, |
| 203 | ) layout.Widget { | 205 | ) layout.Widget { | ||
| 204 | if wsView == nil { | 206 | if wsView == nil { | ||
| 205 | return nil | 207 | return nil | ||
| 206 | } | 208 | } | ||
| 207 | 209 | ||||
| 208 | - | if !isStackSplit { | 210 | + | // When splitting from inside a stack (any direction), we need the stack widget |
| 209 | - | return wsView.GetRootWidget() | 211 | + | // because the domain model promotes the split to happen around the stack container |
| 210 | - | } | 212 | + | if isInStack { |
| 213 | + | tr := wsView.TreeRenderer() | |||
| 214 | + | if tr == nil { | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 211 | 217 | ||||
| 212 | - | tr := wsView.TreeRenderer() | 218 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) |
| 213 | - | if tr == nil { | 219 | + | if stackedView == nil { |
| 214 | - | return nil | 220 | + | return nil |
| 215 | - | } | 221 | + | } |
| 216 | 222 | ||||
| 217 | - | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | 223 | + | return stackedView.Widget() |
| 218 | - | if stackedView == nil { | |||
| 219 | - | return nil | |||
| 220 | } | 224 | } | ||
| 221 | 225 | ||||
| 222 | - | return stackedView.Widget() | 226 | + | return wsView.GetRootWidget() |
| 223 | } | 227 | } | ||
| 224 | 228 | ||||
| 225 | func (c *WorkspaceCoordinator) applySplitToView( | 229 | func (c *WorkspaceCoordinator) applySplitToView( | ||
| @@ -1137,6 +1141,9 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase. | |||||
| 1137 | return nil | 1141 | return nil | ||
| 1138 | } | 1142 | } | ||
| 1139 | 1143 | ||||
| 1144 | + | // Save old active pane ID before navigation updates the domain model | |||
| 1145 | + | oldActivePaneID := ws.ActivePaneID | |||
| 1146 | + | ||||
| 1140 | // Use geometric navigation if focus manager is available | 1147 | // Use geometric navigation if focus manager is available | ||
| 1141 | var newPane *entity.PaneNode | 1148 | var newPane *entity.PaneNode | ||
| 1142 | var err error | 1149 | var err error | ||
| @@ -1161,6 +1168,15 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase. | |||||
| 1161 | return nil | 1168 | return nil | ||
| 1162 | } | 1169 | } | ||
| 1163 | 1170 | ||||
| 1171 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |||
| 1172 | + | wsView.CancelAllPendingHovers() | |||
| 1173 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |||
| 1174 | + | ||||
| 1175 | + | // Deactivate old pane explicitly (domain model already updated by NavigateGeometric) | |||
| 1176 | + | if oldActivePaneID != "" && oldActivePaneID != newPane.Pane.ID { | |||
| 1177 | + | wsView.DeactivatePane(oldActivePaneID) | |||
| 1178 | + | } | |||
| 1179 | + | ||||
| 1164 | // Update the workspace view's active pane | 1180 | // Update the workspace view's active pane | ||
| 1165 | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | 1181 | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | ||
| 1166 | log.Warn().Err(err).Msg("failed to update active pane in view") | 1182 | log.Warn().Err(err).Msg("failed to update active pane in view") | ||
| @@ -1277,7 +1293,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1277 | stackCtx.wsView.ShowOmnibox(ctx, "") | 1293 | stackCtx.wsView.ShowOmnibox(ctx, "") | ||
| 1278 | } | 1294 | } | ||
| 1279 | 1295 | ||||
| 1280 | - | // Set up title bar click callback | 1296 | + | // Set up title bar click and close callbacks |
| 1281 | tr := stackCtx.wsView.TreeRenderer() | 1297 | tr := stackCtx.wsView.TreeRenderer() | ||
| 1282 | if tr != nil { | 1298 | if tr != nil { | ||
| 1283 | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) | 1299 | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) | ||
| @@ -1286,6 +1302,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1286 | stackedView.SetOnActivate(func(index int) { | 1302 | stackedView.SetOnActivate(func(index int) { | ||
| 1287 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | 1303 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | ||
| 1288 | }) | 1304 | }) | ||
| 1305 | + | stackedView.SetOnClosePane(func(paneID string) { | |||
| 1306 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |||
| 1307 | + | }) | |||
| 1289 | } | 1308 | } | ||
| 1290 | } | 1309 | } | ||
| 1291 | 1310 | ||||
| @@ -1531,6 +1550,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||||
| 1531 | // Update workspace view (also updates domain model) | 1550 | // Update workspace view (also updates domain model) | ||
| 1532 | _, wsView := c.getActiveWS() | 1551 | _, wsView := c.getActiveWS() | ||
| 1533 | if wsView != nil { | 1552 | if wsView != nil { | ||
| 1553 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |||
| 1554 | + | wsView.CancelAllPendingHovers() | |||
| 1555 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |||
| 1556 | + | ||||
| 1534 | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | 1557 | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | ||
| 1535 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | 1558 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | ||
| 1536 | } | 1559 | } | ||
| @@ -1543,6 +1566,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||||
| 1543 | Msg("switched active pane via title bar click") | 1566 | Msg("switched active pane via title bar click") | ||
| 1544 | } | 1567 | } | ||
| 1545 | 1568 | ||||
| 1569 | + | // onStackedPaneClose handles close button clicks on stacked pane title bars. | |||
| 1570 | + | func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) { | |||
| 1571 | + | log := logging.FromContext(ctx) | |||
| 1572 | + | ||||
| 1573 | + | log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button") | |||
| 1574 | + | ||||
| 1575 | + | if err := c.ClosePaneByID(ctx, paneID); err != nil { | |||
| 1576 | + | log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane") | |||
| 1577 | + | } | |||
| 1578 | + | } | |||
| 1579 | + | ||||
| 1546 | // --- Popup Insertion --- | 1580 | // --- Popup Insertion --- | ||
| 1547 | 1581 | ||||
| 1548 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | 1582 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | ||
| @@ -1836,6 +1870,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||||
| 1836 | stackedView.SetOnActivate(func(index int) { | 1870 | stackedView.SetOnActivate(func(index int) { | ||
| 1837 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | 1871 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | ||
| 1838 | }) | 1872 | }) | ||
| 1873 | + | stackedView.SetOnClosePane(func(paneID string) { | |||
| 1874 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |||
| 1875 | + | }) | |||
| 1839 | } | 1876 | } | ||
| 1840 | } | 1877 | } | ||
| 1841 | return nil | 1878 | return nil | ||
M internal/ui/focus/manager.go +35 -10
| @@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||
| 38 | 38 | // For panes in stacks, uses the stack container's geometry since individual | |
| 39 | 39 | // collapsed panes have no allocated size. | |
| 40 | 40 | // Only visible panes are included - for stacked panes, only the active one is visible. | |
| 41 | - | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | |
| 41 | + | func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect { | |
| 42 | + | log := logging.FromContext(ctx) | |
| 42 | 43 | var rects []entity.PaneRect | |
| 43 | 44 | ||
| 44 | 45 | container := provider.ContainerWidget() | |
| 45 | 46 | ||
| 46 | - | for _, paneID := range provider.GetPaneIDs() { | |
| 47 | + | allPaneIDs := provider.GetPaneIDs() | |
| 48 | + | log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting") | |
| 49 | + | ||
| 50 | + | for _, paneID := range allPaneIDs { | |
| 47 | 51 | paneWidget := provider.GetPaneWidget(paneID) | |
| 48 | 52 | ||
| 49 | 53 | // Skip panes that aren't visible (includes inactive stacked panes) | |
| 50 | 54 | if paneWidget == nil || !paneWidget.IsVisible() { | |
| 55 | + | log.Debug(). | |
| 56 | + | Str("pane_id", string(paneID)). | |
| 57 | + | Bool("widget_nil", paneWidget == nil). | |
| 58 | + | Msg("CollectPaneRects: skipping invisible pane") | |
| 51 | 59 | continue | |
| 52 | 60 | } | |
| 53 | 61 | ||
| @@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||
| 77 | 85 | continue | |
| 78 | 86 | } | |
| 79 | 87 | ||
| 88 | + | log.Debug(). | |
| 89 | + | Str("pane_id", string(paneID)). | |
| 90 | + | Int("x", int(x)). | |
| 91 | + | Int("y", int(y)). | |
| 92 | + | Int("w", w). | |
| 93 | + | Int("h", h). | |
| 94 | + | Msg("CollectPaneRects: added visible pane") | |
| 95 | + | ||
| 80 | 96 | rects = append(rects, entity.PaneRect{ | |
| 81 | 97 | PaneID: paneID, | |
| 82 | 98 | X: int(x), | |
| @@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||
| 86 | 102 | }) | |
| 87 | 103 | } | |
| 88 | 104 | ||
| 105 | + | log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done") | |
| 106 | + | ||
| 89 | 107 | return rects | |
| 90 | 108 | } | |
| 91 | 109 | ||
| @@ -119,8 +137,12 @@ func (m *Manager) NavigateGeometric( | |||
| 119 | 137 | stackNode := activeNode.Parent | |
| 120 | 138 | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | |
| 121 | 139 | if canNavigate { | |
| 122 | - | ws.ActivePaneID = newPaneID | |
| 123 | - | return ws.FindPane(newPaneID), nil | |
| 140 | + | // Apply focus change through use case (handles stack index updates) | |
| 141 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID) | |
| 142 | + | if err != nil { | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | return targetNode, nil | |
| 124 | 146 | } | |
| 125 | 147 | // At stack boundary - fall through to geometric navigation | |
| 126 | 148 | log.Debug().Msg("at stack boundary, using geometric navigation") | |
| @@ -128,7 +150,7 @@ func (m *Manager) NavigateGeometric( | |||
| 128 | 150 | } | |
| 129 | 151 | ||
| 130 | 152 | // Collect geometry from visible panes | |
| 131 | - | rects := m.CollectPaneRects(provider) | |
| 153 | + | rects := m.CollectPaneRects(ctx, provider) | |
| 132 | 154 | if len(rects) == 0 { | |
| 133 | 155 | return nil, nil | |
| 134 | 156 | } | |
| @@ -146,16 +168,20 @@ func (m *Manager) NavigateGeometric( | |||
| 146 | 168 | return nil, nil | |
| 147 | 169 | } | |
| 148 | 170 | ||
| 149 | - | // Update workspace | |
| 150 | - | ws.ActivePaneID = output.TargetPaneID | |
| 171 | + | // Apply focus change through use case (handles stack index updates) | |
| 172 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID) | |
| 173 | + | if err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 151 | 176 | ||
| 152 | - | // Return target node | |
| 153 | - | return ws.FindPane(output.TargetPaneID), nil | |
| 177 | + | return targetNode, nil | |
| 154 | 178 | } | |
| 155 | 179 | ||
| 156 | 180 | // navigateWithinStack tries to navigate within a stack. | |
| 157 | 181 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | |
| 158 | 182 | // boundary and navigation should escape the stack. | |
| 183 | + | // Note: This method does not modify domain state; the caller should use | |
| 184 | + | // ApplyFocusChange to apply the focus change. | |
| 159 | 185 | func (m *Manager) navigateWithinStack( | |
| 160 | 186 | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | |
| 161 | 187 | ) (bool, entity.PaneID) { | |
| @@ -173,7 +199,6 @@ func (m *Manager) navigateWithinStack( | |||
| 173 | 199 | return false, "" | |
| 174 | 200 | } | |
| 175 | 201 | ||
| 176 | - | stackNode.ActiveStackIndex = newIdx | |
| 177 | 202 | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | |
| 178 | 203 | } | |
| 179 | 204 | ||
| @@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||||
| 38 | // For panes in stacks, uses the stack container's geometry since individual | 38 | // For panes in stacks, uses the stack container's geometry since individual | ||
| 39 | // collapsed panes have no allocated size. | 39 | // collapsed panes have no allocated size. | ||
| 40 | // Only visible panes are included - for stacked panes, only the active one is visible. | 40 | // Only visible panes are included - for stacked panes, only the active one is visible. | ||
| 41 | - | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | 41 | + | func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect { |
| 42 | + | log := logging.FromContext(ctx) | |||
| 42 | var rects []entity.PaneRect | 43 | var rects []entity.PaneRect | ||
| 43 | 44 | ||||
| 44 | container := provider.ContainerWidget() | 45 | container := provider.ContainerWidget() | ||
| 45 | 46 | ||||
| 46 | - | for _, paneID := range provider.GetPaneIDs() { | 47 | + | allPaneIDs := provider.GetPaneIDs() |
| 48 | + | log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting") | |||
| 49 | + | ||||
| 50 | + | for _, paneID := range allPaneIDs { | |||
| 47 | paneWidget := provider.GetPaneWidget(paneID) | 51 | paneWidget := provider.GetPaneWidget(paneID) | ||
| 48 | 52 | ||||
| 49 | // Skip panes that aren't visible (includes inactive stacked panes) | 53 | // Skip panes that aren't visible (includes inactive stacked panes) | ||
| 50 | if paneWidget == nil || !paneWidget.IsVisible() { | 54 | if paneWidget == nil || !paneWidget.IsVisible() { | ||
| 55 | + | log.Debug(). | |||
| 56 | + | Str("pane_id", string(paneID)). | |||
| 57 | + | Bool("widget_nil", paneWidget == nil). | |||
| 58 | + | Msg("CollectPaneRects: skipping invisible pane") | |||
| 51 | continue | 59 | continue | ||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| @@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||||
| 77 | continue | 85 | continue | ||
| 78 | } | 86 | } | ||
| 79 | 87 | ||||
| 88 | + | log.Debug(). | |||
| 89 | + | Str("pane_id", string(paneID)). | |||
| 90 | + | Int("x", int(x)). | |||
| 91 | + | Int("y", int(y)). | |||
| 92 | + | Int("w", w). | |||
| 93 | + | Int("h", h). | |||
| 94 | + | Msg("CollectPaneRects: added visible pane") | |||
| 95 | + | ||||
| 80 | rects = append(rects, entity.PaneRect{ | 96 | rects = append(rects, entity.PaneRect{ | ||
| 81 | PaneID: paneID, | 97 | PaneID: paneID, | ||
| 82 | X: int(x), | 98 | X: int(x), | ||
| @@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||||
| 86 | }) | 102 | }) | ||
| 87 | } | 103 | } | ||
| 88 | 104 | ||||
| 105 | + | log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done") | |||
| 106 | + | ||||
| 89 | return rects | 107 | return rects | ||
| 90 | } | 108 | } | ||
| 91 | 109 | ||||
| @@ -119,8 +137,12 @@ func (m *Manager) NavigateGeometric( | |||||
| 119 | stackNode := activeNode.Parent | 137 | stackNode := activeNode.Parent | ||
| 120 | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | 138 | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | ||
| 121 | if canNavigate { | 139 | if canNavigate { | ||
| 122 | - | ws.ActivePaneID = newPaneID | 140 | + | // Apply focus change through use case (handles stack index updates) |
| 123 | - | return ws.FindPane(newPaneID), nil | 141 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID) |
| 142 | + | if err != nil { | |||
| 143 | + | return nil, err | |||
| 144 | + | } | |||
| 145 | + | return targetNode, nil | |||
| 124 | } | 146 | } | ||
| 125 | // At stack boundary - fall through to geometric navigation | 147 | // At stack boundary - fall through to geometric navigation | ||
| 126 | log.Debug().Msg("at stack boundary, using geometric navigation") | 148 | log.Debug().Msg("at stack boundary, using geometric navigation") | ||
| @@ -128,7 +150,7 @@ func (m *Manager) NavigateGeometric( | |||||
| 128 | } | 150 | } | ||
| 129 | 151 | ||||
| 130 | // Collect geometry from visible panes | 152 | // Collect geometry from visible panes | ||
| 131 | - | rects := m.CollectPaneRects(provider) | 153 | + | rects := m.CollectPaneRects(ctx, provider) |
| 132 | if len(rects) == 0 { | 154 | if len(rects) == 0 { | ||
| 133 | return nil, nil | 155 | return nil, nil | ||
| 134 | } | 156 | } | ||
| @@ -146,16 +168,20 @@ func (m *Manager) NavigateGeometric( | |||||
| 146 | return nil, nil | 168 | return nil, nil | ||
| 147 | } | 169 | } | ||
| 148 | 170 | ||||
| 149 | - | // Update workspace | 171 | + | // Apply focus change through use case (handles stack index updates) |
| 150 | - | ws.ActivePaneID = output.TargetPaneID | 172 | + | targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID) |
| 173 | + | if err != nil { | |||
| 174 | + | return nil, err | |||
| 175 | + | } | |||
| 151 | 176 | ||||
| 152 | - | // Return target node | 177 | + | return targetNode, nil |
| 153 | - | return ws.FindPane(output.TargetPaneID), nil | |||
| 154 | } | 178 | } | ||
| 155 | 179 | ||||
| 156 | // navigateWithinStack tries to navigate within a stack. | 180 | // navigateWithinStack tries to navigate within a stack. | ||
| 157 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | 181 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | ||
| 158 | // boundary and navigation should escape the stack. | 182 | // boundary and navigation should escape the stack. | ||
| 183 | + | // Note: This method does not modify domain state; the caller should use | |||
| 184 | + | // ApplyFocusChange to apply the focus change. | |||
| 159 | func (m *Manager) navigateWithinStack( | 185 | func (m *Manager) navigateWithinStack( | ||
| 160 | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | 186 | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | ||
| 161 | ) (bool, entity.PaneID) { | 187 | ) (bool, entity.PaneID) { | ||
| @@ -173,7 +199,6 @@ func (m *Manager) navigateWithinStack( | |||||
| 173 | return false, "" | 199 | return false, "" | ||
| 174 | } | 200 | } | ||
| 175 | 201 | ||||
| 176 | - | stackNode.ActiveStackIndex = newIdx | |||
| 177 | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | 202 | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | ||
| 178 | } | 203 | } | ||
| 179 | 204 | ||||
M internal/ui/focus/manager_test.go +57 -61
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses | |
| 13 | 13 | // ActiveStackIndex from the domain model instead of pointer comparison. | |
| 14 | + | // Note: navigateWithinStack is a pure function that returns the target pane ID | |
| 15 | + | // without modifying state. The actual state mutation happens in ApplyFocusChange. | |
| 14 | 16 | func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |
| 15 | 17 | // Create a stack with 3 children | |
| 16 | 18 | pane0 := &entity.Pane{ID: "pane0"} | |
| @@ -36,60 +38,53 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |||
| 36 | 38 | m := NewManager(nil) | |
| 37 | 39 | ||
| 38 | 40 | tests := []struct { | |
| 39 | - | name string | |
| 40 | - | activeIndex int | |
| 41 | - | direction usecase.NavigateDirection | |
| 42 | - | wantCanNav bool | |
| 43 | - | wantPaneID entity.PaneID | |
| 44 | - | wantNewIndex int | |
| 41 | + | name string | |
| 42 | + | activeIndex int | |
| 43 | + | direction usecase.NavigateDirection | |
| 44 | + | wantCanNav bool | |
| 45 | + | wantPaneID entity.PaneID | |
| 45 | 46 | }{ | |
| 46 | 47 | { | |
| 47 | - | name: "navigate down from middle", | |
| 48 | - | activeIndex: 1, | |
| 49 | - | direction: usecase.NavDown, | |
| 50 | - | wantCanNav: true, | |
| 51 | - | wantPaneID: "pane2", | |
| 52 | - | wantNewIndex: 2, | |
| 48 | + | name: "navigate down from middle", | |
| 49 | + | activeIndex: 1, | |
| 50 | + | direction: usecase.NavDown, | |
| 51 | + | wantCanNav: true, | |
| 52 | + | wantPaneID: "pane2", | |
| 53 | 53 | }, | |
| 54 | 54 | { | |
| 55 | - | name: "navigate up from middle", | |
| 56 | - | activeIndex: 1, | |
| 57 | - | direction: usecase.NavUp, | |
| 58 | - | wantCanNav: true, | |
| 59 | - | wantPaneID: "pane0", | |
| 60 | - | wantNewIndex: 0, | |
| 55 | + | name: "navigate up from middle", | |
| 56 | + | activeIndex: 1, | |
| 57 | + | direction: usecase.NavUp, | |
| 58 | + | wantCanNav: true, | |
| 59 | + | wantPaneID: "pane0", | |
| 61 | 60 | }, | |
| 62 | 61 | { | |
| 63 | - | name: "navigate down from last - boundary", | |
| 64 | - | activeIndex: 2, | |
| 65 | - | direction: usecase.NavDown, | |
| 66 | - | wantCanNav: false, | |
| 67 | - | wantPaneID: "", | |
| 68 | - | wantNewIndex: 2, // unchanged | |
| 62 | + | name: "navigate down from last - boundary", | |
| 63 | + | activeIndex: 2, | |
| 64 | + | direction: usecase.NavDown, | |
| 65 | + | wantCanNav: false, | |
| 66 | + | wantPaneID: "", | |
| 69 | 67 | }, | |
| 70 | 68 | { | |
| 71 | - | name: "navigate up from first - boundary", | |
| 72 | - | activeIndex: 0, | |
| 73 | - | direction: usecase.NavUp, | |
| 74 | - | wantCanNav: false, | |
| 75 | - | wantPaneID: "", | |
| 76 | - | wantNewIndex: 0, // unchanged | |
| 69 | + | name: "navigate up from first - boundary", | |
| 70 | + | activeIndex: 0, | |
| 71 | + | direction: usecase.NavUp, | |
| 72 | + | wantCanNav: false, | |
| 73 | + | wantPaneID: "", | |
| 77 | 74 | }, | |
| 78 | 75 | { | |
| 79 | - | name: "navigate down from first", | |
| 80 | - | activeIndex: 0, | |
| 81 | - | direction: usecase.NavDown, | |
| 82 | - | wantCanNav: true, | |
| 83 | - | wantPaneID: "pane1", | |
| 84 | - | wantNewIndex: 1, | |
| 76 | + | name: "navigate down from first", | |
| 77 | + | activeIndex: 0, | |
| 78 | + | direction: usecase.NavDown, | |
| 79 | + | wantCanNav: true, | |
| 80 | + | wantPaneID: "pane1", | |
| 85 | 81 | }, | |
| 86 | 82 | { | |
| 87 | - | name: "navigate up from last", | |
| 88 | - | activeIndex: 2, | |
| 89 | - | direction: usecase.NavUp, | |
| 90 | - | wantCanNav: true, | |
| 91 | - | wantPaneID: "pane1", | |
| 92 | - | wantNewIndex: 1, | |
| 83 | + | name: "navigate up from last", | |
| 84 | + | activeIndex: 2, | |
| 85 | + | direction: usecase.NavUp, | |
| 86 | + | wantCanNav: true, | |
| 87 | + | wantPaneID: "pane1", | |
| 93 | 88 | }, | |
| 94 | 89 | } | |
| 95 | 90 | ||
| @@ -98,16 +93,11 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |||
| 98 | 93 | // Set up the active index | |
| 99 | 94 | stackNode.ActiveStackIndex = tt.activeIndex | |
| 100 | 95 | ||
| 101 | - | // Call the private function through reflection or test it indirectly | |
| 102 | - | // Since navigateWithinStack is private, we test its behavior | |
| 96 | + | // navigateWithinStack is a pure function - it does not modify state | |
| 103 | 97 | canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction) | |
| 104 | 98 | ||
| 105 | 99 | assert.Equal(t, tt.wantCanNav, canNav) | |
| 106 | 100 | assert.Equal(t, tt.wantPaneID, paneID) | |
| 107 | - | ||
| 108 | - | if canNav { | |
| 109 | - | assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex) | |
| 110 | - | } | |
| 111 | 101 | }) | |
| 112 | 102 | } | |
| 113 | 103 | } | |
| @@ -193,9 +183,10 @@ func TestNavigateWithinStack_InvalidDirection(t *testing.T) { | |||
| 193 | 183 | assert.Empty(t, paneID) | |
| 194 | 184 | } | |
| 195 | 185 | ||
| 196 | - | // TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly | |
| 197 | - | // updates the ActiveStackIndex on the stack node. | |
| 198 | - | func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) { | |
| 186 | + | // TestNavigateWithinStack_ReturnsCorrectPaneID tests that navigation returns | |
| 187 | + | // the correct target pane ID for sequential navigation. | |
| 188 | + | // Note: navigateWithinStack is now a pure function - state updates happen in ApplyFocusChange. | |
| 189 | + | func TestNavigateWithinStack_ReturnsCorrectPaneID(t *testing.T) { | |
| 199 | 190 | pane0 := &entity.Pane{ID: "pane0"} | |
| 200 | 191 | pane1 := &entity.Pane{ID: "pane1"} | |
| 201 | 192 | pane2 := &entity.Pane{ID: "pane2"} | |
| @@ -213,22 +204,27 @@ func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) { | |||
| 213 | 204 | ||
| 214 | 205 | m := NewManager(nil) | |
| 215 | 206 | ||
| 216 | - | // Navigate down twice | |
| 207 | + | // Navigate down from first - should return pane1 | |
| 217 | 208 | stackNode.ActiveStackIndex = 0 | |
| 218 | - | canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | |
| 209 | + | canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | |
| 219 | 210 | assert.True(t, canNav) | |
| 220 | - | assert.Equal(t, 1, stackNode.ActiveStackIndex) | |
| 211 | + | assert.Equal(t, entity.PaneID("pane1"), paneID) | |
| 221 | 212 | ||
| 222 | - | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown) | |
| 213 | + | // Navigate down from middle - should return pane2 | |
| 214 | + | stackNode.ActiveStackIndex = 1 | |
| 215 | + | canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavDown) | |
| 223 | 216 | assert.True(t, canNav) | |
| 224 | - | assert.Equal(t, 2, stackNode.ActiveStackIndex) | |
| 217 | + | assert.Equal(t, entity.PaneID("pane2"), paneID) | |
| 225 | 218 | ||
| 226 | - | // Navigate up twice | |
| 227 | - | canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp) | |
| 219 | + | // Navigate up from last - should return pane1 | |
| 220 | + | stackNode.ActiveStackIndex = 2 | |
| 221 | + | canNav, paneID = m.navigateWithinStack(stackNode, child2, usecase.NavUp) | |
| 228 | 222 | assert.True(t, canNav) | |
| 229 | - | assert.Equal(t, 1, stackNode.ActiveStackIndex) | |
| 223 | + | assert.Equal(t, entity.PaneID("pane1"), paneID) | |
| 230 | 224 | ||
| 231 | - | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp) | |
| 225 | + | // Navigate up from middle - should return pane0 | |
| 226 | + | stackNode.ActiveStackIndex = 1 | |
| 227 | + | canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavUp) | |
| 232 | 228 | assert.True(t, canNav) | |
| 233 | - | assert.Equal(t, 0, stackNode.ActiveStackIndex) | |
| 229 | + | assert.Equal(t, entity.PaneID("pane0"), paneID) | |
| 234 | 230 | } | |
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses | 12 | // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses | ||
| 13 | // ActiveStackIndex from the domain model instead of pointer comparison. | 13 | // ActiveStackIndex from the domain model instead of pointer comparison. | ||
| 14 | + | // Note: navigateWithinStack is a pure function that returns the target pane ID | |||
| 15 | + | // without modifying state. The actual state mutation happens in ApplyFocusChange. | |||
| 14 | func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | 16 | func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | ||
| 15 | // Create a stack with 3 children | 17 | // Create a stack with 3 children | ||
| 16 | pane0 := &entity.Pane{ID: "pane0"} | 18 | pane0 := &entity.Pane{ID: "pane0"} | ||
| @@ -36,60 +38,53 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |||||
| 36 | m := NewManager(nil) | 38 | m := NewManager(nil) | ||
| 37 | 39 | ||||
| 38 | tests := []struct { | 40 | tests := []struct { | ||
| 39 | - | name string | 41 | + | name string |
| 40 | - | activeIndex int | 42 | + | activeIndex int |
| 41 | - | direction usecase.NavigateDirection | 43 | + | direction usecase.NavigateDirection |
| 42 | - | wantCanNav bool | 44 | + | wantCanNav bool |
| 43 | - | wantPaneID entity.PaneID | 45 | + | wantPaneID entity.PaneID |
| 44 | - | wantNewIndex int | |||
| 45 | }{ | 46 | }{ | ||
| 46 | { | 47 | { | ||
| 47 | - | name: "navigate down from middle", | 48 | + | name: "navigate down from middle", |
| 48 | - | activeIndex: 1, | 49 | + | activeIndex: 1, |
| 49 | - | direction: usecase.NavDown, | 50 | + | direction: usecase.NavDown, |
| 50 | - | wantCanNav: true, | 51 | + | wantCanNav: true, |
| 51 | - | wantPaneID: "pane2", | 52 | + | wantPaneID: "pane2", |
| 52 | - | wantNewIndex: 2, | |||
| 53 | }, | 53 | }, | ||
| 54 | { | 54 | { | ||
| 55 | - | name: "navigate up from middle", | 55 | + | name: "navigate up from middle", |
| 56 | - | activeIndex: 1, | 56 | + | activeIndex: 1, |
| 57 | - | direction: usecase.NavUp, | 57 | + | direction: usecase.NavUp, |
| 58 | - | wantCanNav: true, | 58 | + | wantCanNav: true, |
| 59 | - | wantPaneID: "pane0", | 59 | + | wantPaneID: "pane0", |
| 60 | - | wantNewIndex: 0, | |||
| 61 | }, | 60 | }, | ||
| 62 | { | 61 | { | ||
| 63 | - | name: "navigate down from last - boundary", | 62 | + | name: "navigate down from last - boundary", |
| 64 | - | activeIndex: 2, | 63 | + | activeIndex: 2, |
| 65 | - | direction: usecase.NavDown, | 64 | + | direction: usecase.NavDown, |
| 66 | - | wantCanNav: false, | 65 | + | wantCanNav: false, |
| 67 | - | wantPaneID: "", | 66 | + | wantPaneID: "", |
| 68 | - | wantNewIndex: 2, // unchanged | |||
| 69 | }, | 67 | }, | ||
| 70 | { | 68 | { | ||
| 71 | - | name: "navigate up from first - boundary", | 69 | + | name: "navigate up from first - boundary", |
| 72 | - | activeIndex: 0, | 70 | + | activeIndex: 0, |
| 73 | - | direction: usecase.NavUp, | 71 | + | direction: usecase.NavUp, |
| 74 | - | wantCanNav: false, | 72 | + | wantCanNav: false, |
| 75 | - | wantPaneID: "", | 73 | + | wantPaneID: "", |
| 76 | - | wantNewIndex: 0, // unchanged | |||
| 77 | }, | 74 | }, | ||
| 78 | { | 75 | { | ||
| 79 | - | name: "navigate down from first", | 76 | + | name: "navigate down from first", |
| 80 | - | activeIndex: 0, | 77 | + | activeIndex: 0, |
| 81 | - | direction: usecase.NavDown, | 78 | + | direction: usecase.NavDown, |
| 82 | - | wantCanNav: true, | 79 | + | wantCanNav: true, |
| 83 | - | wantPaneID: "pane1", | 80 | + | wantPaneID: "pane1", |
| 84 | - | wantNewIndex: 1, | |||
| 85 | }, | 81 | }, | ||
| 86 | { | 82 | { | ||
| 87 | - | name: "navigate up from last", | 83 | + | name: "navigate up from last", |
| 88 | - | activeIndex: 2, | 84 | + | activeIndex: 2, |
| 89 | - | direction: usecase.NavUp, | 85 | + | direction: usecase.NavUp, |
| 90 | - | wantCanNav: true, | 86 | + | wantCanNav: true, |
| 91 | - | wantPaneID: "pane1", | 87 | + | wantPaneID: "pane1", |
| 92 | - | wantNewIndex: 1, | |||
| 93 | }, | 88 | }, | ||
| 94 | } | 89 | } | ||
| 95 | 90 | ||||
| @@ -98,16 +93,11 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |||||
| 98 | // Set up the active index | 93 | // Set up the active index | ||
| 99 | stackNode.ActiveStackIndex = tt.activeIndex | 94 | stackNode.ActiveStackIndex = tt.activeIndex | ||
| 100 | 95 | ||||
| 101 | - | // Call the private function through reflection or test it indirectly | 96 | + | // navigateWithinStack is a pure function - it does not modify state |
| 102 | - | // Since navigateWithinStack is private, we test its behavior | |||
| 103 | canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction) | 97 | canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction) | ||
| 104 | 98 | ||||
| 105 | assert.Equal(t, tt.wantCanNav, canNav) | 99 | assert.Equal(t, tt.wantCanNav, canNav) | ||
| 106 | assert.Equal(t, tt.wantPaneID, paneID) | 100 | assert.Equal(t, tt.wantPaneID, paneID) | ||
| 107 | - | ||||
| 108 | - | if canNav { | |||
| 109 | - | assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex) | |||
| 110 | - | } | |||
| 111 | }) | 101 | }) | ||
| 112 | } | 102 | } | ||
| 113 | } | 103 | } | ||
| @@ -193,9 +183,10 @@ func TestNavigateWithinStack_InvalidDirection(t *testing.T) { | |||||
| 193 | assert.Empty(t, paneID) | 183 | assert.Empty(t, paneID) | ||
| 194 | } | 184 | } | ||
| 195 | 185 | ||||
| 196 | - | // TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly | 186 | + | // TestNavigateWithinStack_ReturnsCorrectPaneID tests that navigation returns |
| 197 | - | // updates the ActiveStackIndex on the stack node. | 187 | + | // the correct target pane ID for sequential navigation. |
| 198 | - | func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) { | 188 | + | // Note: navigateWithinStack is now a pure function - state updates happen in ApplyFocusChange. |
| 189 | + | func TestNavigateWithinStack_ReturnsCorrectPaneID(t *testing.T) { | |||
| 199 | pane0 := &entity.Pane{ID: "pane0"} | 190 | pane0 := &entity.Pane{ID: "pane0"} | ||
| 200 | pane1 := &entity.Pane{ID: "pane1"} | 191 | pane1 := &entity.Pane{ID: "pane1"} | ||
| 201 | pane2 := &entity.Pane{ID: "pane2"} | 192 | pane2 := &entity.Pane{ID: "pane2"} | ||
| @@ -213,22 +204,27 @@ func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) { | |||||
| 213 | 204 | ||||
| 214 | m := NewManager(nil) | 205 | m := NewManager(nil) | ||
| 215 | 206 | ||||
| 216 | - | // Navigate down twice | 207 | + | // Navigate down from first - should return pane1 |
| 217 | stackNode.ActiveStackIndex = 0 | 208 | stackNode.ActiveStackIndex = 0 | ||
| 218 | - | canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | 209 | + | canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown) |
| 219 | assert.True(t, canNav) | 210 | assert.True(t, canNav) | ||
| 220 | - | assert.Equal(t, 1, stackNode.ActiveStackIndex) | 211 | + | assert.Equal(t, entity.PaneID("pane1"), paneID) |
| 221 | 212 | ||||
| 222 | - | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown) | 213 | + | // Navigate down from middle - should return pane2 |
| 214 | + | stackNode.ActiveStackIndex = 1 | |||
| 215 | + | canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavDown) | |||
| 223 | assert.True(t, canNav) | 216 | assert.True(t, canNav) | ||
| 224 | - | assert.Equal(t, 2, stackNode.ActiveStackIndex) | 217 | + | assert.Equal(t, entity.PaneID("pane2"), paneID) |
| 225 | 218 | ||||
| 226 | - | // Navigate up twice | 219 | + | // Navigate up from last - should return pane1 |
| 227 | - | canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp) | 220 | + | stackNode.ActiveStackIndex = 2 |
| 221 | + | canNav, paneID = m.navigateWithinStack(stackNode, child2, usecase.NavUp) | |||
| 228 | assert.True(t, canNav) | 222 | assert.True(t, canNav) | ||
| 229 | - | assert.Equal(t, 1, stackNode.ActiveStackIndex) | 223 | + | assert.Equal(t, entity.PaneID("pane1"), paneID) |
| 230 | 224 | ||||
| 231 | - | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp) | 225 | + | // Navigate up from middle - should return pane0 |
| 226 | + | stackNode.ActiveStackIndex = 1 | |||
| 227 | + | canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavUp) | |||
| 232 | assert.True(t, canNav) | 228 | assert.True(t, canNav) | ||
| 233 | - | assert.Equal(t, 0, stackNode.ActiveStackIndex) | 229 | + | assert.Equal(t, entity.PaneID("pane0"), paneID) |
| 234 | } | 230 | } | ||
M internal/ui/input/hover.go +6 -0
| @@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() { | |||
| 115 | 115 | ||
| 116 | 116 | // handleLeave cancels any pending focus switch. | |
| 117 | 117 | func (h *HoverHandler) handleLeave() { | |
| 118 | + | h.Cancel() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Cancel cancels any pending focus switch. | |
| 122 | + | // This can be called externally to cancel hover when keyboard navigation occurs. | |
| 123 | + | func (h *HoverHandler) Cancel() { | |
| 118 | 124 | h.timerMu.Lock() | |
| 119 | 125 | defer h.timerMu.Unlock() | |
| 120 | 126 | ||
| @@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() { | |||||
| 115 | 115 | ||||
| 116 | // handleLeave cancels any pending focus switch. | 116 | // handleLeave cancels any pending focus switch. | ||
| 117 | func (h *HoverHandler) handleLeave() { | 117 | func (h *HoverHandler) handleLeave() { | ||
| 118 | + | h.Cancel() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Cancel cancels any pending focus switch. | |||
| 122 | + | // This can be called externally to cancel hover when keyboard navigation occurs. | |||
| 123 | + | func (h *HoverHandler) Cancel() { | |||
| 118 | h.timerMu.Lock() | 124 | h.timerMu.Lock() | ||
| 119 | defer h.timerMu.Unlock() | 125 | defer h.timerMu.Unlock() | ||
| 120 | 126 | ||||
M internal/ui/layout/gtk_widgets.go +3 -2
| @@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget { | |||
| 519 | 519 | return >kWidget{inner: parent} | |
| 520 | 520 | } | |
| 521 | 521 | ||
| 522 | - | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } | |
| 523 | - | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } | |
| 522 | + | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } | |
| 523 | + | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } | |
| 524 | + | func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) } | |
| 524 | 525 | ||
| 525 | 526 | func (btn *gtkButton) SetChild(child Widget) { | |
| 526 | 527 | if child == nil { | |
| @@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget { | |||||
| 519 | return >kWidget{inner: parent} | 519 | return >kWidget{inner: parent} | ||
| 520 | } | 520 | } | ||
| 521 | 521 | ||||
| 522 | - | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } | 522 | + | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } |
| 523 | - | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } | 523 | + | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } |
| 524 | + | func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) } | |||
| 524 | 525 | ||||
| 525 | func (btn *gtkButton) SetChild(child Widget) { | 526 | func (btn *gtkButton) SetChild(child Widget) { | ||
| 526 | if child == nil { | 527 | if child == nil { | ||
M internal/ui/layout/mocks/mocks.go +40 -0
| @@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) | |||
| 8951 | 8951 | return _c | |
| 8952 | 8952 | } | |
| 8953 | 8953 | ||
| 8954 | + | // SetIconName provides a mock function for the type MockButtonWidget | |
| 8955 | + | func (_mock *MockButtonWidget) SetIconName(iconName string) { | |
| 8956 | + | _mock.Called(iconName) | |
| 8957 | + | return | |
| 8958 | + | } | |
| 8959 | + | ||
| 8960 | + | // MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName' | |
| 8961 | + | type MockButtonWidget_SetIconName_Call struct { | |
| 8962 | + | *mock.Call | |
| 8963 | + | } | |
| 8964 | + | ||
| 8965 | + | // SetIconName is a helper method to define mock.On call | |
| 8966 | + | // - iconName string | |
| 8967 | + | func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call { | |
| 8968 | + | return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)} | |
| 8969 | + | } | |
| 8970 | + | ||
| 8971 | + | func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |
| 8972 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 8973 | + | var arg0 string | |
| 8974 | + | if args[0] != nil { | |
| 8975 | + | arg0 = args[0].(string) | |
| 8976 | + | } | |
| 8977 | + | run( | |
| 8978 | + | arg0, | |
| 8979 | + | ) | |
| 8980 | + | }) | |
| 8981 | + | return _c | |
| 8982 | + | } | |
| 8983 | + | ||
| 8984 | + | func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call { | |
| 8985 | + | _c.Call.Return() | |
| 8986 | + | return _c | |
| 8987 | + | } | |
| 8988 | + | ||
| 8989 | + | func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |
| 8990 | + | _c.Run(run) | |
| 8991 | + | return _c | |
| 8992 | + | } | |
| 8993 | + | ||
| 8954 | 8994 | // SetLabel provides a mock function for the type MockButtonWidget | |
| 8955 | 8995 | func (_mock *MockButtonWidget) SetLabel(label string) { | |
| 8956 | 8996 | _mock.Called(label) | |
| @@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) | |||||
| 8951 | return _c | 8951 | return _c | ||
| 8952 | } | 8952 | } | ||
| 8953 | 8953 | ||||
| 8954 | + | // SetIconName provides a mock function for the type MockButtonWidget | |||
| 8955 | + | func (_mock *MockButtonWidget) SetIconName(iconName string) { | |||
| 8956 | + | _mock.Called(iconName) | |||
| 8957 | + | return | |||
| 8958 | + | } | |||
| 8959 | + | ||||
| 8960 | + | // MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName' | |||
| 8961 | + | type MockButtonWidget_SetIconName_Call struct { | |||
| 8962 | + | *mock.Call | |||
| 8963 | + | } | |||
| 8964 | + | ||||
| 8965 | + | // SetIconName is a helper method to define mock.On call | |||
| 8966 | + | // - iconName string | |||
| 8967 | + | func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call { | |||
| 8968 | + | return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)} | |||
| 8969 | + | } | |||
| 8970 | + | ||||
| 8971 | + | func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |||
| 8972 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 8973 | + | var arg0 string | |||
| 8974 | + | if args[0] != nil { | |||
| 8975 | + | arg0 = args[0].(string) | |||
| 8976 | + | } | |||
| 8977 | + | run( | |||
| 8978 | + | arg0, | |||
| 8979 | + | ) | |||
| 8980 | + | }) | |||
| 8981 | + | return _c | |||
| 8982 | + | } | |||
| 8983 | + | ||||
| 8984 | + | func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call { | |||
| 8985 | + | _c.Call.Return() | |||
| 8986 | + | return _c | |||
| 8987 | + | } | |||
| 8988 | + | ||||
| 8989 | + | func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |||
| 8990 | + | _c.Run(run) | |||
| 8991 | + | return _c | |||
| 8992 | + | } | |||
| 8993 | + | ||||
| 8954 | // SetLabel provides a mock function for the type MockButtonWidget | 8994 | // SetLabel provides a mock function for the type MockButtonWidget | ||
| 8955 | func (_mock *MockButtonWidget) SetLabel(label string) { | 8995 | func (_mock *MockButtonWidget) SetLabel(label string) { | ||
| 8956 | _mock.Called(label) | 8996 | _mock.Called(label) | ||
M internal/ui/layout/stacked.go +239 -124
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gobject" | |
| 10 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // ErrStackEmpty is returned when operating on an empty stack. | |
| @@ -17,7 +19,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds") | |||
| 17 | 19 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | |
| 18 | 20 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | |
| 19 | 21 | ||
| 20 | - | const stackedTitleMaxWidthChars = 30 | |
| 22 | + | const ( | |
| 23 | + | stackedTitleMaxWidthChars = 30 | |
| 24 | + | stackedPaneCloseIcon = "window-close-symbolic" | |
| 25 | + | ) | |
| 21 | 26 | ||
| 22 | 27 | // stackedPane represents a single pane within a stacked container. | |
| 23 | 28 | type stackedPane struct { | |
| @@ -28,6 +33,13 @@ type stackedPane struct { | |||
| 28 | 33 | favicon ImageWidget | |
| 29 | 34 | label LabelWidget | |
| 30 | 35 | isActive bool | |
| 36 | + | ||
| 37 | + | // Signal handler IDs for cleanup on removal | |
| 38 | + | closeClickSignalID uint32 | |
| 39 | + | closeButton ButtonWidget // stored for signal disconnection | |
| 40 | + | ||
| 41 | + | // Retained callback for GestureClick to prevent GC | |
| 42 | + | titleClickCallback interface{} | |
| 31 | 43 | } | |
| 32 | 44 | ||
| 33 | 45 | // StackedView manages a stack of panes where only one is visible at a time. | |
| @@ -38,7 +50,8 @@ type StackedView struct { | |||
| 38 | 50 | panes []*stackedPane | |
| 39 | 51 | activeIndex int | |
| 40 | 52 | ||
| 41 | - | onActivate func(index int) // called when a pane is activated via title bar click | |
| 53 | + | onActivate func(index int) // called when a pane is activated via title bar click | |
| 54 | + | onClosePane func(paneID string) // called when a pane's close button is clicked | |
| 42 | 55 | ||
| 43 | 56 | mu sync.RWMutex | |
| 44 | 57 | } | |
| @@ -57,23 +70,23 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||
| 57 | 70 | } | |
| 58 | 71 | } | |
| 59 | 72 | ||
| 60 | - | // AddPane adds a new pane to the stack. | |
| 61 | - | // The new pane becomes active (visible). | |
| 62 | - | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | |
| 63 | - | log := logging.FromContext(ctx) | |
| 64 | - | sv.mu.Lock() | |
| 65 | - | defer sv.mu.Unlock() | |
| 66 | - | ||
| 67 | - | log.Debug(). | |
| 68 | - | Str("title", title). | |
| 69 | - | Bool("container_nil", container == nil). | |
| 70 | - | Int("current_count", len(sv.panes)). | |
| 71 | - | Msg("StackedView.AddPane called") | |
| 73 | + | // titleBarComponents holds the widgets created for a pane's title bar. | |
| 74 | + | type titleBarComponents struct { | |
| 75 | + | titleBar BoxWidget | |
| 76 | + | closeBtn ButtonWidget | |
| 77 | + | favicon ImageWidget | |
| 78 | + | label LabelWidget | |
| 79 | + | } | |
| 72 | 80 | ||
| 73 | - | // Create title bar - must not expand vertically | |
| 81 | + | // createTitleBar creates the title bar widgets for a pane. | |
| 82 | + | func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents { | |
| 83 | + | // Create title bar container - must not expand vertically | |
| 84 | + | // This is now the clickable container directly (using GestureClick), not wrapped in a button | |
| 74 | 85 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | |
| 75 | 86 | titleBar.AddCssClass("stacked-pane-titlebar") | |
| 87 | + | titleBar.AddCssClass("stacked-pane-title-clickable") // For hover styling | |
| 76 | 88 | titleBar.SetVexpand(false) | |
| 89 | + | titleBar.SetHexpand(true) // Fill horizontal space | |
| 77 | 90 | ||
| 78 | 91 | // Create favicon image | |
| 79 | 92 | favicon := sv.factory.NewImage() | |
| @@ -93,43 +106,63 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||
| 93 | 106 | label.SetXalign(0.0) | |
| 94 | 107 | titleBar.Append(label) | |
| 95 | 108 | ||
| 96 | - | // Make title bar clickable - ensure it doesn't expand vertically | |
| 97 | - | titleButton := sv.factory.NewButton() | |
| 98 | - | titleButton.SetChild(titleBar) | |
| 99 | - | titleButton.AddCssClass("stacked-pane-title-button") | |
| 100 | - | titleButton.SetFocusOnClick(false) | |
| 101 | - | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | |
| 102 | - | titleButton.SetHexpand(true) // But fill horizontal space | |
| 109 | + | // Create close button using GTK's native icon button support | |
| 110 | + | closeBtn := sv.factory.NewButton() | |
| 111 | + | closeBtn.SetIconName(stackedPaneCloseIcon) | |
| 112 | + | closeBtn.AddCssClass("stacked-pane-close-button") | |
| 113 | + | closeBtn.SetFocusOnClick(false) | |
| 114 | + | closeBtn.SetVexpand(false) | |
| 115 | + | closeBtn.SetHexpand(false) | |
| 116 | + | titleBar.Append(closeBtn) | |
| 117 | + | ||
| 118 | + | return titleBarComponents{ | |
| 119 | + | titleBar: titleBar, | |
| 120 | + | closeBtn: closeBtn, | |
| 121 | + | favicon: favicon, | |
| 122 | + | label: label, | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // AddPane adds a new pane to the stack. | |
| 127 | + | // The new pane becomes active (visible). | |
| 128 | + | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | |
| 129 | + | log := logging.FromContext(ctx) | |
| 130 | + | sv.mu.Lock() | |
| 131 | + | defer sv.mu.Unlock() | |
| 132 | + | ||
| 133 | + | log.Debug(). | |
| 134 | + | Str("title", title). | |
| 135 | + | Bool("container_nil", container == nil). | |
| 136 | + | Int("current_count", len(sv.panes)). | |
| 137 | + | Msg("StackedView.AddPane called") | |
| 138 | + | ||
| 139 | + | // Create title bar components | |
| 140 | + | tb := sv.createTitleBar(title, faviconIconName) | |
| 141 | + | ||
| 142 | + | // Connect click handlers using paneID (not index, to handle removals) | |
| 143 | + | titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID) | |
| 103 | 144 | ||
| 104 | 145 | pane := &stackedPane{ | |
| 105 | - | paneID: paneID, | |
| 106 | - | titleBar: titleBar, | |
| 107 | - | container: container, | |
| 108 | - | title: title, | |
| 109 | - | favicon: favicon, | |
| 110 | - | label: label, | |
| 111 | - | isActive: false, | |
| 146 | + | paneID: paneID, | |
| 147 | + | titleBar: tb.titleBar, | |
| 148 | + | container: container, | |
| 149 | + | title: title, | |
| 150 | + | favicon: tb.favicon, | |
| 151 | + | label: tb.label, | |
| 152 | + | isActive: false, | |
| 153 | + | closeClickSignalID: closeSignalID, | |
| 154 | + | closeButton: tb.closeBtn, | |
| 155 | + | titleClickCallback: titleClickCb, | |
| 112 | 156 | } | |
| 113 | 157 | ||
| 114 | 158 | index := len(sv.panes) | |
| 115 | 159 | sv.panes = append(sv.panes, pane) | |
| 116 | 160 | ||
| 117 | - | // Connect click handler | |
| 118 | - | titleButton.ConnectClicked(func() { | |
| 119 | - | sv.mu.RLock() | |
| 120 | - | callback := sv.onActivate | |
| 121 | - | sv.mu.RUnlock() | |
| 122 | - | ||
| 123 | - | if callback != nil { | |
| 124 | - | callback(index) | |
| 125 | - | } | |
| 126 | - | }) | |
| 127 | - | ||
| 128 | - | // Add to container | |
| 161 | + | // Add to container - now we add the titleBar directly (not wrapped in a button) | |
| 129 | 162 | log.Debug(). | |
| 130 | 163 | Int("index", index). | |
| 131 | - | Msg("StackedView: appending titleButton and container to box") | |
| 132 | - | sv.box.Append(titleButton) | |
| 164 | + | Msg("StackedView: appending titleBar and container to box") | |
| 165 | + | sv.box.Append(tb.titleBar) | |
| 133 | 166 | if container != nil { | |
| 134 | 167 | sv.box.Append(container) | |
| 135 | 168 | } | |
| @@ -145,6 +178,106 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||
| 145 | 178 | return index | |
| 146 | 179 | } | |
| 147 | 180 | ||
| 181 | + | // connectTitleBarHandlers connects the click handlers for a title bar. | |
| 182 | + | // Uses paneID lookup instead of captured index to handle pane removals correctly. | |
| 183 | + | // Returns the retained callback (to prevent GC) and close signal ID for disconnection. | |
| 184 | + | func (sv *StackedView) connectTitleBarHandlers( | |
| 185 | + | tb titleBarComponents, paneID string, | |
| 186 | + | ) (titleClickCallback interface{}, closeSignalID uint32) { | |
| 187 | + | // Connect title bar click handler using GestureClick | |
| 188 | + | // This prevents event propagation issues with nested buttons | |
| 189 | + | clickCtrl := gtk.NewGestureClick() | |
| 190 | + | ||
| 191 | + | // Store reference to close button for hit testing | |
| 192 | + | closeBtn := tb.closeBtn | |
| 193 | + | ||
| 194 | + | clickCb := func(_ gtk.GestureClick, _ int, clickX float64, clickY float64) { | |
| 195 | + | // Check if click is on the close button - if so, don't activate | |
| 196 | + | // The close button handles its own click event | |
| 197 | + | if closeBtn != nil { | |
| 198 | + | closeBtnWidget := closeBtn.GtkWidget() | |
| 199 | + | if closeBtnWidget != nil { | |
| 200 | + | // Get close button's allocated position and size | |
| 201 | + | btnWidth := closeBtn.GetAllocatedWidth() | |
| 202 | + | btnHeight := closeBtn.GetAllocatedHeight() | |
| 203 | + | ||
| 204 | + | // Get close button's position relative to the title bar | |
| 205 | + | btnX, btnY, ok := closeBtn.ComputePoint(tb.titleBar) | |
| 206 | + | if ok && btnWidth > 0 && btnHeight > 0 { | |
| 207 | + | // Check if click is within close button bounds | |
| 208 | + | if clickX >= btnX && clickX <= btnX+float64(btnWidth) && | |
| 209 | + | clickY >= btnY && clickY <= btnY+float64(btnHeight) { | |
| 210 | + | // Click is on close button, don't activate | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | sv.mu.RLock() | |
| 218 | + | callback := sv.onActivate | |
| 219 | + | currentIndex := sv.findPaneIndexInternal(paneID) | |
| 220 | + | sv.mu.RUnlock() | |
| 221 | + | ||
| 222 | + | if callback != nil && currentIndex >= 0 { | |
| 223 | + | callback(currentIndex) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | clickCtrl.ConnectPressed(&clickCb) | |
| 227 | + | tb.titleBar.AddController(&clickCtrl.EventController) | |
| 228 | + | ||
| 229 | + | // Connect close button click handler | |
| 230 | + | closeSignalID = tb.closeBtn.ConnectClicked(func() { | |
| 231 | + | sv.mu.RLock() | |
| 232 | + | onClose := sv.onClosePane | |
| 233 | + | sv.mu.RUnlock() | |
| 234 | + | ||
| 235 | + | if onClose != nil { | |
| 236 | + | onClose(paneID) | |
| 237 | + | } | |
| 238 | + | }) | |
| 239 | + | ||
| 240 | + | return clickCb, closeSignalID | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // disconnectPaneSignals disconnects signal handlers from a pane's buttons. | |
| 244 | + | // This prevents memory leaks when panes are removed from the stack. | |
| 245 | + | // Note: This is a no-op when using mock widgets in tests (GtkWidget returns nil). | |
| 246 | + | // Note: GestureClick callbacks are cleaned up automatically when the widget is destroyed. | |
| 247 | + | func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) { | |
| 248 | + | if pane == nil { | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Clear retained callback reference to allow GC | |
| 253 | + | // The GestureClick controller is owned by the widget and will be cleaned up when the widget is destroyed | |
| 254 | + | pane.titleClickCallback = nil | |
| 255 | + | ||
| 256 | + | // Disconnect close button click signal | |
| 257 | + | disconnectButtonSignal(pane.closeButton, pane.closeClickSignalID) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // disconnectButtonSignal safely disconnects a signal from a button widget. | |
| 261 | + | // Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations. | |
| 262 | + | func disconnectButtonSignal(btn ButtonWidget, signalID uint32) { | |
| 263 | + | if btn == nil || signalID == 0 { | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | gtkWidget := btn.GtkWidget() | |
| 268 | + | if gtkWidget == nil { | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | ptr := gtkWidget.GoPointer() | |
| 273 | + | if ptr == 0 { | |
| 274 | + | return | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | obj := gobject.ObjectNewFromInternalPtr(ptr) | |
| 278 | + | gobject.SignalHandlerDisconnect(obj, signalID) | |
| 279 | + | } | |
| 280 | + | ||
| 148 | 281 | // InsertPaneAfter inserts a new pane after the specified index position. | |
| 149 | 282 | // Use afterIndex=-1 to insert at the beginning. | |
| 150 | 283 | // The new pane becomes active (visible). | |
| @@ -172,45 +305,23 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 172 | 305 | Int("current_count", len(sv.panes)). | |
| 173 | 306 | Msg("StackedView.InsertPaneAfter called") | |
| 174 | 307 | ||
| 175 | - | // Create title bar - must not expand vertically | |
| 176 | - | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | |
| 177 | - | titleBar.AddCssClass("stacked-pane-titlebar") | |
| 178 | - | titleBar.SetVexpand(false) | |
| 179 | - | ||
| 180 | - | // Create favicon image | |
| 181 | - | favicon := sv.factory.NewImage() | |
| 182 | - | if faviconIconName != "" { | |
| 183 | - | favicon.SetFromIconName(faviconIconName) | |
| 184 | - | } else { | |
| 185 | - | favicon.SetFromIconName("web-browser-symbolic") | |
| 186 | - | } | |
| 187 | - | favicon.SetPixelSize(16) | |
| 188 | - | titleBar.Append(favicon) | |
| 189 | - | ||
| 190 | - | // Create title label | |
| 191 | - | label := sv.factory.NewLabel(title) | |
| 192 | - | label.SetEllipsize(EllipsizeEnd) | |
| 193 | - | label.SetMaxWidthChars(stackedTitleMaxWidthChars) | |
| 194 | - | label.SetHexpand(true) | |
| 195 | - | label.SetXalign(0.0) | |
| 196 | - | titleBar.Append(label) | |
| 308 | + | // Create title bar components | |
| 309 | + | tb := sv.createTitleBar(title, faviconIconName) | |
| 197 | 310 | ||
| 198 | - | // Make title bar clickable - ensure it doesn't expand vertically | |
| 199 | - | titleButton := sv.factory.NewButton() | |
| 200 | - | titleButton.SetChild(titleBar) | |
| 201 | - | titleButton.AddCssClass("stacked-pane-title-button") | |
| 202 | - | titleButton.SetFocusOnClick(false) | |
| 203 | - | titleButton.SetVexpand(false) | |
| 204 | - | titleButton.SetHexpand(true) | |
| 311 | + | // Connect click handlers using paneID (not index, to handle removals) | |
| 312 | + | titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID) | |
| 205 | 313 | ||
| 206 | 314 | pane := &stackedPane{ | |
| 207 | - | paneID: paneID, | |
| 208 | - | titleBar: titleBar, | |
| 209 | - | container: container, | |
| 210 | - | title: title, | |
| 211 | - | favicon: favicon, | |
| 212 | - | label: label, | |
| 213 | - | isActive: false, | |
| 315 | + | paneID: paneID, | |
| 316 | + | titleBar: tb.titleBar, | |
| 317 | + | container: container, | |
| 318 | + | title: title, | |
| 319 | + | favicon: tb.favicon, | |
| 320 | + | label: tb.label, | |
| 321 | + | isActive: false, | |
| 322 | + | closeClickSignalID: closeSignalID, | |
| 323 | + | closeButton: tb.closeBtn, | |
| 324 | + | titleClickCallback: titleClickCb, | |
| 214 | 325 | } | |
| 215 | 326 | ||
| 216 | 327 | // Insert into slice at correct position | |
| @@ -218,38 +329,40 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 218 | 329 | copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:]) | |
| 219 | 330 | sv.panes[insertIndex] = pane | |
| 220 | 331 | ||
| 221 | - | // Connect click handler - use captured index | |
| 222 | - | capturedIndex := insertIndex | |
| 223 | - | titleButton.ConnectClicked(func() { | |
| 224 | - | sv.mu.RLock() | |
| 225 | - | callback := sv.onActivate | |
| 226 | - | sv.mu.RUnlock() | |
| 332 | + | // Insert widgets at correct position in GTK box | |
| 333 | + | sv.insertTitleBarWidgets(tb.titleBar, container, insertIndex) | |
| 227 | 334 | ||
| 228 | - | if callback != nil { | |
| 229 | - | callback(capturedIndex) | |
| 230 | - | } | |
| 231 | - | }) | |
| 335 | + | // Set this pane as active | |
| 336 | + | sv.setActiveInternal(ctx, insertIndex) | |
| 232 | 337 | ||
| 233 | - | // Insert widgets at correct position in GTK box | |
| 338 | + | log.Debug(). | |
| 339 | + | Int("insert_index", insertIndex). | |
| 340 | + | Int("new_count", len(sv.panes)). | |
| 341 | + | Msg("StackedView.InsertPaneAfter completed") | |
| 342 | + | ||
| 343 | + | return insertIndex | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // insertTitleBarWidgets inserts the title bar and container at the correct position. | |
| 347 | + | func (sv *StackedView) insertTitleBarWidgets(titleBar BoxWidget, container Widget, insertIndex int) { | |
| 234 | 348 | if insertIndex > 0 && sv.panes[insertIndex-1] != nil { | |
| 235 | 349 | // Insert after the previous pane's container | |
| 236 | 350 | prevPane := sv.panes[insertIndex-1] | |
| 237 | 351 | if prevPane.container != nil { | |
| 238 | - | sv.box.InsertChildAfter(titleButton, prevPane.container) | |
| 352 | + | sv.box.InsertChildAfter(titleBar, prevPane.container) | |
| 239 | 353 | if container != nil { | |
| 240 | - | sv.box.InsertChildAfter(container, titleButton) | |
| 354 | + | sv.box.InsertChildAfter(container, titleBar) | |
| 241 | 355 | } | |
| 242 | 356 | } else { | |
| 243 | - | // No container, insert after title bar's parent (the button) | |
| 244 | - | prevTitleParent := prevPane.titleBar.GetParent() | |
| 245 | - | if prevTitleParent != nil { | |
| 246 | - | sv.box.InsertChildAfter(titleButton, prevTitleParent) | |
| 357 | + | // No container, insert after the previous pane's title bar directly | |
| 358 | + | if prevPane.titleBar != nil { | |
| 359 | + | sv.box.InsertChildAfter(titleBar, prevPane.titleBar) | |
| 247 | 360 | if container != nil { | |
| 248 | - | sv.box.InsertChildAfter(container, titleButton) | |
| 361 | + | sv.box.InsertChildAfter(container, titleBar) | |
| 249 | 362 | } | |
| 250 | 363 | } else { | |
| 251 | 364 | // Fallback to append | |
| 252 | - | sv.box.Append(titleButton) | |
| 365 | + | sv.box.Append(titleBar) | |
| 253 | 366 | if container != nil { | |
| 254 | 367 | sv.box.Append(container) | |
| 255 | 368 | } | |
| @@ -260,18 +373,8 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 260 | 373 | if container != nil { | |
| 261 | 374 | sv.box.Prepend(container) | |
| 262 | 375 | } | |
| 263 | - | sv.box.Prepend(titleButton) | |
| 376 | + | sv.box.Prepend(titleBar) | |
| 264 | 377 | } | |
| 265 | - | ||
| 266 | - | // Set this pane as active | |
| 267 | - | sv.setActiveInternal(ctx, insertIndex) | |
| 268 | - | ||
| 269 | - | log.Debug(). | |
| 270 | - | Int("insert_index", insertIndex). | |
| 271 | - | Int("new_count", len(sv.panes)). | |
| 272 | - | Msg("StackedView.InsertPaneAfter completed") | |
| 273 | - | ||
| 274 | - | return insertIndex | |
| 275 | 378 | } | |
| 276 | 379 | ||
| 277 | 380 | // RemovePane removes a pane from the stack by index. | |
| @@ -298,13 +401,13 @@ func (sv *StackedView) RemovePane(ctx context.Context, index int) error { | |||
| 298 | 401 | ||
| 299 | 402 | pane := sv.panes[index] | |
| 300 | 403 | ||
| 404 | + | // Disconnect signal handlers before removing widgets to prevent memory leaks | |
| 405 | + | sv.disconnectPaneSignals(pane) | |
| 406 | + | ||
| 301 | 407 | // Remove from container | |
| 302 | 408 | if pane.titleBar != nil { | |
| 303 | - | // The title bar is wrapped in a button, need to get parent | |
| 304 | - | parent := pane.titleBar.GetParent() | |
| 305 | - | if parent != nil { | |
| 306 | - | sv.box.Remove(parent) | |
| 307 | - | } | |
| 409 | + | // The title bar is now added directly to the box (no button wrapper) | |
| 410 | + | sv.box.Remove(pane.titleBar) | |
| 308 | 411 | } | |
| 309 | 412 | if pane.container != nil { | |
| 310 | 413 | sv.box.Remove(pane.container) | |
| @@ -371,17 +474,15 @@ func (sv *StackedView) updateVisibilityInternal(ctx context.Context) { | |||
| 371 | 474 | pane.isActive = isActive | |
| 372 | 475 | ||
| 373 | 476 | // Title bar is always visible for inactive panes, hidden for active | |
| 477 | + | // The title bar is now added directly to the box (no button wrapper) | |
| 374 | 478 | if pane.titleBar != nil { | |
| 375 | - | parent := pane.titleBar.GetParent() | |
| 376 | - | if parent != nil { | |
| 377 | - | parent.SetVisible(!isActive) | |
| 378 | - | log.Debug(). | |
| 379 | - | Int("pane_index", i). | |
| 380 | - | Str("title", pane.title). | |
| 381 | - | Bool("is_active", isActive). | |
| 382 | - | Bool("titlebar_visible", !isActive). | |
| 383 | - | Msg("StackedView: set titlebar visibility") | |
| 384 | - | } | |
| 479 | + | pane.titleBar.SetVisible(!isActive) | |
| 480 | + | log.Debug(). | |
| 481 | + | Int("pane_index", i). | |
| 482 | + | Str("title", pane.title). | |
| 483 | + | Bool("is_active", isActive). | |
| 484 | + | Bool("titlebar_visible", !isActive). | |
| 485 | + | Msg("StackedView: set titlebar visibility") | |
| 385 | 486 | } | |
| 386 | 487 | ||
| 387 | 488 | // Container is visible only for active pane | |
| @@ -429,6 +530,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int { | |||
| 429 | 530 | sv.mu.RLock() | |
| 430 | 531 | defer sv.mu.RUnlock() | |
| 431 | 532 | ||
| 533 | + | return sv.findPaneIndexInternal(paneID) | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // findPaneIndexInternal returns the index of a pane by ID. | |
| 537 | + | // Caller must hold at least a read lock on sv.mu. | |
| 538 | + | func (sv *StackedView) findPaneIndexInternal(paneID string) int { | |
| 432 | 539 | for i, pane := range sv.panes { | |
| 433 | 540 | if pane.paneID == paneID { | |
| 434 | 541 | return i | |
| @@ -445,6 +552,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) { | |||
| 445 | 552 | sv.onActivate = fn | |
| 446 | 553 | } | |
| 447 | 554 | ||
| 555 | + | // SetOnClosePane sets the callback for when a pane's close button is clicked. | |
| 556 | + | func (sv *StackedView) SetOnClosePane(fn func(paneID string)) { | |
| 557 | + | sv.mu.Lock() | |
| 558 | + | defer sv.mu.Unlock() | |
| 559 | + | ||
| 560 | + | sv.onClosePane = fn | |
| 561 | + | } | |
| 562 | + | ||
| 448 | 563 | // UpdateTitle updates the title of a pane at the given index. | |
| 449 | 564 | func (sv *StackedView) UpdateTitle(index int, title string) error { | |
| 450 | 565 | sv.mu.Lock() | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "sync" | 6 | "sync" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | + | "github.com/jwijenbergh/puregotk/v4/gobject" | |||
| 10 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | // ErrStackEmpty is returned when operating on an empty stack. | 13 | // ErrStackEmpty is returned when operating on an empty stack. | ||
| @@ -17,7 +19,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds") | |||||
| 17 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | 19 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | ||
| 18 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | 20 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | ||
| 19 | 21 | ||||
| 20 | - | const stackedTitleMaxWidthChars = 30 | 22 | + | const ( |
| 23 | + | stackedTitleMaxWidthChars = 30 | |||
| 24 | + | stackedPaneCloseIcon = "window-close-symbolic" | |||
| 25 | + | ) | |||
| 21 | 26 | ||||
| 22 | // stackedPane represents a single pane within a stacked container. | 27 | // stackedPane represents a single pane within a stacked container. | ||
| 23 | type stackedPane struct { | 28 | type stackedPane struct { | ||
| @@ -28,6 +33,13 @@ type stackedPane struct { | |||||
| 28 | favicon ImageWidget | 33 | favicon ImageWidget | ||
| 29 | label LabelWidget | 34 | label LabelWidget | ||
| 30 | isActive bool | 35 | isActive bool | ||
| 36 | + | ||||
| 37 | + | // Signal handler IDs for cleanup on removal | |||
| 38 | + | closeClickSignalID uint32 | |||
| 39 | + | closeButton ButtonWidget // stored for signal disconnection | |||
| 40 | + | ||||
| 41 | + | // Retained callback for GestureClick to prevent GC | |||
| 42 | + | titleClickCallback interface{} | |||
| 31 | } | 43 | } | ||
| 32 | 44 | ||||
| 33 | // StackedView manages a stack of panes where only one is visible at a time. | 45 | // StackedView manages a stack of panes where only one is visible at a time. | ||
| @@ -38,7 +50,8 @@ type StackedView struct { | |||||
| 38 | panes []*stackedPane | 50 | panes []*stackedPane | ||
| 39 | activeIndex int | 51 | activeIndex int | ||
| 40 | 52 | ||||
| 41 | - | onActivate func(index int) // called when a pane is activated via title bar click | 53 | + | onActivate func(index int) // called when a pane is activated via title bar click |
| 54 | + | onClosePane func(paneID string) // called when a pane's close button is clicked | |||
| 42 | 55 | ||||
| 43 | mu sync.RWMutex | 56 | mu sync.RWMutex | ||
| 44 | } | 57 | } | ||
| @@ -57,23 +70,23 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||||
| 57 | } | 70 | } | ||
| 58 | } | 71 | } | ||
| 59 | 72 | ||||
| 60 | - | // AddPane adds a new pane to the stack. | 73 | + | // titleBarComponents holds the widgets created for a pane's title bar. |
| 61 | - | // The new pane becomes active (visible). | 74 | + | type titleBarComponents struct { |
| 62 | - | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | 75 | + | titleBar BoxWidget |
| 63 | - | log := logging.FromContext(ctx) | 76 | + | closeBtn ButtonWidget |
| 64 | - | sv.mu.Lock() | 77 | + | favicon ImageWidget |
| 65 | - | defer sv.mu.Unlock() | 78 | + | label LabelWidget |
| 66 | - | 79 | + | } | |
| 67 | - | log.Debug(). | |||
| 68 | - | Str("title", title). | |||
| 69 | - | Bool("container_nil", container == nil). | |||
| 70 | - | Int("current_count", len(sv.panes)). | |||
| 71 | - | Msg("StackedView.AddPane called") | |||
| 72 | 80 | ||||
| 73 | - | // Create title bar - must not expand vertically | 81 | + | // createTitleBar creates the title bar widgets for a pane. |
| 82 | + | func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents { | |||
| 83 | + | // Create title bar container - must not expand vertically | |||
| 84 | + | // This is now the clickable container directly (using GestureClick), not wrapped in a button | |||
| 74 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | 85 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | ||
| 75 | titleBar.AddCssClass("stacked-pane-titlebar") | 86 | titleBar.AddCssClass("stacked-pane-titlebar") | ||
| 87 | + | titleBar.AddCssClass("stacked-pane-title-clickable") // For hover styling | |||
| 76 | titleBar.SetVexpand(false) | 88 | titleBar.SetVexpand(false) | ||
| 89 | + | titleBar.SetHexpand(true) // Fill horizontal space | |||
| 77 | 90 | ||||
| 78 | // Create favicon image | 91 | // Create favicon image | ||
| 79 | favicon := sv.factory.NewImage() | 92 | favicon := sv.factory.NewImage() | ||
| @@ -93,43 +106,63 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||||
| 93 | label.SetXalign(0.0) | 106 | label.SetXalign(0.0) | ||
| 94 | titleBar.Append(label) | 107 | titleBar.Append(label) | ||
| 95 | 108 | ||||
| 96 | - | // Make title bar clickable - ensure it doesn't expand vertically | 109 | + | // Create close button using GTK's native icon button support |
| 97 | - | titleButton := sv.factory.NewButton() | 110 | + | closeBtn := sv.factory.NewButton() |
| 98 | - | titleButton.SetChild(titleBar) | 111 | + | closeBtn.SetIconName(stackedPaneCloseIcon) |
| 99 | - | titleButton.AddCssClass("stacked-pane-title-button") | 112 | + | closeBtn.AddCssClass("stacked-pane-close-button") |
| 100 | - | titleButton.SetFocusOnClick(false) | 113 | + | closeBtn.SetFocusOnClick(false) |
| 101 | - | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | 114 | + | closeBtn.SetVexpand(false) |
| 102 | - | titleButton.SetHexpand(true) // But fill horizontal space | 115 | + | closeBtn.SetHexpand(false) |
| 116 | + | titleBar.Append(closeBtn) | |||
| 117 | + | ||||
| 118 | + | return titleBarComponents{ | |||
| 119 | + | titleBar: titleBar, | |||
| 120 | + | closeBtn: closeBtn, | |||
| 121 | + | favicon: favicon, | |||
| 122 | + | label: label, | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // AddPane adds a new pane to the stack. | |||
| 127 | + | // The new pane becomes active (visible). | |||
| 128 | + | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | |||
| 129 | + | log := logging.FromContext(ctx) | |||
| 130 | + | sv.mu.Lock() | |||
| 131 | + | defer sv.mu.Unlock() | |||
| 132 | + | ||||
| 133 | + | log.Debug(). | |||
| 134 | + | Str("title", title). | |||
| 135 | + | Bool("container_nil", container == nil). | |||
| 136 | + | Int("current_count", len(sv.panes)). | |||
| 137 | + | Msg("StackedView.AddPane called") | |||
| 138 | + | ||||
| 139 | + | // Create title bar components | |||
| 140 | + | tb := sv.createTitleBar(title, faviconIconName) | |||
| 141 | + | ||||
| 142 | + | // Connect click handlers using paneID (not index, to handle removals) | |||
| 143 | + | titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID) | |||
| 103 | 144 | ||||
| 104 | pane := &stackedPane{ | 145 | pane := &stackedPane{ | ||
| 105 | - | paneID: paneID, | 146 | + | paneID: paneID, |
| 106 | - | titleBar: titleBar, | 147 | + | titleBar: tb.titleBar, |
| 107 | - | container: container, | 148 | + | container: container, |
| 108 | - | title: title, | 149 | + | title: title, |
| 109 | - | favicon: favicon, | 150 | + | favicon: tb.favicon, |
| 110 | - | label: label, | 151 | + | label: tb.label, |
| 111 | - | isActive: false, | 152 | + | isActive: false, |
| 153 | + | closeClickSignalID: closeSignalID, | |||
| 154 | + | closeButton: tb.closeBtn, | |||
| 155 | + | titleClickCallback: titleClickCb, | |||
| 112 | } | 156 | } | ||
| 113 | 157 | ||||
| 114 | index := len(sv.panes) | 158 | index := len(sv.panes) | ||
| 115 | sv.panes = append(sv.panes, pane) | 159 | sv.panes = append(sv.panes, pane) | ||
| 116 | 160 | ||||
| 117 | - | // Connect click handler | 161 | + | // Add to container - now we add the titleBar directly (not wrapped in a button) |
| 118 | - | titleButton.ConnectClicked(func() { | |||
| 119 | - | sv.mu.RLock() | |||
| 120 | - | callback := sv.onActivate | |||
| 121 | - | sv.mu.RUnlock() | |||
| 122 | - | ||||
| 123 | - | if callback != nil { | |||
| 124 | - | callback(index) | |||
| 125 | - | } | |||
| 126 | - | }) | |||
| 127 | - | ||||
| 128 | - | // Add to container | |||
| 129 | log.Debug(). | 162 | log.Debug(). | ||
| 130 | Int("index", index). | 163 | Int("index", index). | ||
| 131 | - | Msg("StackedView: appending titleButton and container to box") | 164 | + | Msg("StackedView: appending titleBar and container to box") |
| 132 | - | sv.box.Append(titleButton) | 165 | + | sv.box.Append(tb.titleBar) |
| 133 | if container != nil { | 166 | if container != nil { | ||
| 134 | sv.box.Append(container) | 167 | sv.box.Append(container) | ||
| 135 | } | 168 | } | ||
| @@ -145,6 +178,106 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||||
| 145 | return index | 178 | return index | ||
| 146 | } | 179 | } | ||
| 147 | 180 | ||||
| 181 | + | // connectTitleBarHandlers connects the click handlers for a title bar. | |||
| 182 | + | // Uses paneID lookup instead of captured index to handle pane removals correctly. | |||
| 183 | + | // Returns the retained callback (to prevent GC) and close signal ID for disconnection. | |||
| 184 | + | func (sv *StackedView) connectTitleBarHandlers( | |||
| 185 | + | tb titleBarComponents, paneID string, | |||
| 186 | + | ) (titleClickCallback interface{}, closeSignalID uint32) { | |||
| 187 | + | // Connect title bar click handler using GestureClick | |||
| 188 | + | // This prevents event propagation issues with nested buttons | |||
| 189 | + | clickCtrl := gtk.NewGestureClick() | |||
| 190 | + | ||||
| 191 | + | // Store reference to close button for hit testing | |||
| 192 | + | closeBtn := tb.closeBtn | |||
| 193 | + | ||||
| 194 | + | clickCb := func(_ gtk.GestureClick, _ int, clickX float64, clickY float64) { | |||
| 195 | + | // Check if click is on the close button - if so, don't activate | |||
| 196 | + | // The close button handles its own click event | |||
| 197 | + | if closeBtn != nil { | |||
| 198 | + | closeBtnWidget := closeBtn.GtkWidget() | |||
| 199 | + | if closeBtnWidget != nil { | |||
| 200 | + | // Get close button's allocated position and size | |||
| 201 | + | btnWidth := closeBtn.GetAllocatedWidth() | |||
| 202 | + | btnHeight := closeBtn.GetAllocatedHeight() | |||
| 203 | + | ||||
| 204 | + | // Get close button's position relative to the title bar | |||
| 205 | + | btnX, btnY, ok := closeBtn.ComputePoint(tb.titleBar) | |||
| 206 | + | if ok && btnWidth > 0 && btnHeight > 0 { | |||
| 207 | + | // Check if click is within close button bounds | |||
| 208 | + | if clickX >= btnX && clickX <= btnX+float64(btnWidth) && | |||
| 209 | + | clickY >= btnY && clickY <= btnY+float64(btnHeight) { | |||
| 210 | + | // Click is on close button, don't activate | |||
| 211 | + | return | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | sv.mu.RLock() | |||
| 218 | + | callback := sv.onActivate | |||
| 219 | + | currentIndex := sv.findPaneIndexInternal(paneID) | |||
| 220 | + | sv.mu.RUnlock() | |||
| 221 | + | ||||
| 222 | + | if callback != nil && currentIndex >= 0 { | |||
| 223 | + | callback(currentIndex) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | clickCtrl.ConnectPressed(&clickCb) | |||
| 227 | + | tb.titleBar.AddController(&clickCtrl.EventController) | |||
| 228 | + | ||||
| 229 | + | // Connect close button click handler | |||
| 230 | + | closeSignalID = tb.closeBtn.ConnectClicked(func() { | |||
| 231 | + | sv.mu.RLock() | |||
| 232 | + | onClose := sv.onClosePane | |||
| 233 | + | sv.mu.RUnlock() | |||
| 234 | + | ||||
| 235 | + | if onClose != nil { | |||
| 236 | + | onClose(paneID) | |||
| 237 | + | } | |||
| 238 | + | }) | |||
| 239 | + | ||||
| 240 | + | return clickCb, closeSignalID | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // disconnectPaneSignals disconnects signal handlers from a pane's buttons. | |||
| 244 | + | // This prevents memory leaks when panes are removed from the stack. | |||
| 245 | + | // Note: This is a no-op when using mock widgets in tests (GtkWidget returns nil). | |||
| 246 | + | // Note: GestureClick callbacks are cleaned up automatically when the widget is destroyed. | |||
| 247 | + | func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) { | |||
| 248 | + | if pane == nil { | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // Clear retained callback reference to allow GC | |||
| 253 | + | // The GestureClick controller is owned by the widget and will be cleaned up when the widget is destroyed | |||
| 254 | + | pane.titleClickCallback = nil | |||
| 255 | + | ||||
| 256 | + | // Disconnect close button click signal | |||
| 257 | + | disconnectButtonSignal(pane.closeButton, pane.closeClickSignalID) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // disconnectButtonSignal safely disconnects a signal from a button widget. | |||
| 261 | + | // Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations. | |||
| 262 | + | func disconnectButtonSignal(btn ButtonWidget, signalID uint32) { | |||
| 263 | + | if btn == nil || signalID == 0 { | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | gtkWidget := btn.GtkWidget() | |||
| 268 | + | if gtkWidget == nil { | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | ptr := gtkWidget.GoPointer() | |||
| 273 | + | if ptr == 0 { | |||
| 274 | + | return | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | obj := gobject.ObjectNewFromInternalPtr(ptr) | |||
| 278 | + | gobject.SignalHandlerDisconnect(obj, signalID) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 148 | // InsertPaneAfter inserts a new pane after the specified index position. | 281 | // InsertPaneAfter inserts a new pane after the specified index position. | ||
| 149 | // Use afterIndex=-1 to insert at the beginning. | 282 | // Use afterIndex=-1 to insert at the beginning. | ||
| 150 | // The new pane becomes active (visible). | 283 | // The new pane becomes active (visible). | ||
| @@ -172,45 +305,23 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 172 | Int("current_count", len(sv.panes)). | 305 | Int("current_count", len(sv.panes)). | ||
| 173 | Msg("StackedView.InsertPaneAfter called") | 306 | Msg("StackedView.InsertPaneAfter called") | ||
| 174 | 307 | ||||
| 175 | - | // Create title bar - must not expand vertically | 308 | + | // Create title bar components |
| 176 | - | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | 309 | + | tb := sv.createTitleBar(title, faviconIconName) |
| 177 | - | titleBar.AddCssClass("stacked-pane-titlebar") | |||
| 178 | - | titleBar.SetVexpand(false) | |||
| 179 | - | ||||
| 180 | - | // Create favicon image | |||
| 181 | - | favicon := sv.factory.NewImage() | |||
| 182 | - | if faviconIconName != "" { | |||
| 183 | - | favicon.SetFromIconName(faviconIconName) | |||
| 184 | - | } else { | |||
| 185 | - | favicon.SetFromIconName("web-browser-symbolic") | |||
| 186 | - | } | |||
| 187 | - | favicon.SetPixelSize(16) | |||
| 188 | - | titleBar.Append(favicon) | |||
| 189 | - | ||||
| 190 | - | // Create title label | |||
| 191 | - | label := sv.factory.NewLabel(title) | |||
| 192 | - | label.SetEllipsize(EllipsizeEnd) | |||
| 193 | - | label.SetMaxWidthChars(stackedTitleMaxWidthChars) | |||
| 194 | - | label.SetHexpand(true) | |||
| 195 | - | label.SetXalign(0.0) | |||
| 196 | - | titleBar.Append(label) | |||
| 197 | 310 | ||||
| 198 | - | // Make title bar clickable - ensure it doesn't expand vertically | 311 | + | // Connect click handlers using paneID (not index, to handle removals) |
| 199 | - | titleButton := sv.factory.NewButton() | 312 | + | titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID) |
| 200 | - | titleButton.SetChild(titleBar) | |||
| 201 | - | titleButton.AddCssClass("stacked-pane-title-button") | |||
| 202 | - | titleButton.SetFocusOnClick(false) | |||
| 203 | - | titleButton.SetVexpand(false) | |||
| 204 | - | titleButton.SetHexpand(true) | |||
| 205 | 313 | ||||
| 206 | pane := &stackedPane{ | 314 | pane := &stackedPane{ | ||
| 207 | - | paneID: paneID, | 315 | + | paneID: paneID, |
| 208 | - | titleBar: titleBar, | 316 | + | titleBar: tb.titleBar, |
| 209 | - | container: container, | 317 | + | container: container, |
| 210 | - | title: title, | 318 | + | title: title, |
| 211 | - | favicon: favicon, | 319 | + | favicon: tb.favicon, |
| 212 | - | label: label, | 320 | + | label: tb.label, |
| 213 | - | isActive: false, | 321 | + | isActive: false, |
| 322 | + | closeClickSignalID: closeSignalID, | |||
| 323 | + | closeButton: tb.closeBtn, | |||
| 324 | + | titleClickCallback: titleClickCb, | |||
| 214 | } | 325 | } | ||
| 215 | 326 | ||||
| 216 | // Insert into slice at correct position | 327 | // Insert into slice at correct position | ||
| @@ -218,38 +329,40 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 218 | copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:]) | 329 | copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:]) | ||
| 219 | sv.panes[insertIndex] = pane | 330 | sv.panes[insertIndex] = pane | ||
| 220 | 331 | ||||
| 221 | - | // Connect click handler - use captured index | 332 | + | // Insert widgets at correct position in GTK box |
| 222 | - | capturedIndex := insertIndex | 333 | + | sv.insertTitleBarWidgets(tb.titleBar, container, insertIndex) |
| 223 | - | titleButton.ConnectClicked(func() { | |||
| 224 | - | sv.mu.RLock() | |||
| 225 | - | callback := sv.onActivate | |||
| 226 | - | sv.mu.RUnlock() | |||
| 227 | 334 | ||||
| 228 | - | if callback != nil { | 335 | + | // Set this pane as active |
| 229 | - | callback(capturedIndex) | 336 | + | sv.setActiveInternal(ctx, insertIndex) |
| 230 | - | } | |||
| 231 | - | }) | |||
| 232 | 337 | ||||
| 233 | - | // Insert widgets at correct position in GTK box | 338 | + | log.Debug(). |
| 339 | + | Int("insert_index", insertIndex). | |||
| 340 | + | Int("new_count", len(sv.panes)). | |||
| 341 | + | Msg("StackedView.InsertPaneAfter completed") | |||
| 342 | + | ||||
| 343 | + | return insertIndex | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // insertTitleBarWidgets inserts the title bar and container at the correct position. | |||
| 347 | + | func (sv *StackedView) insertTitleBarWidgets(titleBar BoxWidget, container Widget, insertIndex int) { | |||
| 234 | if insertIndex > 0 && sv.panes[insertIndex-1] != nil { | 348 | if insertIndex > 0 && sv.panes[insertIndex-1] != nil { | ||
| 235 | // Insert after the previous pane's container | 349 | // Insert after the previous pane's container | ||
| 236 | prevPane := sv.panes[insertIndex-1] | 350 | prevPane := sv.panes[insertIndex-1] | ||
| 237 | if prevPane.container != nil { | 351 | if prevPane.container != nil { | ||
| 238 | - | sv.box.InsertChildAfter(titleButton, prevPane.container) | 352 | + | sv.box.InsertChildAfter(titleBar, prevPane.container) |
| 239 | if container != nil { | 353 | if container != nil { | ||
| 240 | - | sv.box.InsertChildAfter(container, titleButton) | 354 | + | sv.box.InsertChildAfter(container, titleBar) |
| 241 | } | 355 | } | ||
| 242 | } else { | 356 | } else { | ||
| 243 | - | // No container, insert after title bar's parent (the button) | 357 | + | // No container, insert after the previous pane's title bar directly |
| 244 | - | prevTitleParent := prevPane.titleBar.GetParent() | 358 | + | if prevPane.titleBar != nil { |
| 245 | - | if prevTitleParent != nil { | 359 | + | sv.box.InsertChildAfter(titleBar, prevPane.titleBar) |
| 246 | - | sv.box.InsertChildAfter(titleButton, prevTitleParent) | |||
| 247 | if container != nil { | 360 | if container != nil { | ||
| 248 | - | sv.box.InsertChildAfter(container, titleButton) | 361 | + | sv.box.InsertChildAfter(container, titleBar) |
| 249 | } | 362 | } | ||
| 250 | } else { | 363 | } else { | ||
| 251 | // Fallback to append | 364 | // Fallback to append | ||
| 252 | - | sv.box.Append(titleButton) | 365 | + | sv.box.Append(titleBar) |
| 253 | if container != nil { | 366 | if container != nil { | ||
| 254 | sv.box.Append(container) | 367 | sv.box.Append(container) | ||
| 255 | } | 368 | } | ||
| @@ -260,18 +373,8 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 260 | if container != nil { | 373 | if container != nil { | ||
| 261 | sv.box.Prepend(container) | 374 | sv.box.Prepend(container) | ||
| 262 | } | 375 | } | ||
| 263 | - | sv.box.Prepend(titleButton) | 376 | + | sv.box.Prepend(titleBar) |
| 264 | } | 377 | } | ||
| 265 | - | ||||
| 266 | - | // Set this pane as active | |||
| 267 | - | sv.setActiveInternal(ctx, insertIndex) | |||
| 268 | - | ||||
| 269 | - | log.Debug(). | |||
| 270 | - | Int("insert_index", insertIndex). | |||
| 271 | - | Int("new_count", len(sv.panes)). | |||
| 272 | - | Msg("StackedView.InsertPaneAfter completed") | |||
| 273 | - | ||||
| 274 | - | return insertIndex | |||
| 275 | } | 378 | } | ||
| 276 | 379 | ||||
| 277 | // RemovePane removes a pane from the stack by index. | 380 | // RemovePane removes a pane from the stack by index. | ||
| @@ -298,13 +401,13 @@ func (sv *StackedView) RemovePane(ctx context.Context, index int) error { | |||||
| 298 | 401 | ||||
| 299 | pane := sv.panes[index] | 402 | pane := sv.panes[index] | ||
| 300 | 403 | ||||
| 404 | + | // Disconnect signal handlers before removing widgets to prevent memory leaks | |||
| 405 | + | sv.disconnectPaneSignals(pane) | |||
| 406 | + | ||||
| 301 | // Remove from container | 407 | // Remove from container | ||
| 302 | if pane.titleBar != nil { | 408 | if pane.titleBar != nil { | ||
| 303 | - | // The title bar is wrapped in a button, need to get parent | 409 | + | // The title bar is now added directly to the box (no button wrapper) |
| 304 | - | parent := pane.titleBar.GetParent() | 410 | + | sv.box.Remove(pane.titleBar) |
| 305 | - | if parent != nil { | |||
| 306 | - | sv.box.Remove(parent) | |||
| 307 | - | } | |||
| 308 | } | 411 | } | ||
| 309 | if pane.container != nil { | 412 | if pane.container != nil { | ||
| 310 | sv.box.Remove(pane.container) | 413 | sv.box.Remove(pane.container) | ||
| @@ -371,17 +474,15 @@ func (sv *StackedView) updateVisibilityInternal(ctx context.Context) { | |||||
| 371 | pane.isActive = isActive | 474 | pane.isActive = isActive | ||
| 372 | 475 | ||||
| 373 | // Title bar is always visible for inactive panes, hidden for active | 476 | // Title bar is always visible for inactive panes, hidden for active | ||
| 477 | + | // The title bar is now added directly to the box (no button wrapper) | |||
| 374 | if pane.titleBar != nil { | 478 | if pane.titleBar != nil { | ||
| 375 | - | parent := pane.titleBar.GetParent() | 479 | + | pane.titleBar.SetVisible(!isActive) |
| 376 | - | if parent != nil { | 480 | + | log.Debug(). |
| 377 | - | parent.SetVisible(!isActive) | 481 | + | Int("pane_index", i). |
| 378 | - | log.Debug(). | 482 | + | Str("title", pane.title). |
| 379 | - | Int("pane_index", i). | 483 | + | Bool("is_active", isActive). |
| 380 | - | Str("title", pane.title). | 484 | + | Bool("titlebar_visible", !isActive). |
| 381 | - | Bool("is_active", isActive). | 485 | + | Msg("StackedView: set titlebar visibility") |
| 382 | - | Bool("titlebar_visible", !isActive). | |||
| 383 | - | Msg("StackedView: set titlebar visibility") | |||
| 384 | - | } | |||
| 385 | } | 486 | } | ||
| 386 | 487 | ||||
| 387 | // Container is visible only for active pane | 488 | // Container is visible only for active pane | ||
| @@ -429,6 +530,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int { | |||||
| 429 | sv.mu.RLock() | 530 | sv.mu.RLock() | ||
| 430 | defer sv.mu.RUnlock() | 531 | defer sv.mu.RUnlock() | ||
| 431 | 532 | ||||
| 533 | + | return sv.findPaneIndexInternal(paneID) | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | // findPaneIndexInternal returns the index of a pane by ID. | |||
| 537 | + | // Caller must hold at least a read lock on sv.mu. | |||
| 538 | + | func (sv *StackedView) findPaneIndexInternal(paneID string) int { | |||
| 432 | for i, pane := range sv.panes { | 539 | for i, pane := range sv.panes { | ||
| 433 | if pane.paneID == paneID { | 540 | if pane.paneID == paneID { | ||
| 434 | return i | 541 | return i | ||
| @@ -445,6 +552,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) { | |||||
| 445 | sv.onActivate = fn | 552 | sv.onActivate = fn | ||
| 446 | } | 553 | } | ||
| 447 | 554 | ||||
| 555 | + | // SetOnClosePane sets the callback for when a pane's close button is clicked. | |||
| 556 | + | func (sv *StackedView) SetOnClosePane(fn func(paneID string)) { | |||
| 557 | + | sv.mu.Lock() | |||
| 558 | + | defer sv.mu.Unlock() | |||
| 559 | + | ||||
| 560 | + | sv.onClosePane = fn | |||
| 561 | + | } | |||
| 562 | + | ||||
| 448 | // UpdateTitle updates the title of a pane at the given index. | 563 | // UpdateTitle updates the title of a pane at the given index. | ||
| 449 | func (sv *StackedView) UpdateTitle(index int, title string) error { | 564 | func (sv *StackedView) UpdateTitle(index int, title string) error { | ||
| 450 | sv.mu.Lock() | 565 | sv.mu.Lock() | ||
M internal/ui/layout/stacked_test.go +109 -104
| @@ -25,19 +25,23 @@ func setupMockFactory(t *testing.T) (*mocks.MockWidgetFactory, *mocks.MockBoxWid | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | // setupPaneMocks creates mocks needed for AddPane | |
| 28 | + | // Note: The stacked view now uses GestureClick on the titleBar directly instead of wrapping it in a button. | |
| 29 | + | // The GestureClick is added via AddController which we mock to accept any EventController. | |
| 28 | 30 | func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) ( | |
| 29 | - | *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockButtonWidget, *mocks.MockWidget, | |
| 31 | + | *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockWidget, | |
| 30 | 32 | ) { | |
| 31 | 33 | mockTitleBar := mocks.NewMockBoxWidget(t) | |
| 32 | 34 | mockFavicon := mocks.NewMockImageWidget(t) | |
| 33 | 35 | mockLabel := mocks.NewMockLabelWidget(t) | |
| 34 | - | mockButton := mocks.NewMockButtonWidget(t) | |
| 36 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |
| 35 | 37 | mockContainer := mocks.NewMockWidget(t) | |
| 36 | 38 | ||
| 37 | - | // Title bar creation | |
| 39 | + | // Title bar creation - now directly used without button wrapper | |
| 38 | 40 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | |
| 39 | 41 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | |
| 42 | + | mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |
| 40 | 43 | mockTitleBar.EXPECT().SetVexpand(false).Once() | |
| 44 | + | mockTitleBar.EXPECT().SetHexpand(true).Once() | |
| 41 | 45 | ||
| 42 | 46 | // Favicon | |
| 43 | 47 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | |
| @@ -53,20 +57,29 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||
| 53 | 57 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | |
| 54 | 58 | mockTitleBar.EXPECT().Append(mockLabel).Once() | |
| 55 | 59 | ||
| 56 | - | // Button wrapping title bar | |
| 57 | - | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | |
| 58 | - | mockButton.EXPECT().SetChild(mockTitleBar).Once() | |
| 59 | - | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | |
| 60 | - | mockButton.EXPECT().SetFocusOnClick(false).Once() | |
| 61 | - | mockButton.EXPECT().SetVexpand(false).Once() | |
| 62 | - | mockButton.EXPECT().SetHexpand(true).Once() | |
| 63 | - | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | |
| 64 | - | ||
| 65 | - | // Adding to main box | |
| 66 | - | mockBox.EXPECT().Append(mockButton).Once() | |
| 60 | + | // Close button (uses SetIconName directly instead of child image) | |
| 61 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |
| 62 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |
| 63 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |
| 64 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |
| 65 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |
| 66 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |
| 67 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |
| 68 | + | ||
| 69 | + | // GestureClick is added to titleBar via AddController | |
| 70 | + | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |
| 71 | + | ||
| 72 | + | // Close button click handler | |
| 73 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |
| 74 | + | ||
| 75 | + | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |
| 76 | + | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |
| 77 | + | ||
| 78 | + | // Adding to main box - now titleBar is added directly (no button wrapper) | |
| 79 | + | mockBox.EXPECT().Append(mockTitleBar).Once() | |
| 67 | 80 | mockBox.EXPECT().Append(mockContainer).Once() | |
| 68 | 81 | ||
| 69 | - | return mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer | |
| 82 | + | return mockTitleBar, mockFavicon, mockLabel, mockContainer | |
| 70 | 83 | } | |
| 71 | 84 | ||
| 72 | 85 | func TestNewStackedView_EmptyStack(t *testing.T) { | |
| @@ -86,13 +99,12 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) { | |||
| 86 | 99 | // Arrange | |
| 87 | 100 | ctx := context.Background() | |
| 88 | 101 | mockFactory, mockBox := setupMockFactory(t) | |
| 89 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 102 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 90 | 103 | _ = mockFavicon | |
| 91 | 104 | _ = mockLabel | |
| 92 | - | _ = mockButton | |
| 93 | 105 | ||
| 94 | - | // Visibility updates for active pane | |
| 95 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 106 | + | // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it | |
| 107 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 96 | 108 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 97 | 109 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 98 | 110 | ||
| @@ -113,26 +125,24 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) { | |||
| 113 | 125 | mockFactory, mockBox := setupMockFactory(t) | |
| 114 | 126 | ||
| 115 | 127 | // First pane | |
| 116 | - | mockTitleBar1, mockFavicon1, mockLabel1, mockButton1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 128 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 117 | 129 | _ = mockFavicon1 | |
| 118 | 130 | _ = mockLabel1 | |
| 119 | - | _ = mockButton1 | |
| 120 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 131 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 121 | 132 | mockContainer1.EXPECT().SetVisible(true).Once() | |
| 122 | 133 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 123 | 134 | ||
| 124 | 135 | // Second pane - first pane becomes inactive | |
| 125 | - | mockTitleBar2, mockFavicon2, mockLabel2, mockButton2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox) | |
| 136 | + | mockTitleBar2, mockFavicon2, mockLabel2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox) | |
| 126 | 137 | _ = mockFavicon2 | |
| 127 | 138 | _ = mockLabel2 | |
| 128 | - | _ = mockButton2 | |
| 129 | 139 | ||
| 130 | 140 | // When second pane is added, update visibility for both | |
| 131 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 141 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar | |
| 132 | 142 | mockContainer1.EXPECT().SetVisible(false).Once() | |
| 133 | 143 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | |
| 134 | 144 | ||
| 135 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | |
| 145 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar | |
| 136 | 146 | mockContainer2.EXPECT().SetVisible(true).Once() | |
| 137 | 147 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 138 | 148 | ||
| @@ -156,22 +166,17 @@ func TestRemovePane_MiddlePane(t *testing.T) { | |||
| 156 | 166 | // Setup 3 panes (simplified - just track the key behaviors) | |
| 157 | 167 | containers := make([]*mocks.MockWidget, 3) | |
| 158 | 168 | titleBars := make([]*mocks.MockBoxWidget, 3) | |
| 159 | - | buttons := make([]*mocks.MockButtonWidget, 3) | |
| 160 | 169 | ||
| 161 | 170 | for i := 0; i < 3; i++ { | |
| 162 | - | var btn *mocks.MockButtonWidget | |
| 163 | - | titleBars[i], _, _, btn, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | |
| 164 | - | buttons[i] = btn | |
| 171 | + | titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | |
| 165 | 172 | } | |
| 166 | 173 | ||
| 167 | 174 | // Allow any visibility calls during setup and removal | |
| 168 | 175 | for i := 0; i < 3; i++ { | |
| 169 | - | titleBars[i].EXPECT().GetParent().Return(buttons[i]).Maybe() | |
| 170 | 176 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 177 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 171 | 178 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | |
| 172 | 179 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | |
| 173 | - | // Button visibility changes when panes are activated/deactivated | |
| 174 | - | buttons[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 175 | 180 | } | |
| 176 | 181 | ||
| 177 | 182 | sv := layout.NewStackedView(mockFactory) | |
| @@ -180,8 +185,8 @@ func TestRemovePane_MiddlePane(t *testing.T) { | |||
| 180 | 185 | sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2]) | |
| 181 | 186 | ||
| 182 | 187 | // Remove middle pane (index 1) | |
| 183 | - | // The parent of titleBar is the button widget | |
| 184 | - | mockBox.EXPECT().Remove(buttons[1]).Once() | |
| 188 | + | // The titleBar is now directly in the box (no button wrapper) | |
| 189 | + | mockBox.EXPECT().Remove(titleBars[1]).Once() | |
| 185 | 190 | mockBox.EXPECT().Remove(containers[1]).Once() | |
| 186 | 191 | ||
| 187 | 192 | // Act | |
| @@ -196,12 +201,11 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) { | |||
| 196 | 201 | // Arrange | |
| 197 | 202 | ctx := context.Background() | |
| 198 | 203 | mockFactory, mockBox := setupMockFactory(t) | |
| 199 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 204 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 200 | 205 | _ = mockFavicon | |
| 201 | 206 | _ = mockLabel | |
| 202 | - | _ = mockButton | |
| 203 | 207 | ||
| 204 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 208 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 205 | 209 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 206 | 210 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 207 | 211 | ||
| @@ -233,12 +237,11 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) { | |||
| 233 | 237 | // Arrange | |
| 234 | 238 | ctx := context.Background() | |
| 235 | 239 | mockFactory, mockBox := setupMockFactory(t) | |
| 236 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 240 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 237 | 241 | _ = mockFavicon | |
| 238 | 242 | _ = mockLabel | |
| 239 | - | _ = mockButton | |
| 240 | 243 | ||
| 241 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 244 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 242 | 245 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 243 | 246 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 244 | 247 | ||
| @@ -261,15 +264,14 @@ func TestSetActive_ValidIndex(t *testing.T) { | |||
| 261 | 264 | titleBars := make([]*mocks.MockBoxWidget, 2) | |
| 262 | 265 | ||
| 263 | 266 | for i := 0; i < 2; i++ { | |
| 264 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 267 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 265 | 268 | _ = mockFavicon | |
| 266 | - | _ = mockOverlay | |
| 267 | - | _ = mockSpinner | |
| 269 | + | _ = mockLabel | |
| 268 | 270 | ||
| 269 | 271 | titleBars[i] = titleBar | |
| 270 | 272 | containers[i] = container | |
| 271 | 273 | ||
| 272 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | |
| 274 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 273 | 275 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 274 | 276 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | |
| 275 | 277 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -291,12 +293,11 @@ func TestSetActive_OutOfBounds(t *testing.T) { | |||
| 291 | 293 | // Arrange | |
| 292 | 294 | ctx := context.Background() | |
| 293 | 295 | mockFactory, mockBox := setupMockFactory(t) | |
| 294 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 296 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 295 | 297 | _ = mockFavicon | |
| 296 | 298 | _ = mockLabel | |
| 297 | - | _ = mockButton | |
| 298 | 299 | ||
| 299 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 300 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 300 | 301 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 301 | 302 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 302 | 303 | ||
| @@ -327,9 +328,9 @@ func TestUpdateTitle(t *testing.T) { | |||
| 327 | 328 | // Arrange | |
| 328 | 329 | ctx := context.Background() | |
| 329 | 330 | mockFactory, mockBox := setupMockFactory(t) | |
| 330 | - | mockTitleBar, _, mockLabel, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 331 | + | mockTitleBar, _, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 331 | 332 | ||
| 332 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 333 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 333 | 334 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 334 | 335 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 335 | 336 | ||
| @@ -361,9 +362,9 @@ func TestUpdateFavicon(t *testing.T) { | |||
| 361 | 362 | // Arrange | |
| 362 | 363 | ctx := context.Background() | |
| 363 | 364 | mockFactory, mockBox := setupMockFactory(t) | |
| 364 | - | mockTitleBar, mockFavicon, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 365 | + | mockTitleBar, mockFavicon, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 365 | 366 | ||
| 366 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 367 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 367 | 368 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 368 | 369 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 369 | 370 | ||
| @@ -383,12 +384,11 @@ func TestGetContainer(t *testing.T) { | |||
| 383 | 384 | // Arrange | |
| 384 | 385 | ctx := context.Background() | |
| 385 | 386 | mockFactory, mockBox := setupMockFactory(t) | |
| 386 | - | mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 387 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | |
| 387 | 388 | _ = mockFavicon | |
| 388 | - | _ = mockOverlay | |
| 389 | - | _ = mockSpinner | |
| 389 | + | _ = mockLabel | |
| 390 | 390 | ||
| 391 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 391 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 392 | 392 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 393 | 393 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 394 | 394 | ||
| @@ -437,15 +437,14 @@ func TestNavigateNext_WrapsAround(t *testing.T) { | |||
| 437 | 437 | titleBars := make([]*mocks.MockBoxWidget, 2) | |
| 438 | 438 | ||
| 439 | 439 | for i := 0; i < 2; i++ { | |
| 440 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 440 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 441 | 441 | _ = mockFavicon | |
| 442 | - | _ = mockOverlay | |
| 443 | - | _ = mockSpinner | |
| 442 | + | _ = mockLabel | |
| 444 | 443 | ||
| 445 | 444 | titleBars[i] = titleBar | |
| 446 | 445 | containers[i] = container | |
| 447 | 446 | ||
| 448 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | |
| 447 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 449 | 448 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 450 | 449 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | |
| 451 | 450 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -475,15 +474,14 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) { | |||
| 475 | 474 | titleBars := make([]*mocks.MockBoxWidget, 2) | |
| 476 | 475 | ||
| 477 | 476 | for i := 0; i < 2; i++ { | |
| 478 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 477 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 479 | 478 | _ = mockFavicon | |
| 480 | - | _ = mockOverlay | |
| 481 | - | _ = mockSpinner | |
| 479 | + | _ = mockLabel | |
| 482 | 480 | ||
| 483 | 481 | titleBars[i] = titleBar | |
| 484 | 482 | containers[i] = container | |
| 485 | 483 | ||
| 486 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | |
| 484 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 487 | 485 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 488 | 486 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | |
| 489 | 487 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -558,13 +556,15 @@ func setupInsertPaneMocks( | |||
| 558 | 556 | mockTitleBar := mocks.NewMockBoxWidget(t) | |
| 559 | 557 | mockFavicon := mocks.NewMockImageWidget(t) | |
| 560 | 558 | mockLabel := mocks.NewMockLabelWidget(t) | |
| 561 | - | mockButton := mocks.NewMockButtonWidget(t) | |
| 559 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |
| 562 | 560 | mockContainer := mocks.NewMockWidget(t) | |
| 563 | 561 | ||
| 564 | - | // Title bar creation | |
| 562 | + | // Title bar creation - now directly used without button wrapper | |
| 565 | 563 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | |
| 566 | 564 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | |
| 565 | + | mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |
| 567 | 566 | mockTitleBar.EXPECT().SetVexpand(false).Once() | |
| 567 | + | mockTitleBar.EXPECT().SetHexpand(true).Once() | |
| 568 | 568 | ||
| 569 | 569 | // Favicon | |
| 570 | 570 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | |
| @@ -580,23 +580,32 @@ func setupInsertPaneMocks( | |||
| 580 | 580 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | |
| 581 | 581 | mockTitleBar.EXPECT().Append(mockLabel).Once() | |
| 582 | 582 | ||
| 583 | - | // Button wrapping title bar | |
| 584 | - | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | |
| 585 | - | mockButton.EXPECT().SetChild(mockTitleBar).Once() | |
| 586 | - | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | |
| 587 | - | mockButton.EXPECT().SetFocusOnClick(false).Once() | |
| 588 | - | mockButton.EXPECT().SetVexpand(false).Once() | |
| 589 | - | mockButton.EXPECT().SetHexpand(true).Once() | |
| 590 | - | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | |
| 583 | + | // Close button (uses SetIconName directly instead of child image) | |
| 584 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |
| 585 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |
| 586 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |
| 587 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |
| 588 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |
| 589 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |
| 590 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |
| 591 | + | ||
| 592 | + | // GestureClick is added to titleBar via AddController | |
| 593 | + | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |
| 591 | 594 | ||
| 592 | - | // Position-aware insertion using InsertChildAfter | |
| 595 | + | // Close button click handler | |
| 596 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |
| 597 | + | ||
| 598 | + | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |
| 599 | + | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |
| 600 | + | ||
| 601 | + | // Position-aware insertion using InsertChildAfter - now using titleBar directly | |
| 593 | 602 | if siblingContainer != nil { | |
| 594 | - | mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once() | |
| 595 | - | mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once() | |
| 603 | + | mockBox.EXPECT().InsertChildAfter(mockTitleBar, siblingContainer).Once() | |
| 604 | + | mockBox.EXPECT().InsertChildAfter(mockContainer, mockTitleBar).Once() | |
| 596 | 605 | } else { | |
| 597 | 606 | // Insert at beginning using Prepend | |
| 598 | 607 | mockBox.EXPECT().Prepend(mockContainer).Once() | |
| 599 | - | mockBox.EXPECT().Prepend(mockButton).Once() | |
| 608 | + | mockBox.EXPECT().Prepend(mockTitleBar).Once() | |
| 600 | 609 | } | |
| 601 | 610 | ||
| 602 | 611 | return mockTitleBar, mockContainer | |
| @@ -608,11 +617,10 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||
| 608 | 617 | mockFactory, mockBox := setupMockFactory(t) | |
| 609 | 618 | ||
| 610 | 619 | // First pane - appended normally | |
| 611 | - | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 620 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 612 | 621 | _ = mockFavicon1 | |
| 613 | - | _ = mockOverlay1 | |
| 614 | - | _ = mockSpinner1 | |
| 615 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 622 | + | _ = mockLabel1 | |
| 623 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 616 | 624 | mockContainer1.EXPECT().SetVisible(true).Once() | |
| 617 | 625 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 618 | 626 | ||
| @@ -623,11 +631,11 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||
| 623 | 631 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil) | |
| 624 | 632 | ||
| 625 | 633 | // Visibility updates | |
| 626 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 634 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar | |
| 627 | 635 | mockContainer1.EXPECT().SetVisible(false).Once() | |
| 628 | 636 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | |
| 629 | 637 | ||
| 630 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | |
| 638 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar | |
| 631 | 639 | mockContainer2.EXPECT().SetVisible(true).Once() | |
| 632 | 640 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 633 | 641 | ||
| @@ -650,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) { | |||
| 650 | 658 | titleBars := make([]*mocks.MockBoxWidget, 2) | |
| 651 | 659 | ||
| 652 | 660 | for i := 0; i < 2; i++ { | |
| 653 | - | titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | |
| 654 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | |
| 661 | + | titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | |
| 662 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 655 | 663 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 656 | 664 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | |
| 657 | 665 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -666,7 +674,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) { | |||
| 666 | 674 | ||
| 667 | 675 | // Insert new pane after index 0 (should become index 1) | |
| 668 | 676 | mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0]) | |
| 669 | - | mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe() | |
| 677 | + | mockTitleBar3.EXPECT().SetVisible(mock.Anything).Maybe() | |
| 670 | 678 | mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe() | |
| 671 | 679 | mockTitleBar3.EXPECT().AddCssClass("active").Maybe() | |
| 672 | 680 | mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -686,11 +694,10 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||
| 686 | 694 | mockFactory, mockBox := setupMockFactory(t) | |
| 687 | 695 | ||
| 688 | 696 | // First pane | |
| 689 | - | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 697 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 690 | 698 | _ = mockFavicon1 | |
| 691 | - | _ = mockOverlay1 | |
| 692 | - | _ = mockSpinner1 | |
| 693 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 699 | + | _ = mockLabel1 | |
| 700 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 694 | 701 | mockContainer1.EXPECT().SetVisible(true).Once() | |
| 695 | 702 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 696 | 703 | ||
| @@ -700,11 +707,11 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||
| 700 | 707 | // Insert after last pane (afterIndex=0, becomes index 1) | |
| 701 | 708 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | |
| 702 | 709 | ||
| 703 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 710 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar | |
| 704 | 711 | mockContainer1.EXPECT().SetVisible(false).Once() | |
| 705 | 712 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | |
| 706 | 713 | ||
| 707 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | |
| 714 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar | |
| 708 | 715 | mockContainer2.EXPECT().SetVisible(true).Once() | |
| 709 | 716 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 710 | 717 | ||
| @@ -727,15 +734,14 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||
| 727 | 734 | titleBars := make([]*mocks.MockBoxWidget, 3) | |
| 728 | 735 | ||
| 729 | 736 | for i := 0; i < 3; i++ { | |
| 730 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 737 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) | |
| 731 | 738 | _ = mockFavicon | |
| 732 | - | _ = mockOverlay | |
| 733 | - | _ = mockSpinner | |
| 739 | + | _ = mockLabel | |
| 734 | 740 | ||
| 735 | 741 | titleBars[i] = titleBar | |
| 736 | 742 | containers[i] = container | |
| 737 | 743 | ||
| 738 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | |
| 744 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 739 | 745 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | |
| 740 | 746 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | |
| 741 | 747 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -751,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||
| 751 | 757 | ||
| 752 | 758 | // Insert D after B (should be at index 2, C moves to index 3) | |
| 753 | 759 | mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1]) | |
| 754 | - | mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe() | |
| 760 | + | mockTitleBar4.EXPECT().SetVisible(mock.Anything).Maybe() | |
| 755 | 761 | mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe() | |
| 756 | 762 | mockTitleBar4.EXPECT().AddCssClass("active").Maybe() | |
| 757 | 763 | mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe() | |
| @@ -782,11 +788,10 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||
| 782 | 788 | mockFactory, mockBox := setupMockFactory(t) | |
| 783 | 789 | ||
| 784 | 790 | // First pane | |
| 785 | - | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 791 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | |
| 786 | 792 | _ = mockFavicon1 | |
| 787 | - | _ = mockOverlay1 | |
| 788 | - | _ = mockSpinner1 | |
| 789 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 793 | + | _ = mockLabel1 | |
| 794 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 790 | 795 | mockContainer1.EXPECT().SetVisible(true).Once() | |
| 791 | 796 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 792 | 797 | ||
| @@ -796,11 +801,11 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||
| 796 | 801 | // Try to insert at invalid index (100) - should clamp to end | |
| 797 | 802 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | |
| 798 | 803 | ||
| 799 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |
| 804 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar | |
| 800 | 805 | mockContainer1.EXPECT().SetVisible(false).Once() | |
| 801 | 806 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | |
| 802 | 807 | ||
| 803 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | |
| 808 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar | |
| 804 | 809 | mockContainer2.EXPECT().SetVisible(true).Once() | |
| 805 | 810 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 806 | 811 | ||
| @@ -25,19 +25,23 @@ func setupMockFactory(t *testing.T) (*mocks.MockWidgetFactory, *mocks.MockBoxWid | |||||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | // setupPaneMocks creates mocks needed for AddPane | 27 | // setupPaneMocks creates mocks needed for AddPane | ||
| 28 | + | // Note: The stacked view now uses GestureClick on the titleBar directly instead of wrapping it in a button. | |||
| 29 | + | // The GestureClick is added via AddController which we mock to accept any EventController. | |||
| 28 | func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) ( | 30 | func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) ( | ||
| 29 | - | *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockButtonWidget, *mocks.MockWidget, | 31 | + | *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockWidget, |
| 30 | ) { | 32 | ) { | ||
| 31 | mockTitleBar := mocks.NewMockBoxWidget(t) | 33 | mockTitleBar := mocks.NewMockBoxWidget(t) | ||
| 32 | mockFavicon := mocks.NewMockImageWidget(t) | 34 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| 33 | mockLabel := mocks.NewMockLabelWidget(t) | 35 | mockLabel := mocks.NewMockLabelWidget(t) | ||
| 34 | - | mockButton := mocks.NewMockButtonWidget(t) | 36 | + | mockCloseButton := mocks.NewMockButtonWidget(t) |
| 35 | mockContainer := mocks.NewMockWidget(t) | 37 | mockContainer := mocks.NewMockWidget(t) | ||
| 36 | 38 | ||||
| 37 | - | // Title bar creation | 39 | + | // Title bar creation - now directly used without button wrapper |
| 38 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | 40 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | ||
| 39 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | 41 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | ||
| 42 | + | mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |||
| 40 | mockTitleBar.EXPECT().SetVexpand(false).Once() | 43 | mockTitleBar.EXPECT().SetVexpand(false).Once() | ||
| 44 | + | mockTitleBar.EXPECT().SetHexpand(true).Once() | |||
| 41 | 45 | ||||
| 42 | // Favicon | 46 | // Favicon | ||
| 43 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | 47 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | ||
| @@ -53,20 +57,29 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||||
| 53 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | 57 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | ||
| 54 | mockTitleBar.EXPECT().Append(mockLabel).Once() | 58 | mockTitleBar.EXPECT().Append(mockLabel).Once() | ||
| 55 | 59 | ||||
| 56 | - | // Button wrapping title bar | 60 | + | // Close button (uses SetIconName directly instead of child image) |
| 57 | - | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | 61 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() |
| 58 | - | mockButton.EXPECT().SetChild(mockTitleBar).Once() | 62 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() |
| 59 | - | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | 63 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() |
| 60 | - | mockButton.EXPECT().SetFocusOnClick(false).Once() | 64 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() |
| 61 | - | mockButton.EXPECT().SetVexpand(false).Once() | 65 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() |
| 62 | - | mockButton.EXPECT().SetHexpand(true).Once() | 66 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() |
| 63 | - | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | 67 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() |
| 64 | - | 68 | + | ||
| 65 | - | // Adding to main box | 69 | + | // GestureClick is added to titleBar via AddController |
| 66 | - | mockBox.EXPECT().Append(mockButton).Once() | 70 | + | mockTitleBar.EXPECT().AddController(mock.Anything).Once() |
| 71 | + | ||||
| 72 | + | // Close button click handler | |||
| 73 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |||
| 74 | + | ||||
| 75 | + | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |||
| 76 | + | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |||
| 77 | + | ||||
| 78 | + | // Adding to main box - now titleBar is added directly (no button wrapper) | |||
| 79 | + | mockBox.EXPECT().Append(mockTitleBar).Once() | |||
| 67 | mockBox.EXPECT().Append(mockContainer).Once() | 80 | mockBox.EXPECT().Append(mockContainer).Once() | ||
| 68 | 81 | ||||
| 69 | - | return mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer | 82 | + | return mockTitleBar, mockFavicon, mockLabel, mockContainer |
| 70 | } | 83 | } | ||
| 71 | 84 | ||||
| 72 | func TestNewStackedView_EmptyStack(t *testing.T) { | 85 | func TestNewStackedView_EmptyStack(t *testing.T) { | ||
| @@ -86,13 +99,12 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) { | |||||
| 86 | // Arrange | 99 | // Arrange | ||
| 87 | ctx := context.Background() | 100 | ctx := context.Background() | ||
| 88 | mockFactory, mockBox := setupMockFactory(t) | 101 | mockFactory, mockBox := setupMockFactory(t) | ||
| 89 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 102 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 90 | _ = mockFavicon | 103 | _ = mockFavicon | ||
| 91 | _ = mockLabel | 104 | _ = mockLabel | ||
| 92 | - | _ = mockButton | |||
| 93 | 105 | ||||
| 94 | - | // Visibility updates for active pane | 106 | + | // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it |
| 95 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 107 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 96 | mockContainer.EXPECT().SetVisible(true).Once() | 108 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 97 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 109 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 98 | 110 | ||||
| @@ -113,26 +125,24 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) { | |||||
| 113 | mockFactory, mockBox := setupMockFactory(t) | 125 | mockFactory, mockBox := setupMockFactory(t) | ||
| 114 | 126 | ||||
| 115 | // First pane | 127 | // First pane | ||
| 116 | - | mockTitleBar1, mockFavicon1, mockLabel1, mockButton1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 128 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 117 | _ = mockFavicon1 | 129 | _ = mockFavicon1 | ||
| 118 | _ = mockLabel1 | 130 | _ = mockLabel1 | ||
| 119 | - | _ = mockButton1 | 131 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 120 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |||
| 121 | mockContainer1.EXPECT().SetVisible(true).Once() | 132 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 122 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 133 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| 123 | 134 | ||||
| 124 | // Second pane - first pane becomes inactive | 135 | // Second pane - first pane becomes inactive | ||
| 125 | - | mockTitleBar2, mockFavicon2, mockLabel2, mockButton2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox) | 136 | + | mockTitleBar2, mockFavicon2, mockLabel2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox) |
| 126 | _ = mockFavicon2 | 137 | _ = mockFavicon2 | ||
| 127 | _ = mockLabel2 | 138 | _ = mockLabel2 | ||
| 128 | - | _ = mockButton2 | |||
| 129 | 139 | ||||
| 130 | // When second pane is added, update visibility for both | 140 | // When second pane is added, update visibility for both | ||
| 131 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 141 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar |
| 132 | mockContainer1.EXPECT().SetVisible(false).Once() | 142 | mockContainer1.EXPECT().SetVisible(false).Once() | ||
| 133 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | 143 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | ||
| 134 | 144 | ||||
| 135 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | 145 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar |
| 136 | mockContainer2.EXPECT().SetVisible(true).Once() | 146 | mockContainer2.EXPECT().SetVisible(true).Once() | ||
| 137 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | 147 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | ||
| 138 | 148 | ||||
| @@ -156,22 +166,17 @@ func TestRemovePane_MiddlePane(t *testing.T) { | |||||
| 156 | // Setup 3 panes (simplified - just track the key behaviors) | 166 | // Setup 3 panes (simplified - just track the key behaviors) | ||
| 157 | containers := make([]*mocks.MockWidget, 3) | 167 | containers := make([]*mocks.MockWidget, 3) | ||
| 158 | titleBars := make([]*mocks.MockBoxWidget, 3) | 168 | titleBars := make([]*mocks.MockBoxWidget, 3) | ||
| 159 | - | buttons := make([]*mocks.MockButtonWidget, 3) | |||
| 160 | 169 | ||||
| 161 | for i := 0; i < 3; i++ { | 170 | for i := 0; i < 3; i++ { | ||
| 162 | - | var btn *mocks.MockButtonWidget | 171 | + | titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) |
| 163 | - | titleBars[i], _, _, btn, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | |||
| 164 | - | buttons[i] = btn | |||
| 165 | } | 172 | } | ||
| 166 | 173 | ||||
| 167 | // Allow any visibility calls during setup and removal | 174 | // Allow any visibility calls during setup and removal | ||
| 168 | for i := 0; i < 3; i++ { | 175 | for i := 0; i < 3; i++ { | ||
| 169 | - | titleBars[i].EXPECT().GetParent().Return(buttons[i]).Maybe() | |||
| 170 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 176 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 177 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() | |||
| 171 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 178 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| 172 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | 179 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | ||
| 173 | - | // Button visibility changes when panes are activated/deactivated | |||
| 174 | - | buttons[i].EXPECT().SetVisible(mock.Anything).Maybe() | |||
| 175 | } | 180 | } | ||
| 176 | 181 | ||||
| 177 | sv := layout.NewStackedView(mockFactory) | 182 | sv := layout.NewStackedView(mockFactory) | ||
| @@ -180,8 +185,8 @@ func TestRemovePane_MiddlePane(t *testing.T) { | |||||
| 180 | sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2]) | 185 | sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2]) | ||
| 181 | 186 | ||||
| 182 | // Remove middle pane (index 1) | 187 | // Remove middle pane (index 1) | ||
| 183 | - | // The parent of titleBar is the button widget | 188 | + | // The titleBar is now directly in the box (no button wrapper) |
| 184 | - | mockBox.EXPECT().Remove(buttons[1]).Once() | 189 | + | mockBox.EXPECT().Remove(titleBars[1]).Once() |
| 185 | mockBox.EXPECT().Remove(containers[1]).Once() | 190 | mockBox.EXPECT().Remove(containers[1]).Once() | ||
| 186 | 191 | ||||
| 187 | // Act | 192 | // Act | ||
| @@ -196,12 +201,11 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) { | |||||
| 196 | // Arrange | 201 | // Arrange | ||
| 197 | ctx := context.Background() | 202 | ctx := context.Background() | ||
| 198 | mockFactory, mockBox := setupMockFactory(t) | 203 | mockFactory, mockBox := setupMockFactory(t) | ||
| 199 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 204 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 200 | _ = mockFavicon | 205 | _ = mockFavicon | ||
| 201 | _ = mockLabel | 206 | _ = mockLabel | ||
| 202 | - | _ = mockButton | |||
| 203 | 207 | ||||
| 204 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 208 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 205 | mockContainer.EXPECT().SetVisible(true).Once() | 209 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 206 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 210 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 207 | 211 | ||||
| @@ -233,12 +237,11 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) { | |||||
| 233 | // Arrange | 237 | // Arrange | ||
| 234 | ctx := context.Background() | 238 | ctx := context.Background() | ||
| 235 | mockFactory, mockBox := setupMockFactory(t) | 239 | mockFactory, mockBox := setupMockFactory(t) | ||
| 236 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 240 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 237 | _ = mockFavicon | 241 | _ = mockFavicon | ||
| 238 | _ = mockLabel | 242 | _ = mockLabel | ||
| 239 | - | _ = mockButton | |||
| 240 | 243 | ||||
| 241 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 244 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 242 | mockContainer.EXPECT().SetVisible(true).Once() | 245 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 243 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 246 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 244 | 247 | ||||
| @@ -261,15 +264,14 @@ func TestSetActive_ValidIndex(t *testing.T) { | |||||
| 261 | titleBars := make([]*mocks.MockBoxWidget, 2) | 264 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 262 | 265 | ||||
| 263 | for i := 0; i < 2; i++ { | 266 | for i := 0; i < 2; i++ { | ||
| 264 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | 267 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) |
| 265 | _ = mockFavicon | 268 | _ = mockFavicon | ||
| 266 | - | _ = mockOverlay | 269 | + | _ = mockLabel |
| 267 | - | _ = mockSpinner | |||
| 268 | 270 | ||||
| 269 | titleBars[i] = titleBar | 271 | titleBars[i] = titleBar | ||
| 270 | containers[i] = container | 272 | containers[i] = container | ||
| 271 | 273 | ||||
| 272 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 274 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() |
| 273 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 275 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 274 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 276 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| 275 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | 277 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -291,12 +293,11 @@ func TestSetActive_OutOfBounds(t *testing.T) { | |||||
| 291 | // Arrange | 293 | // Arrange | ||
| 292 | ctx := context.Background() | 294 | ctx := context.Background() | ||
| 293 | mockFactory, mockBox := setupMockFactory(t) | 295 | mockFactory, mockBox := setupMockFactory(t) | ||
| 294 | - | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 296 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 295 | _ = mockFavicon | 297 | _ = mockFavicon | ||
| 296 | _ = mockLabel | 298 | _ = mockLabel | ||
| 297 | - | _ = mockButton | |||
| 298 | 299 | ||||
| 299 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 300 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 300 | mockContainer.EXPECT().SetVisible(true).Once() | 301 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 301 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 302 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 302 | 303 | ||||
| @@ -327,9 +328,9 @@ func TestUpdateTitle(t *testing.T) { | |||||
| 327 | // Arrange | 328 | // Arrange | ||
| 328 | ctx := context.Background() | 329 | ctx := context.Background() | ||
| 329 | mockFactory, mockBox := setupMockFactory(t) | 330 | mockFactory, mockBox := setupMockFactory(t) | ||
| 330 | - | mockTitleBar, _, mockLabel, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 331 | + | mockTitleBar, _, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 331 | 332 | ||||
| 332 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 333 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 333 | mockContainer.EXPECT().SetVisible(true).Once() | 334 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 334 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 335 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 335 | 336 | ||||
| @@ -361,9 +362,9 @@ func TestUpdateFavicon(t *testing.T) { | |||||
| 361 | // Arrange | 362 | // Arrange | ||
| 362 | ctx := context.Background() | 363 | ctx := context.Background() | ||
| 363 | mockFactory, mockBox := setupMockFactory(t) | 364 | mockFactory, mockBox := setupMockFactory(t) | ||
| 364 | - | mockTitleBar, mockFavicon, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 365 | + | mockTitleBar, mockFavicon, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 365 | 366 | ||||
| 366 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 367 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 367 | mockContainer.EXPECT().SetVisible(true).Once() | 368 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 368 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 369 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 369 | 370 | ||||
| @@ -383,12 +384,11 @@ func TestGetContainer(t *testing.T) { | |||||
| 383 | // Arrange | 384 | // Arrange | ||
| 384 | ctx := context.Background() | 385 | ctx := context.Background() | ||
| 385 | mockFactory, mockBox := setupMockFactory(t) | 386 | mockFactory, mockBox := setupMockFactory(t) | ||
| 386 | - | mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 387 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 387 | _ = mockFavicon | 388 | _ = mockFavicon | ||
| 388 | - | _ = mockOverlay | 389 | + | _ = mockLabel |
| 389 | - | _ = mockSpinner | |||
| 390 | 390 | ||||
| 391 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 391 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 392 | mockContainer.EXPECT().SetVisible(true).Once() | 392 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 393 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 393 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 394 | 394 | ||||
| @@ -437,15 +437,14 @@ func TestNavigateNext_WrapsAround(t *testing.T) { | |||||
| 437 | titleBars := make([]*mocks.MockBoxWidget, 2) | 437 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 438 | 438 | ||||
| 439 | for i := 0; i < 2; i++ { | 439 | for i := 0; i < 2; i++ { | ||
| 440 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | 440 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) |
| 441 | _ = mockFavicon | 441 | _ = mockFavicon | ||
| 442 | - | _ = mockOverlay | 442 | + | _ = mockLabel |
| 443 | - | _ = mockSpinner | |||
| 444 | 443 | ||||
| 445 | titleBars[i] = titleBar | 444 | titleBars[i] = titleBar | ||
| 446 | containers[i] = container | 445 | containers[i] = container | ||
| 447 | 446 | ||||
| 448 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 447 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() |
| 449 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 448 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 450 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 449 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| 451 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | 450 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -475,15 +474,14 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) { | |||||
| 475 | titleBars := make([]*mocks.MockBoxWidget, 2) | 474 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 476 | 475 | ||||
| 477 | for i := 0; i < 2; i++ { | 476 | for i := 0; i < 2; i++ { | ||
| 478 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | 477 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) |
| 479 | _ = mockFavicon | 478 | _ = mockFavicon | ||
| 480 | - | _ = mockOverlay | 479 | + | _ = mockLabel |
| 481 | - | _ = mockSpinner | |||
| 482 | 480 | ||||
| 483 | titleBars[i] = titleBar | 481 | titleBars[i] = titleBar | ||
| 484 | containers[i] = container | 482 | containers[i] = container | ||
| 485 | 483 | ||||
| 486 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 484 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() |
| 487 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 485 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 488 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 486 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| 489 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | 487 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -558,13 +556,15 @@ func setupInsertPaneMocks( | |||||
| 558 | mockTitleBar := mocks.NewMockBoxWidget(t) | 556 | mockTitleBar := mocks.NewMockBoxWidget(t) | ||
| 559 | mockFavicon := mocks.NewMockImageWidget(t) | 557 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| 560 | mockLabel := mocks.NewMockLabelWidget(t) | 558 | mockLabel := mocks.NewMockLabelWidget(t) | ||
| 561 | - | mockButton := mocks.NewMockButtonWidget(t) | 559 | + | mockCloseButton := mocks.NewMockButtonWidget(t) |
| 562 | mockContainer := mocks.NewMockWidget(t) | 560 | mockContainer := mocks.NewMockWidget(t) | ||
| 563 | 561 | ||||
| 564 | - | // Title bar creation | 562 | + | // Title bar creation - now directly used without button wrapper |
| 565 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | 563 | mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once() | ||
| 566 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | 564 | mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | ||
| 565 | + | mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |||
| 567 | mockTitleBar.EXPECT().SetVexpand(false).Once() | 566 | mockTitleBar.EXPECT().SetVexpand(false).Once() | ||
| 567 | + | mockTitleBar.EXPECT().SetHexpand(true).Once() | |||
| 568 | 568 | ||||
| 569 | // Favicon | 569 | // Favicon | ||
| 570 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | 570 | mockFactory.EXPECT().NewImage().Return(mockFavicon).Once() | ||
| @@ -580,23 +580,32 @@ func setupInsertPaneMocks( | |||||
| 580 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | 580 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | ||
| 581 | mockTitleBar.EXPECT().Append(mockLabel).Once() | 581 | mockTitleBar.EXPECT().Append(mockLabel).Once() | ||
| 582 | 582 | ||||
| 583 | - | // Button wrapping title bar | 583 | + | // Close button (uses SetIconName directly instead of child image) |
| 584 | - | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | 584 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() |
| 585 | - | mockButton.EXPECT().SetChild(mockTitleBar).Once() | 585 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() |
| 586 | - | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | 586 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() |
| 587 | - | mockButton.EXPECT().SetFocusOnClick(false).Once() | 587 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() |
| 588 | - | mockButton.EXPECT().SetVexpand(false).Once() | 588 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() |
| 589 | - | mockButton.EXPECT().SetHexpand(true).Once() | 589 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() |
| 590 | - | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | 590 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() |
| 591 | + | ||||
| 592 | + | // GestureClick is added to titleBar via AddController | |||
| 593 | + | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |||
| 591 | 594 | ||||
| 592 | - | // Position-aware insertion using InsertChildAfter | 595 | + | // Close button click handler |
| 596 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |||
| 597 | + | ||||
| 598 | + | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |||
| 599 | + | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |||
| 600 | + | ||||
| 601 | + | // Position-aware insertion using InsertChildAfter - now using titleBar directly | |||
| 593 | if siblingContainer != nil { | 602 | if siblingContainer != nil { | ||
| 594 | - | mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once() | 603 | + | mockBox.EXPECT().InsertChildAfter(mockTitleBar, siblingContainer).Once() |
| 595 | - | mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once() | 604 | + | mockBox.EXPECT().InsertChildAfter(mockContainer, mockTitleBar).Once() |
| 596 | } else { | 605 | } else { | ||
| 597 | // Insert at beginning using Prepend | 606 | // Insert at beginning using Prepend | ||
| 598 | mockBox.EXPECT().Prepend(mockContainer).Once() | 607 | mockBox.EXPECT().Prepend(mockContainer).Once() | ||
| 599 | - | mockBox.EXPECT().Prepend(mockButton).Once() | 608 | + | mockBox.EXPECT().Prepend(mockTitleBar).Once() |
| 600 | } | 609 | } | ||
| 601 | 610 | ||||
| 602 | return mockTitleBar, mockContainer | 611 | return mockTitleBar, mockContainer | ||
| @@ -608,11 +617,10 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||||
| 608 | mockFactory, mockBox := setupMockFactory(t) | 617 | mockFactory, mockBox := setupMockFactory(t) | ||
| 609 | 618 | ||||
| 610 | // First pane - appended normally | 619 | // First pane - appended normally | ||
| 611 | - | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 620 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 612 | _ = mockFavicon1 | 621 | _ = mockFavicon1 | ||
| 613 | - | _ = mockOverlay1 | 622 | + | _ = mockLabel1 |
| 614 | - | _ = mockSpinner1 | 623 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 615 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |||
| 616 | mockContainer1.EXPECT().SetVisible(true).Once() | 624 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 617 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 625 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| 618 | 626 | ||||
| @@ -623,11 +631,11 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||||
| 623 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil) | 631 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil) | ||
| 624 | 632 | ||||
| 625 | // Visibility updates | 633 | // Visibility updates | ||
| 626 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 634 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar |
| 627 | mockContainer1.EXPECT().SetVisible(false).Once() | 635 | mockContainer1.EXPECT().SetVisible(false).Once() | ||
| 628 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | 636 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | ||
| 629 | 637 | ||||
| 630 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | 638 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar |
| 631 | mockContainer2.EXPECT().SetVisible(true).Once() | 639 | mockContainer2.EXPECT().SetVisible(true).Once() | ||
| 632 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | 640 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | ||
| 633 | 641 | ||||
| @@ -650,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) { | |||||
| 650 | titleBars := make([]*mocks.MockBoxWidget, 2) | 658 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 651 | 659 | ||||
| 652 | for i := 0; i < 2; i++ { | 660 | for i := 0; i < 2; i++ { | ||
| 653 | - | titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | 661 | + | titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) |
| 654 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 662 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() |
| 655 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 663 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 656 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 664 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| 657 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | 665 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -666,7 +674,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) { | |||||
| 666 | 674 | ||||
| 667 | // Insert new pane after index 0 (should become index 1) | 675 | // Insert new pane after index 0 (should become index 1) | ||
| 668 | mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0]) | 676 | mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0]) | ||
| 669 | - | mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe() | 677 | + | mockTitleBar3.EXPECT().SetVisible(mock.Anything).Maybe() |
| 670 | mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe() | 678 | mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 671 | mockTitleBar3.EXPECT().AddCssClass("active").Maybe() | 679 | mockTitleBar3.EXPECT().AddCssClass("active").Maybe() | ||
| 672 | mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe() | 680 | mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -686,11 +694,10 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||||
| 686 | mockFactory, mockBox := setupMockFactory(t) | 694 | mockFactory, mockBox := setupMockFactory(t) | ||
| 687 | 695 | ||||
| 688 | // First pane | 696 | // First pane | ||
| 689 | - | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 697 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 690 | _ = mockFavicon1 | 698 | _ = mockFavicon1 | ||
| 691 | - | _ = mockOverlay1 | 699 | + | _ = mockLabel1 |
| 692 | - | _ = mockSpinner1 | 700 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 693 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |||
| 694 | mockContainer1.EXPECT().SetVisible(true).Once() | 701 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 695 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 702 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| 696 | 703 | ||||
| @@ -700,11 +707,11 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||||
| 700 | // Insert after last pane (afterIndex=0, becomes index 1) | 707 | // Insert after last pane (afterIndex=0, becomes index 1) | ||
| 701 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | 708 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | ||
| 702 | 709 | ||||
| 703 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 710 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar |
| 704 | mockContainer1.EXPECT().SetVisible(false).Once() | 711 | mockContainer1.EXPECT().SetVisible(false).Once() | ||
| 705 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | 712 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | ||
| 706 | 713 | ||||
| 707 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | 714 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar |
| 708 | mockContainer2.EXPECT().SetVisible(true).Once() | 715 | mockContainer2.EXPECT().SetVisible(true).Once() | ||
| 709 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | 716 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | ||
| 710 | 717 | ||||
| @@ -727,15 +734,14 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||||
| 727 | titleBars := make([]*mocks.MockBoxWidget, 3) | 734 | titleBars := make([]*mocks.MockBoxWidget, 3) | ||
| 728 | 735 | ||||
| 729 | for i := 0; i < 3; i++ { | 736 | for i := 0; i < 3; i++ { | ||
| 730 | - | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) | 737 | + | titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox) |
| 731 | _ = mockFavicon | 738 | _ = mockFavicon | ||
| 732 | - | _ = mockOverlay | 739 | + | _ = mockLabel |
| 733 | - | _ = mockSpinner | |||
| 734 | 740 | ||||
| 735 | titleBars[i] = titleBar | 741 | titleBars[i] = titleBar | ||
| 736 | containers[i] = container | 742 | containers[i] = container | ||
| 737 | 743 | ||||
| 738 | - | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 744 | + | titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe() |
| 739 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 745 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 740 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 746 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| 741 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | 747 | titleBars[i].EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -751,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||||
| 751 | 757 | ||||
| 752 | // Insert D after B (should be at index 2, C moves to index 3) | 758 | // Insert D after B (should be at index 2, C moves to index 3) | ||
| 753 | mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1]) | 759 | mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1]) | ||
| 754 | - | mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe() | 760 | + | mockTitleBar4.EXPECT().SetVisible(mock.Anything).Maybe() |
| 755 | mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe() | 761 | mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 756 | mockTitleBar4.EXPECT().AddCssClass("active").Maybe() | 762 | mockTitleBar4.EXPECT().AddCssClass("active").Maybe() | ||
| 757 | mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe() | 763 | mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe() | ||
| @@ -782,11 +788,10 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||||
| 782 | mockFactory, mockBox := setupMockFactory(t) | 788 | mockFactory, mockBox := setupMockFactory(t) | ||
| 783 | 789 | ||||
| 784 | // First pane | 790 | // First pane | ||
| 785 | - | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 791 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 786 | _ = mockFavicon1 | 792 | _ = mockFavicon1 | ||
| 787 | - | _ = mockOverlay1 | 793 | + | _ = mockLabel1 |
| 788 | - | _ = mockSpinner1 | 794 | + | mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 789 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | |||
| 790 | mockContainer1.EXPECT().SetVisible(true).Once() | 795 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 791 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 796 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| 792 | 797 | ||||
| @@ -796,11 +801,11 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||||
| 796 | // Try to insert at invalid index (100) - should clamp to end | 801 | // Try to insert at invalid index (100) - should clamp to end | ||
| 797 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | 802 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | ||
| 798 | 803 | ||||
| 799 | - | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 804 | + | mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar |
| 800 | mockContainer1.EXPECT().SetVisible(false).Once() | 805 | mockContainer1.EXPECT().SetVisible(false).Once() | ||
| 801 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | 806 | mockTitleBar1.EXPECT().RemoveCssClass("active").Once() | ||
| 802 | 807 | ||||
| 803 | - | mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe() | 808 | + | mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar |
| 804 | mockContainer2.EXPECT().SetVisible(true).Once() | 809 | mockContainer2.EXPECT().SetVisible(true).Once() | ||
| 805 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | 810 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | ||
| 806 | 811 | ||||
M internal/ui/layout/tree_test.go +3 -4
| @@ -19,11 +19,10 @@ func setupStackedLeafMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) ( | |||
| 19 | 19 | mockStackBox.EXPECT().SetHexpand(true).Once() | |
| 20 | 20 | mockStackBox.EXPECT().SetVexpand(true).Once() | |
| 21 | 21 | ||
| 22 | - | mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox) | |
| 22 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox) | |
| 23 | 23 | _ = mockFavicon | |
| 24 | - | _ = mockOverlay | |
| 25 | - | _ = mockSpinner | |
| 26 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |
| 24 | + | _ = mockLabel | |
| 25 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar | |
| 27 | 26 | mockContainer.EXPECT().SetVisible(true).Once() | |
| 28 | 27 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 29 | 28 | ||
| @@ -19,11 +19,10 @@ func setupStackedLeafMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) ( | |||||
| 19 | mockStackBox.EXPECT().SetHexpand(true).Once() | 19 | mockStackBox.EXPECT().SetHexpand(true).Once() | ||
| 20 | mockStackBox.EXPECT().SetVexpand(true).Once() | 20 | mockStackBox.EXPECT().SetVexpand(true).Once() | ||
| 21 | 21 | ||||
| 22 | - | mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox) | 22 | + | mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox) |
| 23 | _ = mockFavicon | 23 | _ = mockFavicon | ||
| 24 | - | _ = mockOverlay | 24 | + | _ = mockLabel |
| 25 | - | _ = mockSpinner | 25 | + | mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar |
| 26 | - | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | |||
| 27 | mockContainer.EXPECT().SetVisible(true).Once() | 26 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 28 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 27 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 29 | 28 | ||||
M internal/ui/layout/widgets.go +1 -0
| @@ -179,6 +179,7 @@ type ButtonWidget interface { | |||
| 179 | 179 | GetLabel() string | |
| 180 | 180 | SetChild(child Widget) | |
| 181 | 181 | GetChild() Widget | |
| 182 | + | SetIconName(iconName string) | |
| 182 | 183 | ||
| 183 | 184 | // Connect click handler, returns signal ID for disconnection | |
| 184 | 185 | ConnectClicked(callback func()) uint32 | |
| @@ -179,6 +179,7 @@ type ButtonWidget interface { | |||||
| 179 | GetLabel() string | 179 | GetLabel() string | ||
| 180 | SetChild(child Widget) | 180 | SetChild(child Widget) | ||
| 181 | GetChild() Widget | 181 | GetChild() Widget | ||
| 182 | + | SetIconName(iconName string) | |||
| 182 | 183 | ||||
| 183 | // Connect click handler, returns signal ID for disconnection | 184 | // Connect click handler, returns signal ID for disconnection | ||
| 184 | ConnectClicked(callback func()) uint32 | 185 | ConnectClicked(callback func()) uint32 | ||
M internal/ui/theme/css.go +43 -16
| @@ -572,6 +572,7 @@ func generateStackedPaneCSS(p Palette) string { | |||
| 572 | 572 | return `/* ===== Stacked Pane Styling ===== */ | |
| 573 | 573 | ||
| 574 | 574 | /* Title bar for inactive panes in a stack */ | |
| 575 | + | /* The title bar is now a Box with GestureClick, not wrapped in a button */ | |
| 575 | 576 | .stacked-pane-titlebar { | |
| 576 | 577 | background-color: var(--surface-variant); | |
| 577 | 578 | border-bottom: 0.0625em solid var(--border); | |
| @@ -579,27 +580,15 @@ func generateStackedPaneCSS(p Palette) string { | |||
| 579 | 580 | min-height: 1.5em; | |
| 580 | 581 | } | |
| 581 | 582 | ||
| 582 | - | /* Title bar button wrapper - clickable area */ | |
| 583 | - | button.stacked-pane-title-button { | |
| 584 | - | background-color: var(--surface-variant); | |
| 585 | - | background-image: none; | |
| 586 | - | border: none; | |
| 587 | - | border-bottom: 0.0625em solid var(--border); | |
| 588 | - | border-radius: 0; | |
| 589 | - | padding: 0; | |
| 590 | - | margin: 0; | |
| 583 | + | /* Clickable title bar - hover styling */ | |
| 584 | + | .stacked-pane-titlebar.stacked-pane-title-clickable { | |
| 591 | 585 | transition: background-color 150ms ease-in-out; | |
| 592 | 586 | } | |
| 593 | 587 | ||
| 594 | - | button.stacked-pane-title-button:hover { | |
| 588 | + | .stacked-pane-titlebar.stacked-pane-title-clickable:hover { | |
| 595 | 589 | background-color: shade(var(--surface-variant), 1.15); | |
| 596 | 590 | } | |
| 597 | 591 | ||
| 598 | - | button.stacked-pane-title-button:focus { | |
| 599 | - | outline: none; | |
| 600 | - | box-shadow: none; | |
| 601 | - | } | |
| 602 | - | ||
| 603 | 592 | /* Title bar content box */ | |
| 604 | 593 | .stacked-pane-titlebar.active { | |
| 605 | 594 | background-color: shade(var(--surface-variant), 1.2); | |
| @@ -620,9 +609,47 @@ button.stacked-pane-title-button:focus { | |||
| 620 | 609 | font-weight: 400; | |
| 621 | 610 | } | |
| 622 | 611 | ||
| 623 | - | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | |
| 612 | + | /* Hover effect on title bar label */ | |
| 613 | + | .stacked-pane-titlebar.stacked-pane-title-clickable:hover label { | |
| 624 | 614 | color: var(--accent); | |
| 625 | 615 | } | |
| 616 | + | ||
| 617 | + | /* Close button in stacked pane title bar */ | |
| 618 | + | .stacked-pane-close-button { | |
| 619 | + | background: transparent; | |
| 620 | + | border: none; | |
| 621 | + | border-radius: 4px; | |
| 622 | + | padding: 4px; | |
| 623 | + | margin: 0 2px; | |
| 624 | + | min-width: 20px; | |
| 625 | + | min-height: 20px; | |
| 626 | + | opacity: 0.4; | |
| 627 | + | transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out; | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | .stacked-pane-close-button > image { | |
| 631 | + | margin: 0; | |
| 632 | + | padding: 0; | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | .stacked-pane-close-button:hover { | |
| 636 | + | opacity: 1.0; | |
| 637 | + | background: alpha(var(--destructive), 0.2); | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | .stacked-pane-close-button:hover > image { | |
| 641 | + | color: var(--destructive); | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | /* Show close button more prominently when hovering title bar */ | |
| 645 | + | .stacked-pane-titlebar.stacked-pane-title-clickable:hover .stacked-pane-close-button { | |
| 646 | + | opacity: 0.7; | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | .stacked-pane-close-button:focus { | |
| 650 | + | outline: none; | |
| 651 | + | box-shadow: none; | |
| 652 | + | } | |
| 626 | 653 | ` | |
| 627 | 654 | } | |
| 628 | 655 | ||
| @@ -572,6 +572,7 @@ func generateStackedPaneCSS(p Palette) string { | |||||
| 572 | return `/* ===== Stacked Pane Styling ===== */ | 572 | return `/* ===== Stacked Pane Styling ===== */ | ||
| 573 | 573 | ||||
| 574 | /* Title bar for inactive panes in a stack */ | 574 | /* Title bar for inactive panes in a stack */ | ||
| 575 | + | /* The title bar is now a Box with GestureClick, not wrapped in a button */ | |||
| 575 | .stacked-pane-titlebar { | 576 | .stacked-pane-titlebar { | ||
| 576 | background-color: var(--surface-variant); | 577 | background-color: var(--surface-variant); | ||
| 577 | border-bottom: 0.0625em solid var(--border); | 578 | border-bottom: 0.0625em solid var(--border); | ||
| @@ -579,27 +580,15 @@ func generateStackedPaneCSS(p Palette) string { | |||||
| 579 | min-height: 1.5em; | 580 | min-height: 1.5em; | ||
| 580 | } | 581 | } | ||
| 581 | 582 | ||||
| 582 | - | /* Title bar button wrapper - clickable area */ | 583 | + | /* Clickable title bar - hover styling */ |
| 583 | - | button.stacked-pane-title-button { | 584 | + | .stacked-pane-titlebar.stacked-pane-title-clickable { |
| 584 | - | background-color: var(--surface-variant); | |||
| 585 | - | background-image: none; | |||
| 586 | - | border: none; | |||
| 587 | - | border-bottom: 0.0625em solid var(--border); | |||
| 588 | - | border-radius: 0; | |||
| 589 | - | padding: 0; | |||
| 590 | - | margin: 0; | |||
| 591 | transition: background-color 150ms ease-in-out; | 585 | transition: background-color 150ms ease-in-out; | ||
| 592 | } | 586 | } | ||
| 593 | 587 | ||||
| 594 | - | button.stacked-pane-title-button:hover { | 588 | + | .stacked-pane-titlebar.stacked-pane-title-clickable:hover { |
| 595 | background-color: shade(var(--surface-variant), 1.15); | 589 | background-color: shade(var(--surface-variant), 1.15); | ||
| 596 | } | 590 | } | ||
| 597 | 591 | ||||
| 598 | - | button.stacked-pane-title-button:focus { | |||
| 599 | - | outline: none; | |||
| 600 | - | box-shadow: none; | |||
| 601 | - | } | |||
| 602 | - | ||||
| 603 | /* Title bar content box */ | 592 | /* Title bar content box */ | ||
| 604 | .stacked-pane-titlebar.active { | 593 | .stacked-pane-titlebar.active { | ||
| 605 | background-color: shade(var(--surface-variant), 1.2); | 594 | background-color: shade(var(--surface-variant), 1.2); | ||
| @@ -620,9 +609,47 @@ button.stacked-pane-title-button:focus { | |||||
| 620 | font-weight: 400; | 609 | font-weight: 400; | ||
| 621 | } | 610 | } | ||
| 622 | 611 | ||||
| 623 | - | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | 612 | + | /* Hover effect on title bar label */ |
| 613 | + | .stacked-pane-titlebar.stacked-pane-title-clickable:hover label { | |||
| 624 | color: var(--accent); | 614 | color: var(--accent); | ||
| 625 | } | 615 | } | ||
| 616 | + | ||||
| 617 | + | /* Close button in stacked pane title bar */ | |||
| 618 | + | .stacked-pane-close-button { | |||
| 619 | + | background: transparent; | |||
| 620 | + | border: none; | |||
| 621 | + | border-radius: 4px; | |||
| 622 | + | padding: 4px; | |||
| 623 | + | margin: 0 2px; | |||
| 624 | + | min-width: 20px; | |||
| 625 | + | min-height: 20px; | |||
| 626 | + | opacity: 0.4; | |||
| 627 | + | transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out; | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | .stacked-pane-close-button > image { | |||
| 631 | + | margin: 0; | |||
| 632 | + | padding: 0; | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | .stacked-pane-close-button:hover { | |||
| 636 | + | opacity: 1.0; | |||
| 637 | + | background: alpha(var(--destructive), 0.2); | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | .stacked-pane-close-button:hover > image { | |||
| 641 | + | color: var(--destructive); | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | /* Show close button more prominently when hovering title bar */ | |||
| 645 | + | .stacked-pane-titlebar.stacked-pane-title-clickable:hover .stacked-pane-close-button { | |||
| 646 | + | opacity: 0.7; | |||
| 647 | + | } | |||
| 648 | + | ||||
| 649 | + | .stacked-pane-close-button:focus { | |||
| 650 | + | outline: none; | |||
| 651 | + | box-shadow: none; | |||
| 652 | + | } | |||
| 626 | ` | 653 | ` | ||
| 627 | } | 654 | } | ||
| 628 | 655 | ||||