feat(context-menu): unify WebKit and CEF right-click handling
- commit
- 3ecba54bc867b85ecc00ea6b42ed6a7b9a1f0559
- author
- brice · · 2026-04-13 14:35 UTC
- committer
- brice · · 2026-04-14 12:06 UTC
- parent
- d7aff0c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
38 files · +1969 -188
M internal/application/port/clipboard.go +3 -0
| @@ -8,6 +8,9 @@ type Clipboard interface { | |||
| 8 | 8 | // WriteText copies text to the clipboard. | |
| 9 | 9 | WriteText(ctx context.Context, text string) error | |
| 10 | 10 | ||
| 11 | + | // WriteImage copies encoded image bytes to the clipboard. | |
| 12 | + | WriteImage(ctx context.Context, image ImageData) error | |
| 13 | + | ||
| 11 | 14 | // ReadText reads text from the clipboard. | |
| 12 | 15 | // Returns empty string if clipboard is empty or contains non-text data. | |
| 13 | 16 | ReadText(ctx context.Context) (string, error) | |
| @@ -8,6 +8,9 @@ type Clipboard interface { | |||||
| 8 | // WriteText copies text to the clipboard. | 8 | // WriteText copies text to the clipboard. | ||
| 9 | WriteText(ctx context.Context, text string) error | 9 | WriteText(ctx context.Context, text string) error | ||
| 10 | 10 | ||||
| 11 | + | // WriteImage copies encoded image bytes to the clipboard. | |||
| 12 | + | WriteImage(ctx context.Context, image ImageData) error | |||
| 13 | + | ||||
| 11 | // ReadText reads text from the clipboard. | 14 | // ReadText reads text from the clipboard. | ||
| 12 | // Returns empty string if clipboard is empty or contains non-text data. | 15 | // Returns empty string if clipboard is empty or contains non-text data. | ||
| 13 | ReadText(ctx context.Context) (string, error) | 16 | ReadText(ctx context.Context) (string, error) | ||
M internal/application/port/config_transformer.go +4 -0
| @@ -6,4 +6,8 @@ type ConfigTransformer interface { | |||
| 6 | 6 | // to new ActionBinding format (map with keys and desc). | |
| 7 | 7 | // Modifies the rawConfig map in place. | |
| 8 | 8 | TransformLegacyActions(rawConfig map[string]any) | |
| 9 | + | ||
| 10 | + | // TransformLegacyEngineConfig removes deprecated engine config keys. | |
| 11 | + | // Modifies the rawConfig map in place. | |
| 12 | + | TransformLegacyEngineConfig(rawConfig map[string]any) | |
| 9 | 13 | } | |
| @@ -6,4 +6,8 @@ type ConfigTransformer interface { | |||||
| 6 | // to new ActionBinding format (map with keys and desc). | 6 | // to new ActionBinding format (map with keys and desc). | ||
| 7 | // Modifies the rawConfig map in place. | 7 | // Modifies the rawConfig map in place. | ||
| 8 | TransformLegacyActions(rawConfig map[string]any) | 8 | TransformLegacyActions(rawConfig map[string]any) | ||
| 9 | + | ||||
| 10 | + | // TransformLegacyEngineConfig removes deprecated engine config keys. | |||
| 11 | + | // Modifies the rawConfig map in place. | |||
| 12 | + | TransformLegacyEngineConfig(rawConfig map[string]any) | |||
| 9 | } | 13 | } | ||
A internal/application/port/context_menu.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // MenuAction identifies a normalized context menu action. | |
| 6 | + | type MenuAction string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | MenuActionBack MenuAction = "back" | |
| 10 | + | MenuActionForward MenuAction = "forward" | |
| 11 | + | MenuActionReload MenuAction = "reload" | |
| 12 | + | MenuActionOpenLinkNewTab MenuAction = "open_link_new_tab" | |
| 13 | + | MenuActionCopyLink MenuAction = "copy_link" | |
| 14 | + | MenuActionCopyImage MenuAction = "copy_image" | |
| 15 | + | MenuActionSaveImage MenuAction = "save_image" | |
| 16 | + | MenuActionInspectElement MenuAction = "inspect_element" | |
| 17 | + | MenuActionCopySelection MenuAction = "copy_selection" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // MenuContext captures the state needed to build and execute a context menu. | |
| 21 | + | type MenuContext struct { | |
| 22 | + | PageURI string | |
| 23 | + | LinkURI string | |
| 24 | + | ImageURI string | |
| 25 | + | HasSelection bool | |
| 26 | + | IsEditable bool | |
| 27 | + | CanGoBack bool | |
| 28 | + | CanGoForward bool | |
| 29 | + | X int | |
| 30 | + | Y int | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // MenuItem is a normalized context menu entry. | |
| 34 | + | type MenuItem struct { | |
| 35 | + | Action MenuAction | |
| 36 | + | Label string | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // MenuActionDelegator handles actions that are not shared application concerns. | |
| 40 | + | type MenuActionDelegator interface { | |
| 41 | + | DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // MenuAction identifies a normalized context menu action. | |||
| 6 | + | type MenuAction string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | MenuActionBack MenuAction = "back" | |||
| 10 | + | MenuActionForward MenuAction = "forward" | |||
| 11 | + | MenuActionReload MenuAction = "reload" | |||
| 12 | + | MenuActionOpenLinkNewTab MenuAction = "open_link_new_tab" | |||
| 13 | + | MenuActionCopyLink MenuAction = "copy_link" | |||
| 14 | + | MenuActionCopyImage MenuAction = "copy_image" | |||
| 15 | + | MenuActionSaveImage MenuAction = "save_image" | |||
| 16 | + | MenuActionInspectElement MenuAction = "inspect_element" | |||
| 17 | + | MenuActionCopySelection MenuAction = "copy_selection" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // MenuContext captures the state needed to build and execute a context menu. | |||
| 21 | + | type MenuContext struct { | |||
| 22 | + | PageURI string | |||
| 23 | + | LinkURI string | |||
| 24 | + | ImageURI string | |||
| 25 | + | HasSelection bool | |||
| 26 | + | IsEditable bool | |||
| 27 | + | CanGoBack bool | |||
| 28 | + | CanGoForward bool | |||
| 29 | + | X int | |||
| 30 | + | Y int | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // MenuItem is a normalized context menu entry. | |||
| 34 | + | type MenuItem struct { | |||
| 35 | + | Action MenuAction | |||
| 36 | + | Label string | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // MenuActionDelegator handles actions that are not shared application concerns. | |||
| 40 | + | type MenuActionDelegator interface { | |||
| 41 | + | DelegateMenuAction(ctx context.Context, action MenuAction, menuContext MenuContext) error | |||
| 42 | + | } | |||
A internal/application/port/image_data.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // ImageData contains resolved image bytes. | |
| 6 | + | type ImageData struct { | |
| 7 | + | Bytes []byte | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // ImageDataResolver resolves image bytes from a URI. | |
| 11 | + | type ImageDataResolver interface { | |
| 12 | + | ResolveImageData(ctx context.Context, uri string) (ImageData, error) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // ResolvedImageSaver persists already resolved image bytes. | |
| 16 | + | type ResolvedImageSaver interface { | |
| 17 | + | SaveResolvedImage(ctx context.Context, image ImageData) error | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // ImageData contains resolved image bytes. | |||
| 6 | + | type ImageData struct { | |||
| 7 | + | Bytes []byte | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | // ImageDataResolver resolves image bytes from a URI. | |||
| 11 | + | type ImageDataResolver interface { | |||
| 12 | + | ResolveImageData(ctx context.Context, uri string) (ImageData, error) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // ResolvedImageSaver persists already resolved image bytes. | |||
| 16 | + | type ResolvedImageSaver interface { | |||
| 17 | + | SaveResolvedImage(ctx context.Context, image ImageData) error | |||
| 18 | + | } | |||
M internal/application/port/mocks/mocks.go +181 -0
| @@ -387,6 +387,63 @@ func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error { | |||
| 387 | 387 | return r0 | |
| 388 | 388 | } | |
| 389 | 389 | ||
| 390 | + | // WriteImage provides a mock function for the type MockClipboard | |
| 391 | + | func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error { | |
| 392 | + | ret := _mock.Called(ctx, image) | |
| 393 | + | ||
| 394 | + | if len(ret) == 0 { | |
| 395 | + | panic("no return value specified for WriteImage") | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | var r0 error | |
| 399 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok { | |
| 400 | + | r0 = returnFunc(ctx, image) | |
| 401 | + | } else { | |
| 402 | + | r0 = ret.Error(0) | |
| 403 | + | } | |
| 404 | + | return r0 | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // MockClipboard_WriteImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteImage' | |
| 408 | + | type MockClipboard_WriteImage_Call struct { | |
| 409 | + | *mock.Call | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | // WriteImage is a helper method to define mock.On call | |
| 413 | + | // - ctx context.Context | |
| 414 | + | // - image port.ImageData | |
| 415 | + | func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call { | |
| 416 | + | return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)} | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call { | |
| 420 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 421 | + | var arg0 context.Context | |
| 422 | + | if args[0] != nil { | |
| 423 | + | arg0 = args[0].(context.Context) | |
| 424 | + | } | |
| 425 | + | var arg1 port.ImageData | |
| 426 | + | if args[1] != nil { | |
| 427 | + | arg1 = args[1].(port.ImageData) | |
| 428 | + | } | |
| 429 | + | run( | |
| 430 | + | arg0, | |
| 431 | + | arg1, | |
| 432 | + | ) | |
| 433 | + | }) | |
| 434 | + | return _c | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteImage_Call { | |
| 438 | + | _c.Call.Return(err) | |
| 439 | + | return _c | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call { | |
| 443 | + | _c.Call.Return(run) | |
| 444 | + | return _c | |
| 445 | + | } | |
| 446 | + | ||
| 390 | 447 | // MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText' | |
| 391 | 448 | type MockClipboard_WriteText_Call struct { | |
| 392 | 449 | *mock.Call | |
| @@ -427,6 +484,90 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||
| 427 | 484 | return _c | |
| 428 | 485 | } | |
| 429 | 486 | ||
| 487 | + | // NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 488 | + | // The first argument is typically a *testing.T value. | |
| 489 | + | func NewMockResolvedImageSaver(t interface { | |
| 490 | + | mock.TestingT | |
| 491 | + | Cleanup(func()) | |
| 492 | + | }) *MockResolvedImageSaver { | |
| 493 | + | mock := &MockResolvedImageSaver{} | |
| 494 | + | mock.Mock.Test(t) | |
| 495 | + | ||
| 496 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 497 | + | ||
| 498 | + | return mock | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type | |
| 502 | + | type MockResolvedImageSaver struct { | |
| 503 | + | mock.Mock | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | type MockResolvedImageSaver_Expecter struct { | |
| 507 | + | mock *mock.Mock | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter { | |
| 511 | + | return &MockResolvedImageSaver_Expecter{mock: &_m.Mock} | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver | |
| 515 | + | func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error { | |
| 516 | + | ret := _mock.Called(ctx, image) | |
| 517 | + | ||
| 518 | + | if len(ret) == 0 { | |
| 519 | + | panic("no return value specified for SaveResolvedImage") | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | var r0 error | |
| 523 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok { | |
| 524 | + | r0 = returnFunc(ctx, image) | |
| 525 | + | } else { | |
| 526 | + | r0 = ret.Error(0) | |
| 527 | + | } | |
| 528 | + | return r0 | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | // MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage' | |
| 532 | + | type MockResolvedImageSaver_SaveResolvedImage_Call struct { | |
| 533 | + | *mock.Call | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // SaveResolvedImage is a helper method to define mock.On call | |
| 537 | + | // - ctx context.Context | |
| 538 | + | // - image port.ImageData | |
| 539 | + | func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 540 | + | return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image)} | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 544 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 545 | + | var arg0 context.Context | |
| 546 | + | if args[0] != nil { | |
| 547 | + | arg0 = args[0].(context.Context) | |
| 548 | + | } | |
| 549 | + | var arg1 port.ImageData | |
| 550 | + | if args[1] != nil { | |
| 551 | + | arg1 = args[1].(port.ImageData) | |
| 552 | + | } | |
| 553 | + | run( | |
| 554 | + | arg0, | |
| 555 | + | arg1, | |
| 556 | + | ) | |
| 557 | + | }) | |
| 558 | + | return _c | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 562 | + | _c.Call.Return(err) | |
| 563 | + | return _c | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |
| 567 | + | _c.Call.Return(run) | |
| 568 | + | return _c | |
| 569 | + | } | |
| 570 | + | ||
| 430 | 571 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 431 | 572 | // The first argument is typically a *testing.T value. | |
| 432 | 573 | func NewMockColorSchemeDetector(t interface { | |
| @@ -1421,6 +1562,46 @@ func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run fu | |||
| 1421 | 1562 | return _c | |
| 1422 | 1563 | } | |
| 1423 | 1564 | ||
| 1565 | + | // TransformLegacyEngineConfig provides a mock function for the type MockConfigTransformer | |
| 1566 | + | func (_mock *MockConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 1567 | + | _mock.Called(rawConfig) | |
| 1568 | + | return | |
| 1569 | + | } | |
| 1570 | + | ||
| 1571 | + | // MockConfigTransformer_TransformLegacyEngineConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyEngineConfig' | |
| 1572 | + | type MockConfigTransformer_TransformLegacyEngineConfig_Call struct { | |
| 1573 | + | *mock.Call | |
| 1574 | + | } | |
| 1575 | + | ||
| 1576 | + | // TransformLegacyEngineConfig is a helper method to define mock.On call | |
| 1577 | + | // - rawConfig map[string]any | |
| 1578 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyEngineConfig(rawConfig interface{}) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1579 | + | return &MockConfigTransformer_TransformLegacyEngineConfig_Call{Call: _e.mock.On("TransformLegacyEngineConfig", rawConfig)} | |
| 1580 | + | } | |
| 1581 | + | ||
| 1582 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1583 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1584 | + | var arg0 map[string]any | |
| 1585 | + | if args[0] != nil { | |
| 1586 | + | arg0 = args[0].(map[string]any) | |
| 1587 | + | } | |
| 1588 | + | run( | |
| 1589 | + | arg0, | |
| 1590 | + | ) | |
| 1591 | + | }) | |
| 1592 | + | return _c | |
| 1593 | + | } | |
| 1594 | + | ||
| 1595 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Return() *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1596 | + | _c.Call.Return() | |
| 1597 | + | return _c | |
| 1598 | + | } | |
| 1599 | + | ||
| 1600 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |
| 1601 | + | _c.Run(run) | |
| 1602 | + | return _c | |
| 1603 | + | } | |
| 1604 | + | ||
| 1424 | 1605 | // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1425 | 1606 | // The first argument is typically a *testing.T value. | |
| 1426 | 1607 | func NewMockContentInjector(t interface { | |
| @@ -387,6 +387,63 @@ func (_mock *MockClipboard) WriteText(ctx context.Context, text string) error { | |||||
| 387 | return r0 | 387 | return r0 | ||
| 388 | } | 388 | } | ||
| 389 | 389 | ||||
| 390 | + | // WriteImage provides a mock function for the type MockClipboard | |||
| 391 | + | func (_mock *MockClipboard) WriteImage(ctx context.Context, image port.ImageData) error { | |||
| 392 | + | ret := _mock.Called(ctx, image) | |||
| 393 | + | ||||
| 394 | + | if len(ret) == 0 { | |||
| 395 | + | panic("no return value specified for WriteImage") | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | var r0 error | |||
| 399 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok { | |||
| 400 | + | r0 = returnFunc(ctx, image) | |||
| 401 | + | } else { | |||
| 402 | + | r0 = ret.Error(0) | |||
| 403 | + | } | |||
| 404 | + | return r0 | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // MockClipboard_WriteImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteImage' | |||
| 408 | + | type MockClipboard_WriteImage_Call struct { | |||
| 409 | + | *mock.Call | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // WriteImage is a helper method to define mock.On call | |||
| 413 | + | // - ctx context.Context | |||
| 414 | + | // - image port.ImageData | |||
| 415 | + | func (_e *MockClipboard_Expecter) WriteImage(ctx interface{}, image interface{}) *MockClipboard_WriteImage_Call { | |||
| 416 | + | return &MockClipboard_WriteImage_Call{Call: _e.mock.On("WriteImage", ctx, image)} | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func (_c *MockClipboard_WriteImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockClipboard_WriteImage_Call { | |||
| 420 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 421 | + | var arg0 context.Context | |||
| 422 | + | if args[0] != nil { | |||
| 423 | + | arg0 = args[0].(context.Context) | |||
| 424 | + | } | |||
| 425 | + | var arg1 port.ImageData | |||
| 426 | + | if args[1] != nil { | |||
| 427 | + | arg1 = args[1].(port.ImageData) | |||
| 428 | + | } | |||
| 429 | + | run( | |||
| 430 | + | arg0, | |||
| 431 | + | arg1, | |||
| 432 | + | ) | |||
| 433 | + | }) | |||
| 434 | + | return _c | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func (_c *MockClipboard_WriteImage_Call) Return(err error) *MockClipboard_WriteImage_Call { | |||
| 438 | + | _c.Call.Return(err) | |||
| 439 | + | return _c | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | func (_c *MockClipboard_WriteImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockClipboard_WriteImage_Call { | |||
| 443 | + | _c.Call.Return(run) | |||
| 444 | + | return _c | |||
| 445 | + | } | |||
| 446 | + | ||||
| 390 | // MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText' | 447 | // MockClipboard_WriteText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteText' | ||
| 391 | type MockClipboard_WriteText_Call struct { | 448 | type MockClipboard_WriteText_Call struct { | ||
| 392 | *mock.Call | 449 | *mock.Call | ||
| @@ -427,6 +484,90 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||||
| 427 | return _c | 484 | return _c | ||
| 428 | } | 485 | } | ||
| 429 | 486 | ||||
| 487 | + | // NewMockResolvedImageSaver creates a new instance of MockResolvedImageSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 488 | + | // The first argument is typically a *testing.T value. | |||
| 489 | + | func NewMockResolvedImageSaver(t interface { | |||
| 490 | + | mock.TestingT | |||
| 491 | + | Cleanup(func()) | |||
| 492 | + | }) *MockResolvedImageSaver { | |||
| 493 | + | mock := &MockResolvedImageSaver{} | |||
| 494 | + | mock.Mock.Test(t) | |||
| 495 | + | ||||
| 496 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 497 | + | ||||
| 498 | + | return mock | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | // MockResolvedImageSaver is an autogenerated mock type for the ResolvedImageSaver type | |||
| 502 | + | type MockResolvedImageSaver struct { | |||
| 503 | + | mock.Mock | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | type MockResolvedImageSaver_Expecter struct { | |||
| 507 | + | mock *mock.Mock | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | func (_m *MockResolvedImageSaver) EXPECT() *MockResolvedImageSaver_Expecter { | |||
| 511 | + | return &MockResolvedImageSaver_Expecter{mock: &_m.Mock} | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | // SaveResolvedImage provides a mock function for the type MockResolvedImageSaver | |||
| 515 | + | func (_mock *MockResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error { | |||
| 516 | + | ret := _mock.Called(ctx, image) | |||
| 517 | + | ||||
| 518 | + | if len(ret) == 0 { | |||
| 519 | + | panic("no return value specified for SaveResolvedImage") | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | var r0 error | |||
| 523 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, port.ImageData) error); ok { | |||
| 524 | + | r0 = returnFunc(ctx, image) | |||
| 525 | + | } else { | |||
| 526 | + | r0 = ret.Error(0) | |||
| 527 | + | } | |||
| 528 | + | return r0 | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | // MockResolvedImageSaver_SaveResolvedImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveResolvedImage' | |||
| 532 | + | type MockResolvedImageSaver_SaveResolvedImage_Call struct { | |||
| 533 | + | *mock.Call | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | // SaveResolvedImage is a helper method to define mock.On call | |||
| 537 | + | // - ctx context.Context | |||
| 538 | + | // - image port.ImageData | |||
| 539 | + | func (_e *MockResolvedImageSaver_Expecter) SaveResolvedImage(ctx interface{}, image interface{}) *MockResolvedImageSaver_SaveResolvedImage_Call { | |||
| 540 | + | return &MockResolvedImageSaver_SaveResolvedImage_Call{Call: _e.mock.On("SaveResolvedImage", ctx, image)} | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Run(run func(ctx context.Context, image port.ImageData)) *MockResolvedImageSaver_SaveResolvedImage_Call { | |||
| 544 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 545 | + | var arg0 context.Context | |||
| 546 | + | if args[0] != nil { | |||
| 547 | + | arg0 = args[0].(context.Context) | |||
| 548 | + | } | |||
| 549 | + | var arg1 port.ImageData | |||
| 550 | + | if args[1] != nil { | |||
| 551 | + | arg1 = args[1].(port.ImageData) | |||
| 552 | + | } | |||
| 553 | + | run( | |||
| 554 | + | arg0, | |||
| 555 | + | arg1, | |||
| 556 | + | ) | |||
| 557 | + | }) | |||
| 558 | + | return _c | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) Return(err error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |||
| 562 | + | _c.Call.Return(err) | |||
| 563 | + | return _c | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | func (_c *MockResolvedImageSaver_SaveResolvedImage_Call) RunAndReturn(run func(ctx context.Context, image port.ImageData) error) *MockResolvedImageSaver_SaveResolvedImage_Call { | |||
| 567 | + | _c.Call.Return(run) | |||
| 568 | + | return _c | |||
| 569 | + | } | |||
| 570 | + | ||||
| 430 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 571 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 431 | // The first argument is typically a *testing.T value. | 572 | // The first argument is typically a *testing.T value. | ||
| 432 | func NewMockColorSchemeDetector(t interface { | 573 | func NewMockColorSchemeDetector(t interface { | ||
| @@ -1421,6 +1562,46 @@ func (_c *MockConfigTransformer_TransformLegacyActions_Call) RunAndReturn(run fu | |||||
| 1421 | return _c | 1562 | return _c | ||
| 1422 | } | 1563 | } | ||
| 1423 | 1564 | ||||
| 1565 | + | // TransformLegacyEngineConfig provides a mock function for the type MockConfigTransformer | |||
| 1566 | + | func (_mock *MockConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |||
| 1567 | + | _mock.Called(rawConfig) | |||
| 1568 | + | return | |||
| 1569 | + | } | |||
| 1570 | + | ||||
| 1571 | + | // MockConfigTransformer_TransformLegacyEngineConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransformLegacyEngineConfig' | |||
| 1572 | + | type MockConfigTransformer_TransformLegacyEngineConfig_Call struct { | |||
| 1573 | + | *mock.Call | |||
| 1574 | + | } | |||
| 1575 | + | ||||
| 1576 | + | // TransformLegacyEngineConfig is a helper method to define mock.On call | |||
| 1577 | + | // - rawConfig map[string]any | |||
| 1578 | + | func (_e *MockConfigTransformer_Expecter) TransformLegacyEngineConfig(rawConfig interface{}) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |||
| 1579 | + | return &MockConfigTransformer_TransformLegacyEngineConfig_Call{Call: _e.mock.On("TransformLegacyEngineConfig", rawConfig)} | |||
| 1580 | + | } | |||
| 1581 | + | ||||
| 1582 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Run(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |||
| 1583 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1584 | + | var arg0 map[string]any | |||
| 1585 | + | if args[0] != nil { | |||
| 1586 | + | arg0 = args[0].(map[string]any) | |||
| 1587 | + | } | |||
| 1588 | + | run( | |||
| 1589 | + | arg0, | |||
| 1590 | + | ) | |||
| 1591 | + | }) | |||
| 1592 | + | return _c | |||
| 1593 | + | } | |||
| 1594 | + | ||||
| 1595 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) Return() *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |||
| 1596 | + | _c.Call.Return() | |||
| 1597 | + | return _c | |||
| 1598 | + | } | |||
| 1599 | + | ||||
| 1600 | + | func (_c *MockConfigTransformer_TransformLegacyEngineConfig_Call) RunAndReturn(run func(rawConfig map[string]any)) *MockConfigTransformer_TransformLegacyEngineConfig_Call { | |||
| 1601 | + | _c.Run(run) | |||
| 1602 | + | return _c | |||
| 1603 | + | } | |||
| 1604 | + | ||||
| 1424 | // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1605 | // NewMockContentInjector creates a new instance of MockContentInjector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1425 | // The first argument is typically a *testing.T value. | 1606 | // The first argument is typically a *testing.T value. | ||
| 1426 | func NewMockContentInjector(t interface { | 1607 | func NewMockContentInjector(t interface { | ||
A internal/application/usecase/build_context_menu.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // BuildContextMenuUseCase normalizes context menu items from menu state. | |
| 10 | + | type BuildContextMenuUseCase struct{} | |
| 11 | + | ||
| 12 | + | // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase. | |
| 13 | + | func NewBuildContextMenuUseCase() *BuildContextMenuUseCase { | |
| 14 | + | return &BuildContextMenuUseCase{} | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // Build returns normalized menu items for the supplied context. | |
| 18 | + | func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem { | |
| 19 | + | items := make([]port.MenuItem, 0, 9) | |
| 20 | + | ||
| 21 | + | if menuContext.CanGoBack { | |
| 22 | + | items = append(items, port.MenuItem{Action: port.MenuActionBack, Label: "Back"}) | |
| 23 | + | } | |
| 24 | + | if menuContext.CanGoForward { | |
| 25 | + | items = append(items, port.MenuItem{Action: port.MenuActionForward, Label: "Forward"}) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | items = append(items, port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}) | |
| 29 | + | ||
| 30 | + | if menuContext.LinkURI != "" { | |
| 31 | + | items = append(items, | |
| 32 | + | port.MenuItem{Action: port.MenuActionOpenLinkNewTab, Label: "Open Link in New Tab"}, | |
| 33 | + | port.MenuItem{Action: port.MenuActionCopyLink, Label: "Copy Link"}, | |
| 34 | + | ) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | if menuContext.ImageURI != "" { | |
| 38 | + | items = append(items, | |
| 39 | + | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |
| 40 | + | port.MenuItem{Action: port.MenuActionSaveImage, Label: "Save Image"}, | |
| 41 | + | ) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | if menuContext.HasSelection { | |
| 45 | + | items = append(items, port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy Selection"}) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | items = append(items, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |
| 49 | + | ||
| 50 | + | return items | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // BuildContextMenuUseCase normalizes context menu items from menu state. | |||
| 10 | + | type BuildContextMenuUseCase struct{} | |||
| 11 | + | ||||
| 12 | + | // NewBuildContextMenuUseCase creates a new BuildContextMenuUseCase. | |||
| 13 | + | func NewBuildContextMenuUseCase() *BuildContextMenuUseCase { | |||
| 14 | + | return &BuildContextMenuUseCase{} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // Build returns normalized menu items for the supplied context. | |||
| 18 | + | func (*BuildContextMenuUseCase) Build(_ context.Context, menuContext port.MenuContext) []port.MenuItem { | |||
| 19 | + | items := make([]port.MenuItem, 0, 9) | |||
| 20 | + | ||||
| 21 | + | if menuContext.CanGoBack { | |||
| 22 | + | items = append(items, port.MenuItem{Action: port.MenuActionBack, Label: "Back"}) | |||
| 23 | + | } | |||
| 24 | + | if menuContext.CanGoForward { | |||
| 25 | + | items = append(items, port.MenuItem{Action: port.MenuActionForward, Label: "Forward"}) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | items = append(items, port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}) | |||
| 29 | + | ||||
| 30 | + | if menuContext.LinkURI != "" { | |||
| 31 | + | items = append(items, | |||
| 32 | + | port.MenuItem{Action: port.MenuActionOpenLinkNewTab, Label: "Open Link in New Tab"}, | |||
| 33 | + | port.MenuItem{Action: port.MenuActionCopyLink, Label: "Copy Link"}, | |||
| 34 | + | ) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | if menuContext.ImageURI != "" { | |||
| 38 | + | items = append(items, | |||
| 39 | + | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |||
| 40 | + | port.MenuItem{Action: port.MenuActionSaveImage, Label: "Save Image"}, | |||
| 41 | + | ) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | if menuContext.HasSelection { | |||
| 45 | + | items = append(items, port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy Selection"}) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | items = append(items, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |||
| 49 | + | ||||
| 50 | + | return items | |||
| 51 | + | } | |||
A internal/application/usecase/build_context_menu_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBuildContextMenuUseCase_Build(t *testing.T) { | |
| 12 | + | uc := NewBuildContextMenuUseCase() | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | context port.MenuContext | |
| 17 | + | expected []port.MenuAction | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "empty context baseline", | |
| 21 | + | context: port.MenuContext{}, | |
| 22 | + | expected: []port.MenuAction{port.MenuActionReload, port.MenuActionInspectElement}, | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | name: "image only", | |
| 26 | + | context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 27 | + | expected: []port.MenuAction{ | |
| 28 | + | port.MenuActionReload, | |
| 29 | + | port.MenuActionCopyImage, | |
| 30 | + | port.MenuActionSaveImage, | |
| 31 | + | port.MenuActionInspectElement, | |
| 32 | + | }, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "link only", | |
| 36 | + | context: port.MenuContext{LinkURI: "https://example.com/link"}, | |
| 37 | + | expected: []port.MenuAction{ | |
| 38 | + | port.MenuActionReload, | |
| 39 | + | port.MenuActionOpenLinkNewTab, | |
| 40 | + | port.MenuActionCopyLink, | |
| 41 | + | port.MenuActionInspectElement, | |
| 42 | + | }, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "navigation enabled states", | |
| 46 | + | context: port.MenuContext{CanGoBack: true, CanGoForward: true}, | |
| 47 | + | expected: []port.MenuAction{ | |
| 48 | + | port.MenuActionBack, | |
| 49 | + | port.MenuActionForward, | |
| 50 | + | port.MenuActionReload, | |
| 51 | + | port.MenuActionInspectElement, | |
| 52 | + | }, | |
| 53 | + | }, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | for _, tt := range tests { | |
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |
| 58 | + | items := uc.Build(context.Background(), tt.context) | |
| 59 | + | require.Equal(t, tt.expected, menuActions(items)) | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func menuActions(items []port.MenuItem) []port.MenuAction { | |
| 65 | + | actions := make([]port.MenuAction, 0, len(items)) | |
| 66 | + | for _, item := range items { | |
| 67 | + | actions = append(actions, item.Action) | |
| 68 | + | } | |
| 69 | + | return actions | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestBuildContextMenuUseCase_Build(t *testing.T) { | |||
| 12 | + | uc := NewBuildContextMenuUseCase() | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | context port.MenuContext | |||
| 17 | + | expected []port.MenuAction | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "empty context baseline", | |||
| 21 | + | context: port.MenuContext{}, | |||
| 22 | + | expected: []port.MenuAction{port.MenuActionReload, port.MenuActionInspectElement}, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "image only", | |||
| 26 | + | context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |||
| 27 | + | expected: []port.MenuAction{ | |||
| 28 | + | port.MenuActionReload, | |||
| 29 | + | port.MenuActionCopyImage, | |||
| 30 | + | port.MenuActionSaveImage, | |||
| 31 | + | port.MenuActionInspectElement, | |||
| 32 | + | }, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "link only", | |||
| 36 | + | context: port.MenuContext{LinkURI: "https://example.com/link"}, | |||
| 37 | + | expected: []port.MenuAction{ | |||
| 38 | + | port.MenuActionReload, | |||
| 39 | + | port.MenuActionOpenLinkNewTab, | |||
| 40 | + | port.MenuActionCopyLink, | |||
| 41 | + | port.MenuActionInspectElement, | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "navigation enabled states", | |||
| 46 | + | context: port.MenuContext{CanGoBack: true, CanGoForward: true}, | |||
| 47 | + | expected: []port.MenuAction{ | |||
| 48 | + | port.MenuActionBack, | |||
| 49 | + | port.MenuActionForward, | |||
| 50 | + | port.MenuActionReload, | |||
| 51 | + | port.MenuActionInspectElement, | |||
| 52 | + | }, | |||
| 53 | + | }, | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | for _, tt := range tests { | |||
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 58 | + | items := uc.Build(context.Background(), tt.context) | |||
| 59 | + | require.Equal(t, tt.expected, menuActions(items)) | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func menuActions(items []port.MenuItem) []port.MenuAction { | |||
| 65 | + | actions := make([]port.MenuAction, 0, len(items)) | |||
| 66 | + | for _, item := range items { | |||
| 67 | + | actions = append(actions, item.Action) | |||
| 68 | + | } | |||
| 69 | + | return actions | |||
| 70 | + | } | |||
A internal/application/usecase/execute_context_menu_action.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ExecuteContextMenuActionInput identifies the action and its context. | |
| 11 | + | type ExecuteContextMenuActionInput struct { | |
| 12 | + | Action port.MenuAction | |
| 13 | + | Context port.MenuContext | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // ExecuteContextMenuActionUseCase handles shared context menu actions. | |
| 17 | + | type ExecuteContextMenuActionUseCase struct { | |
| 18 | + | clipboard port.Clipboard | |
| 19 | + | resolver port.ImageDataResolver | |
| 20 | + | saver port.ResolvedImageSaver | |
| 21 | + | delegator port.MenuActionDelegator | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase. | |
| 25 | + | func NewExecuteContextMenuActionUseCase( | |
| 26 | + | clipboard port.Clipboard, | |
| 27 | + | resolver port.ImageDataResolver, | |
| 28 | + | saver port.ResolvedImageSaver, | |
| 29 | + | delegator port.MenuActionDelegator, | |
| 30 | + | ) *ExecuteContextMenuActionUseCase { | |
| 31 | + | return &ExecuteContextMenuActionUseCase{ | |
| 32 | + | clipboard: clipboard, | |
| 33 | + | resolver: resolver, | |
| 34 | + | saver: saver, | |
| 35 | + | delegator: delegator, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Execute handles the requested action. | |
| 40 | + | func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error { | |
| 41 | + | switch input.Action { | |
| 42 | + | case port.MenuActionCopyLink: | |
| 43 | + | if input.Context.LinkURI == "" { | |
| 44 | + | return fmt.Errorf("copy link: link URI not available") | |
| 45 | + | } | |
| 46 | + | if uc.clipboard == nil { | |
| 47 | + | return fmt.Errorf("copy link: clipboard not available") | |
| 48 | + | } | |
| 49 | + | if err := uc.clipboard.WriteText(ctx, input.Context.LinkURI); err != nil { | |
| 50 | + | return fmt.Errorf("copy link: %w", err) | |
| 51 | + | } | |
| 52 | + | return nil | |
| 53 | + | case port.MenuActionCopyImage: | |
| 54 | + | image, err := uc.resolveImageData(ctx, input.Context.ImageURI) | |
| 55 | + | if err != nil { | |
| 56 | + | return fmt.Errorf("copy image: %w", err) | |
| 57 | + | } | |
| 58 | + | if uc.clipboard == nil { | |
| 59 | + | return fmt.Errorf("copy image: clipboard not available") | |
| 60 | + | } | |
| 61 | + | if err := uc.clipboard.WriteImage(ctx, image); err != nil { | |
| 62 | + | return fmt.Errorf("copy image: %w", err) | |
| 63 | + | } | |
| 64 | + | return nil | |
| 65 | + | case port.MenuActionSaveImage: | |
| 66 | + | image, err := uc.resolveImageData(ctx, input.Context.ImageURI) | |
| 67 | + | if err != nil { | |
| 68 | + | return fmt.Errorf("save image: %w", err) | |
| 69 | + | } | |
| 70 | + | if uc.saver == nil { | |
| 71 | + | return fmt.Errorf("save image: image saver not available") | |
| 72 | + | } | |
| 73 | + | if err := uc.saver.SaveResolvedImage(ctx, image); err != nil { | |
| 74 | + | return fmt.Errorf("save image: %w", err) | |
| 75 | + | } | |
| 76 | + | return nil | |
| 77 | + | default: | |
| 78 | + | // All other normalized actions are delegated to the engine/UI layer. | |
| 79 | + | if uc.delegator == nil { | |
| 80 | + | return fmt.Errorf("delegate action %s: menu action delegator not available", input.Action) | |
| 81 | + | } | |
| 82 | + | if err := uc.delegator.DelegateMenuAction(ctx, input.Action, input.Context); err != nil { | |
| 83 | + | return fmt.Errorf("delegate action %s: %w", input.Action, err) | |
| 84 | + | } | |
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) { | |
| 90 | + | if uri == "" { | |
| 91 | + | return port.ImageData{}, fmt.Errorf("image URI not available") | |
| 92 | + | } | |
| 93 | + | if uc.resolver == nil { | |
| 94 | + | return port.ImageData{}, fmt.Errorf("image data resolver not available") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | image, err := uc.resolver.ResolveImageData(ctx, uri) | |
| 98 | + | if err != nil { | |
| 99 | + | return port.ImageData{}, err | |
| 100 | + | } | |
| 101 | + | if len(image.Bytes) == 0 { | |
| 102 | + | return port.ImageData{}, fmt.Errorf("image data not available") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | return image, nil | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ExecuteContextMenuActionInput identifies the action and its context. | |||
| 11 | + | type ExecuteContextMenuActionInput struct { | |||
| 12 | + | Action port.MenuAction | |||
| 13 | + | Context port.MenuContext | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // ExecuteContextMenuActionUseCase handles shared context menu actions. | |||
| 17 | + | type ExecuteContextMenuActionUseCase struct { | |||
| 18 | + | clipboard port.Clipboard | |||
| 19 | + | resolver port.ImageDataResolver | |||
| 20 | + | saver port.ResolvedImageSaver | |||
| 21 | + | delegator port.MenuActionDelegator | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewExecuteContextMenuActionUseCase creates a new ExecuteContextMenuActionUseCase. | |||
| 25 | + | func NewExecuteContextMenuActionUseCase( | |||
| 26 | + | clipboard port.Clipboard, | |||
| 27 | + | resolver port.ImageDataResolver, | |||
| 28 | + | saver port.ResolvedImageSaver, | |||
| 29 | + | delegator port.MenuActionDelegator, | |||
| 30 | + | ) *ExecuteContextMenuActionUseCase { | |||
| 31 | + | return &ExecuteContextMenuActionUseCase{ | |||
| 32 | + | clipboard: clipboard, | |||
| 33 | + | resolver: resolver, | |||
| 34 | + | saver: saver, | |||
| 35 | + | delegator: delegator, | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Execute handles the requested action. | |||
| 40 | + | func (uc *ExecuteContextMenuActionUseCase) Execute(ctx context.Context, input ExecuteContextMenuActionInput) error { | |||
| 41 | + | switch input.Action { | |||
| 42 | + | case port.MenuActionCopyLink: | |||
| 43 | + | if input.Context.LinkURI == "" { | |||
| 44 | + | return fmt.Errorf("copy link: link URI not available") | |||
| 45 | + | } | |||
| 46 | + | if uc.clipboard == nil { | |||
| 47 | + | return fmt.Errorf("copy link: clipboard not available") | |||
| 48 | + | } | |||
| 49 | + | if err := uc.clipboard.WriteText(ctx, input.Context.LinkURI); err != nil { | |||
| 50 | + | return fmt.Errorf("copy link: %w", err) | |||
| 51 | + | } | |||
| 52 | + | return nil | |||
| 53 | + | case port.MenuActionCopyImage: | |||
| 54 | + | image, err := uc.resolveImageData(ctx, input.Context.ImageURI) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return fmt.Errorf("copy image: %w", err) | |||
| 57 | + | } | |||
| 58 | + | if uc.clipboard == nil { | |||
| 59 | + | return fmt.Errorf("copy image: clipboard not available") | |||
| 60 | + | } | |||
| 61 | + | if err := uc.clipboard.WriteImage(ctx, image); err != nil { | |||
| 62 | + | return fmt.Errorf("copy image: %w", err) | |||
| 63 | + | } | |||
| 64 | + | return nil | |||
| 65 | + | case port.MenuActionSaveImage: | |||
| 66 | + | image, err := uc.resolveImageData(ctx, input.Context.ImageURI) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return fmt.Errorf("save image: %w", err) | |||
| 69 | + | } | |||
| 70 | + | if uc.saver == nil { | |||
| 71 | + | return fmt.Errorf("save image: image saver not available") | |||
| 72 | + | } | |||
| 73 | + | if err := uc.saver.SaveResolvedImage(ctx, image); err != nil { | |||
| 74 | + | return fmt.Errorf("save image: %w", err) | |||
| 75 | + | } | |||
| 76 | + | return nil | |||
| 77 | + | default: | |||
| 78 | + | // All other normalized actions are delegated to the engine/UI layer. | |||
| 79 | + | if uc.delegator == nil { | |||
| 80 | + | return fmt.Errorf("delegate action %s: menu action delegator not available", input.Action) | |||
| 81 | + | } | |||
| 82 | + | if err := uc.delegator.DelegateMenuAction(ctx, input.Action, input.Context); err != nil { | |||
| 83 | + | return fmt.Errorf("delegate action %s: %w", input.Action, err) | |||
| 84 | + | } | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (port.ImageData, error) { | |||
| 90 | + | if uri == "" { | |||
| 91 | + | return port.ImageData{}, fmt.Errorf("image URI not available") | |||
| 92 | + | } | |||
| 93 | + | if uc.resolver == nil { | |||
| 94 | + | return port.ImageData{}, fmt.Errorf("image data resolver not available") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | image, err := uc.resolver.ResolveImageData(ctx, uri) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return port.ImageData{}, err | |||
| 100 | + | } | |||
| 101 | + | if len(image.Bytes) == 0 { | |||
| 102 | + | return port.ImageData{}, fmt.Errorf("image data not available") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | return image, nil | |||
| 106 | + | } | |||
A internal/application/usecase/execute_context_menu_action_test.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type fakeClipboard struct { | |
| 13 | + | writeTextCalls int | |
| 14 | + | writeImageCalls int | |
| 15 | + | text string | |
| 16 | + | image port.ImageData | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | |
| 20 | + | f.writeTextCalls++ | |
| 21 | + | f.text = text | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |
| 26 | + | f.writeImageCalls++ | |
| 27 | + | f.image = image | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (*fakeClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 32 | + | func (*fakeClipboard) Clear(context.Context) error { return nil } | |
| 33 | + | func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 34 | + | ||
| 35 | + | type fakeImageResolver struct { | |
| 36 | + | resolveCalls int | |
| 37 | + | uri string | |
| 38 | + | image port.ImageData | |
| 39 | + | err error | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) { | |
| 43 | + | f.resolveCalls++ | |
| 44 | + | f.uri = uri | |
| 45 | + | return f.image, f.err | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type fakeResolvedImageSaver struct { | |
| 49 | + | saveCalls int | |
| 50 | + | image port.ImageData | |
| 51 | + | err error | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData) error { | |
| 55 | + | f.saveCalls++ | |
| 56 | + | f.image = image | |
| 57 | + | return f.err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | type fakeMenuActionDelegator struct { | |
| 61 | + | delegateCalls int | |
| 62 | + | action port.MenuAction | |
| 63 | + | menuContext port.MenuContext | |
| 64 | + | err error | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (f *fakeMenuActionDelegator) DelegateMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |
| 68 | + | f.delegateCalls++ | |
| 69 | + | f.action = action | |
| 70 | + | f.menuContext = menuContext | |
| 71 | + | return f.err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *testing.T) { | |
| 75 | + | clipboard := &fakeClipboard{} | |
| 76 | + | resolver := &fakeImageResolver{} | |
| 77 | + | saver := &fakeResolvedImageSaver{} | |
| 78 | + | delegator := &fakeMenuActionDelegator{} | |
| 79 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 80 | + | ||
| 81 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 82 | + | Action: port.MenuActionCopyImage, | |
| 83 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 84 | + | }) | |
| 85 | + | ||
| 86 | + | require.Error(t, err) | |
| 87 | + | require.Contains(t, err.Error(), "copy image:") | |
| 88 | + | require.Contains(t, err.Error(), "image data not available") | |
| 89 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 90 | + | require.Zero(t, saver.saveCalls) | |
| 91 | + | require.Zero(t, delegator.delegateCalls) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | |
| 95 | + | clipboard := &fakeClipboard{} | |
| 96 | + | resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}}} | |
| 97 | + | saver := &fakeResolvedImageSaver{} | |
| 98 | + | delegator := &fakeMenuActionDelegator{} | |
| 99 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 100 | + | ||
| 101 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 102 | + | Action: port.MenuActionSaveImage, | |
| 103 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |
| 104 | + | }) | |
| 105 | + | ||
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Equal(t, 1, resolver.resolveCalls) | |
| 108 | + | require.Equal(t, "https://example.com/image.png", resolver.uri) | |
| 109 | + | require.Equal(t, 1, saver.saveCalls) | |
| 110 | + | require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}}, saver.image) | |
| 111 | + | require.Zero(t, clipboard.writeTextCalls) | |
| 112 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 113 | + | require.Zero(t, delegator.delegateCalls) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) { | |
| 117 | + | clipboard := &fakeClipboard{} | |
| 118 | + | resolver := &fakeImageResolver{} | |
| 119 | + | saver := &fakeResolvedImageSaver{} | |
| 120 | + | delegator := &fakeMenuActionDelegator{} | |
| 121 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 122 | + | ||
| 123 | + | menuContext := port.MenuContext{ | |
| 124 | + | PageURI: "https://example.com", | |
| 125 | + | X: 17, | |
| 126 | + | Y: 42, | |
| 127 | + | } | |
| 128 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 129 | + | Action: port.MenuActionInspectElement, | |
| 130 | + | Context: menuContext, | |
| 131 | + | }) | |
| 132 | + | ||
| 133 | + | require.NoError(t, err) | |
| 134 | + | require.Equal(t, 1, delegator.delegateCalls) | |
| 135 | + | require.Equal(t, port.MenuActionInspectElement, delegator.action) | |
| 136 | + | require.Equal(t, menuContext, delegator.menuContext) | |
| 137 | + | require.Zero(t, clipboard.writeTextCalls) | |
| 138 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 139 | + | require.Zero(t, resolver.resolveCalls) | |
| 140 | + | require.Zero(t, saver.saveCalls) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) { | |
| 144 | + | clipboard := &fakeClipboard{} | |
| 145 | + | resolver := &fakeImageResolver{} | |
| 146 | + | saver := &fakeResolvedImageSaver{} | |
| 147 | + | delegator := &fakeMenuActionDelegator{err: errors.New("boom")} | |
| 148 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 149 | + | ||
| 150 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 151 | + | Action: port.MenuActionCopySelection, | |
| 152 | + | Context: port.MenuContext{HasSelection: true}, | |
| 153 | + | }) | |
| 154 | + | ||
| 155 | + | require.Error(t, err) | |
| 156 | + | require.Contains(t, err.Error(), "delegate action copy_selection:") | |
| 157 | + | require.Contains(t, err.Error(), "boom") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | |
| 161 | + | clipboard := &fakeClipboard{} | |
| 162 | + | resolver := &fakeImageResolver{} | |
| 163 | + | saver := &fakeResolvedImageSaver{} | |
| 164 | + | delegator := &fakeMenuActionDelegator{} | |
| 165 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 166 | + | ||
| 167 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 168 | + | Action: port.MenuActionCopyLink, | |
| 169 | + | Context: port.MenuContext{LinkURI: "https://example.com/link"}, | |
| 170 | + | }) | |
| 171 | + | ||
| 172 | + | require.NoError(t, err) | |
| 173 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |
| 174 | + | require.Equal(t, "https://example.com/link", clipboard.text) | |
| 175 | + | require.Zero(t, clipboard.writeImageCalls) | |
| 176 | + | require.Zero(t, resolver.resolveCalls) | |
| 177 | + | require.Zero(t, saver.saveCalls) | |
| 178 | + | require.Zero(t, delegator.delegateCalls) | |
| 179 | + | } | |
| @@ -0,0 +1,179 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type fakeClipboard struct { | |||
| 13 | + | writeTextCalls int | |||
| 14 | + | writeImageCalls int | |||
| 15 | + | text string | |||
| 16 | + | image port.ImageData | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (f *fakeClipboard) WriteText(_ context.Context, text string) error { | |||
| 20 | + | f.writeTextCalls++ | |||
| 21 | + | f.text = text | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (f *fakeClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |||
| 26 | + | f.writeImageCalls++ | |||
| 27 | + | f.image = image | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (*fakeClipboard) ReadText(context.Context) (string, error) { return "", nil } | |||
| 32 | + | func (*fakeClipboard) Clear(context.Context) error { return nil } | |||
| 33 | + | func (*fakeClipboard) HasText(context.Context) (bool, error) { return false, nil } | |||
| 34 | + | ||||
| 35 | + | type fakeImageResolver struct { | |||
| 36 | + | resolveCalls int | |||
| 37 | + | uri string | |||
| 38 | + | image port.ImageData | |||
| 39 | + | err error | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (f *fakeImageResolver) ResolveImageData(_ context.Context, uri string) (port.ImageData, error) { | |||
| 43 | + | f.resolveCalls++ | |||
| 44 | + | f.uri = uri | |||
| 45 | + | return f.image, f.err | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type fakeResolvedImageSaver struct { | |||
| 49 | + | saveCalls int | |||
| 50 | + | image port.ImageData | |||
| 51 | + | err error | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (f *fakeResolvedImageSaver) SaveResolvedImage(_ context.Context, image port.ImageData) error { | |||
| 55 | + | f.saveCalls++ | |||
| 56 | + | f.image = image | |||
| 57 | + | return f.err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | type fakeMenuActionDelegator struct { | |||
| 61 | + | delegateCalls int | |||
| 62 | + | action port.MenuAction | |||
| 63 | + | menuContext port.MenuContext | |||
| 64 | + | err error | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (f *fakeMenuActionDelegator) DelegateMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error { | |||
| 68 | + | f.delegateCalls++ | |||
| 69 | + | f.action = action | |||
| 70 | + | f.menuContext = menuContext | |||
| 71 | + | return f.err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestExecuteContextMenuActionUseCase_CopyImageFailsWithoutResolvedData(t *testing.T) { | |||
| 75 | + | clipboard := &fakeClipboard{} | |||
| 76 | + | resolver := &fakeImageResolver{} | |||
| 77 | + | saver := &fakeResolvedImageSaver{} | |||
| 78 | + | delegator := &fakeMenuActionDelegator{} | |||
| 79 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 80 | + | ||||
| 81 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 82 | + | Action: port.MenuActionCopyImage, | |||
| 83 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |||
| 84 | + | }) | |||
| 85 | + | ||||
| 86 | + | require.Error(t, err) | |||
| 87 | + | require.Contains(t, err.Error(), "copy image:") | |||
| 88 | + | require.Contains(t, err.Error(), "image data not available") | |||
| 89 | + | require.Zero(t, clipboard.writeImageCalls) | |||
| 90 | + | require.Zero(t, saver.saveCalls) | |||
| 91 | + | require.Zero(t, delegator.delegateCalls) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestExecuteContextMenuActionUseCase_SaveImageDelegatesResolvedImage(t *testing.T) { | |||
| 95 | + | clipboard := &fakeClipboard{} | |||
| 96 | + | resolver := &fakeImageResolver{image: port.ImageData{Bytes: []byte{1, 2, 3}}} | |||
| 97 | + | saver := &fakeResolvedImageSaver{} | |||
| 98 | + | delegator := &fakeMenuActionDelegator{} | |||
| 99 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 100 | + | ||||
| 101 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 102 | + | Action: port.MenuActionSaveImage, | |||
| 103 | + | Context: port.MenuContext{ImageURI: "https://example.com/image.png"}, | |||
| 104 | + | }) | |||
| 105 | + | ||||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.Equal(t, 1, resolver.resolveCalls) | |||
| 108 | + | require.Equal(t, "https://example.com/image.png", resolver.uri) | |||
| 109 | + | require.Equal(t, 1, saver.saveCalls) | |||
| 110 | + | require.Equal(t, port.ImageData{Bytes: []byte{1, 2, 3}}, saver.image) | |||
| 111 | + | require.Zero(t, clipboard.writeTextCalls) | |||
| 112 | + | require.Zero(t, clipboard.writeImageCalls) | |||
| 113 | + | require.Zero(t, delegator.delegateCalls) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestExecuteContextMenuActionUseCase_DelegatesInspect(t *testing.T) { | |||
| 117 | + | clipboard := &fakeClipboard{} | |||
| 118 | + | resolver := &fakeImageResolver{} | |||
| 119 | + | saver := &fakeResolvedImageSaver{} | |||
| 120 | + | delegator := &fakeMenuActionDelegator{} | |||
| 121 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 122 | + | ||||
| 123 | + | menuContext := port.MenuContext{ | |||
| 124 | + | PageURI: "https://example.com", | |||
| 125 | + | X: 17, | |||
| 126 | + | Y: 42, | |||
| 127 | + | } | |||
| 128 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 129 | + | Action: port.MenuActionInspectElement, | |||
| 130 | + | Context: menuContext, | |||
| 131 | + | }) | |||
| 132 | + | ||||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | require.Equal(t, 1, delegator.delegateCalls) | |||
| 135 | + | require.Equal(t, port.MenuActionInspectElement, delegator.action) | |||
| 136 | + | require.Equal(t, menuContext, delegator.menuContext) | |||
| 137 | + | require.Zero(t, clipboard.writeTextCalls) | |||
| 138 | + | require.Zero(t, clipboard.writeImageCalls) | |||
| 139 | + | require.Zero(t, resolver.resolveCalls) | |||
| 140 | + | require.Zero(t, saver.saveCalls) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) { | |||
| 144 | + | clipboard := &fakeClipboard{} | |||
| 145 | + | resolver := &fakeImageResolver{} | |||
| 146 | + | saver := &fakeResolvedImageSaver{} | |||
| 147 | + | delegator := &fakeMenuActionDelegator{err: errors.New("boom")} | |||
| 148 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 149 | + | ||||
| 150 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 151 | + | Action: port.MenuActionCopySelection, | |||
| 152 | + | Context: port.MenuContext{HasSelection: true}, | |||
| 153 | + | }) | |||
| 154 | + | ||||
| 155 | + | require.Error(t, err) | |||
| 156 | + | require.Contains(t, err.Error(), "delegate action copy_selection:") | |||
| 157 | + | require.Contains(t, err.Error(), "boom") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | |||
| 161 | + | clipboard := &fakeClipboard{} | |||
| 162 | + | resolver := &fakeImageResolver{} | |||
| 163 | + | saver := &fakeResolvedImageSaver{} | |||
| 164 | + | delegator := &fakeMenuActionDelegator{} | |||
| 165 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 166 | + | ||||
| 167 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 168 | + | Action: port.MenuActionCopyLink, | |||
| 169 | + | Context: port.MenuContext{LinkURI: "https://example.com/link"}, | |||
| 170 | + | }) | |||
| 171 | + | ||||
| 172 | + | require.NoError(t, err) | |||
| 173 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |||
| 174 | + | require.Equal(t, "https://example.com/link", clipboard.text) | |||
| 175 | + | require.Zero(t, clipboard.writeImageCalls) | |||
| 176 | + | require.Zero(t, resolver.resolveCalls) | |||
| 177 | + | require.Zero(t, saver.saveCalls) | |||
| 178 | + | require.Zero(t, delegator.delegateCalls) | |||
| 179 | + | } | |||
M internal/application/usecase/transform_legacy_config.go +1 -0
| @@ -15,4 +15,5 @@ func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *Transf | |||
| 15 | 15 | // Execute transforms legacy config format in place. | |
| 16 | 16 | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | |
| 17 | 17 | uc.transformer.TransformLegacyActions(rawConfig) | |
| 18 | + | uc.transformer.TransformLegacyEngineConfig(rawConfig) | |
| 18 | 19 | } | |
| @@ -15,4 +15,5 @@ func NewTransformLegacyConfigUseCase(transformer port.ConfigTransformer) *Transf | |||||
| 15 | // Execute transforms legacy config format in place. | 15 | // Execute transforms legacy config format in place. | ||
| 16 | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | 16 | func (uc *TransformLegacyConfigUseCase) Execute(rawConfig map[string]any) { | ||
| 17 | uc.transformer.TransformLegacyActions(rawConfig) | 17 | uc.transformer.TransformLegacyActions(rawConfig) | ||
| 18 | + | uc.transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 18 | } | 19 | } | ||
M internal/application/usecase/transform_legacy_config_test.go +2 -1
| @@ -13,6 +13,7 @@ func TestTransformLegacyConfigUseCase_Execute(t *testing.T) { | |||
| 13 | 13 | rawConfig := map[string]any{"test": "data"} | |
| 14 | 14 | ||
| 15 | 15 | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | |
| 16 | + | mockTransformer.EXPECT().TransformLegacyEngineConfig(mock.Anything).Return() | |
| 16 | 17 | ||
| 17 | 18 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |
| 18 | 19 | uc.Execute(rawConfig) | |
| @@ -33,8 +34,8 @@ func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) { | |||
| 33 | 34 | }, | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | - | // Verify the transformer receives the exact rawConfig | |
| 37 | 37 | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | |
| 38 | + | mockTransformer.EXPECT().TransformLegacyEngineConfig(rawConfig).Return() | |
| 38 | 39 | ||
| 39 | 40 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | |
| 40 | 41 | uc.Execute(rawConfig) | |
| @@ -13,6 +13,7 @@ func TestTransformLegacyConfigUseCase_Execute(t *testing.T) { | |||||
| 13 | rawConfig := map[string]any{"test": "data"} | 13 | rawConfig := map[string]any{"test": "data"} | ||
| 14 | 14 | ||||
| 15 | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | 15 | mockTransformer.EXPECT().TransformLegacyActions(mock.Anything).Return() | ||
| 16 | + | mockTransformer.EXPECT().TransformLegacyEngineConfig(mock.Anything).Return() | |||
| 16 | 17 | ||||
| 17 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | 18 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | ||
| 18 | uc.Execute(rawConfig) | 19 | uc.Execute(rawConfig) | ||
| @@ -33,8 +34,8 @@ func TestTransformLegacyConfigUseCase_Execute_CallsTransformer(t *testing.T) { | |||||
| 33 | }, | 34 | }, | ||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | - | // Verify the transformer receives the exact rawConfig | |||
| 37 | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | 37 | mockTransformer.EXPECT().TransformLegacyActions(rawConfig).Return() | ||
| 38 | + | mockTransformer.EXPECT().TransformLegacyEngineConfig(rawConfig).Return() | |||
| 38 | 39 | ||||
| 39 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | 40 | uc := NewTransformLegacyConfigUseCase(mockTransformer) | ||
| 40 | uc.Execute(rawConfig) | 41 | uc.Execute(rawConfig) | ||
M internal/bootstrap/engine.go +6 -7
| @@ -52,13 +52,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 52 | 52 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 53 | 53 | } | |
| 54 | 54 | cefCfg := cef.RuntimeConfig{ | |
| 55 | - | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 56 | - | LogFile: cfg.Engine.CEF.LogFile, | |
| 57 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 58 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 59 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 60 | - | EnableContextMenuHandler: cfg.Engine.CEF.EnableContextMenuHandler, | |
| 61 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 55 | + | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 56 | + | LogFile: cfg.Engine.CEF.LogFile, | |
| 57 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 58 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 59 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 60 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 62 | 61 | } | |
| 63 | 62 | transcodingCfg := cef.TranscodingRuntimeConfig{ | |
| 64 | 63 | Enabled: cfg.Transcoding.Enabled, | |
| @@ -52,13 +52,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 52 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 52 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 53 | } | 53 | } | ||
| 54 | cefCfg := cef.RuntimeConfig{ | 54 | cefCfg := cef.RuntimeConfig{ | ||
| 55 | - | CEFDir: cfg.Engine.CEF.CEFDir, | 55 | + | CEFDir: cfg.Engine.CEF.CEFDir, |
| 56 | - | LogFile: cfg.Engine.CEF.LogFile, | 56 | + | LogFile: cfg.Engine.CEF.LogFile, |
| 57 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | 57 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, |
| 58 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | 58 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, |
| 59 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | 59 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, |
| 60 | - | EnableContextMenuHandler: cfg.Engine.CEF.EnableContextMenuHandler, | 60 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, |
| 61 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |||
| 62 | } | 61 | } | ||
| 63 | transcodingCfg := cef.TranscodingRuntimeConfig{ | 62 | transcodingCfg := cef.TranscodingRuntimeConfig{ | ||
| 64 | Enabled: cfg.Transcoding.Enabled, | 63 | Enabled: cfg.Transcoding.Enabled, | ||
M internal/infrastructure/cef/context_menu.go +108 -70
| @@ -1,17 +1,14 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | - | "github.com/bnema/puregotk/v4/gdk" | |
| 6 | - | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | - | ) | |
| 6 | + | "strings" | |
| 8 | 7 | ||
| 9 | - | // menuEntry holds a snapshot of one CEF menu item. | |
| 10 | - | type menuEntry struct { | |
| 11 | - | label string | |
| 12 | - | commandID int32 | |
| 13 | - | isSep bool | |
| 14 | - | } | |
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |
| 11 | + | ) | |
| 15 | 12 | ||
| 16 | 13 | // =========================================================================== | |
| 17 | 14 | // ContextMenuHandler (implemented on handlerSet) | |
| @@ -25,98 +22,139 @@ func (h *handlerSet) RunContextMenu( | |||
| 25 | 22 | params purecef.ContextMenuParams, model purecef.MenuModel, | |
| 26 | 23 | callback purecef.RunContextMenuCallback, | |
| 27 | 24 | ) int32 { | |
| 25 | + | if h == nil || callback == nil || model == nil { | |
| 26 | + | if callback != nil { | |
| 27 | + | callback.Cancel() | |
| 28 | + | } | |
| 29 | + | return 1 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | menuContext := buildMenuContext(h.wv, params) | |
| 33 | + | items := usecase.NewBuildContextMenuUseCase().Build(context.Background(), menuContext) | |
| 34 | + | if len(items) == 0 { | |
| 35 | + | callback.Cancel() | |
| 36 | + | return 1 | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Snapshot the command IDs from CEF — model/params pointers are only valid during this call. | |
| 28 | 40 | count := model.GetCount() | |
| 29 | 41 | if count == 0 { | |
| 30 | 42 | callback.Cancel() | |
| 31 | 43 | return 1 | |
| 32 | 44 | } | |
| 33 | - | ||
| 34 | - | // Snapshot menu items — model/params pointers are only valid during this call. | |
| 35 | - | items := make([]menuEntry, 0, count) | |
| 45 | + | commandIDsByLabel := make(map[string]int32, count) | |
| 36 | 46 | for i := range count { | |
| 37 | 47 | t := model.GetTypeAt(i) | |
| 38 | 48 | if t == purecef.MenuItemTypeMenuitemtypeSeparator { | |
| 39 | - | items = append(items, menuEntry{isSep: true}) | |
| 40 | 49 | continue | |
| 41 | 50 | } | |
| 42 | 51 | if t == purecef.MenuItemTypeMenuitemtypeCommand { | |
| 43 | - | items = append(items, menuEntry{ | |
| 44 | - | label: model.GetLabelAt(i), | |
| 45 | - | commandID: model.GetCommandIDAt(i), | |
| 46 | - | }) | |
| 52 | + | label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i))) | |
| 53 | + | if label != "" { | |
| 54 | + | commandIDsByLabel[label] = model.GetCommandIDAt(i) | |
| 55 | + | } | |
| 47 | 56 | } | |
| 48 | 57 | } | |
| 49 | - | if len(items) == 0 { | |
| 58 | + | commandIDByAction := make(map[port.MenuAction]int32, len(items)) | |
| 59 | + | for _, item := range items { | |
| 60 | + | if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok { | |
| 61 | + | commandIDByAction[item.Action] = cmdID | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil { | |
| 50 | 65 | callback.Cancel() | |
| 51 | 66 | return 1 | |
| 52 | 67 | } | |
| 53 | 68 | ||
| 54 | 69 | x := params.GetXcoord() | |
| 55 | 70 | y := params.GetYcoord() | |
| 56 | - | ||
| 57 | - | h.wv.runOnGTK(func() { | |
| 58 | - | showContextMenu(h.wv, items, x, y, callback) | |
| 59 | - | }) | |
| 71 | + | glArea := h.wv.pipeline.glArea | |
| 72 | + | ||
| 73 | + | contextmenu.NewRenderer(h.wv.runOnGTK).Show( | |
| 74 | + | items, | |
| 75 | + | &glArea.Widget, | |
| 76 | + | x, | |
| 77 | + | y, | |
| 78 | + | func(item port.MenuItem) { dispatchContextMenuSelection(callback, commandIDByAction, item) }, | |
| 79 | + | func() { | |
| 80 | + | callback.Cancel() | |
| 81 | + | }, | |
| 82 | + | ) | |
| 60 | 83 | return 1 | |
| 61 | 84 | } | |
| 62 | 85 | ||
| 63 | - | func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purecef.RunContextMenuCallback) { | |
| 64 | - | glArea := wv.pipeline.glArea | |
| 65 | - | if glArea == nil { | |
| 66 | - | callback.Cancel() | |
| 86 | + | func dispatchContextMenuSelection( | |
| 87 | + | callback purecef.RunContextMenuCallback, | |
| 88 | + | commandIDByAction map[port.MenuAction]int32, | |
| 89 | + | item port.MenuItem, | |
| 90 | + | ) { | |
| 91 | + | if callback == nil { | |
| 67 | 92 | return | |
| 68 | 93 | } | |
| 94 | + | if cmdID, ok := commandIDByAction[item.Action]; ok { | |
| 95 | + | callback.Cont(cmdID, 0) | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | callback.Cancel() | |
| 99 | + | } | |
| 69 | 100 | ||
| 70 | - | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 71 | - | box.AddCssClass("context-menu") | |
| 72 | - | ||
| 73 | - | selected := false | |
| 74 | - | var popover *gtk.Popover | |
| 75 | - | ||
| 76 | - | for _, item := range items { | |
| 77 | - | if item.isSep { | |
| 78 | - | sep := gtk.NewSeparator(gtk.OrientationHorizontalValue) | |
| 79 | - | box.Append(&sep.Widget) | |
| 80 | - | continue | |
| 101 | + | func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) { | |
| 102 | + | for _, candidate := range contextMenuActionLabels(action, label) { | |
| 103 | + | if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok { | |
| 104 | + | return cmdID, true | |
| 81 | 105 | } | |
| 82 | - | ||
| 83 | - | btn := gtk.NewButton() | |
| 84 | - | btn.SetLabel(item.label) | |
| 85 | - | btn.AddCssClass("flat") | |
| 86 | - | ||
| 87 | - | cmdID := item.commandID | |
| 88 | - | // clickCb captures popover by reference; this is safe because the | |
| 89 | - | // button cannot be clicked until after Popup() is called below, | |
| 90 | - | // at which point popover is fully initialized. | |
| 91 | - | clickCb := func(_ gtk.Button) { | |
| 92 | - | selected = true | |
| 93 | - | callback.Cont(cmdID, 0) | |
| 94 | - | if popover != nil { | |
| 95 | - | popover.Popdown() | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | btn.ConnectClicked(&clickCb) | |
| 99 | - | box.Append(&btn.Widget) | |
| 100 | 106 | } | |
| 107 | + | return 0, false | |
| 108 | + | } | |
| 101 | 109 | ||
| 102 | - | popover = gtk.NewPopover() | |
| 103 | - | popover.SetChild(&box.Widget) | |
| 104 | - | popover.SetParent(&glArea.Widget) | |
| 105 | - | popover.SetHasArrow(false) | |
| 106 | - | popover.SetAutohide(true) | |
| 107 | - | ||
| 108 | - | rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1} | |
| 109 | - | popover.SetPointingTo(rect) | |
| 110 | + | func contextMenuActionLabels(action port.MenuAction, label string) []string { | |
| 111 | + | labels := []string{label} | |
| 112 | + | switch action { | |
| 113 | + | case port.MenuActionBack: | |
| 114 | + | labels = append(labels, "Back") | |
| 115 | + | case port.MenuActionForward: | |
| 116 | + | labels = append(labels, "Forward") | |
| 117 | + | case port.MenuActionReload: | |
| 118 | + | labels = append(labels, "Reload") | |
| 119 | + | case port.MenuActionOpenLinkNewTab: | |
| 120 | + | labels = append(labels, "Open Link in New Tab", "Open link in new tab") | |
| 121 | + | case port.MenuActionCopyLink: | |
| 122 | + | labels = append(labels, "Copy Link", "Copy link address") | |
| 123 | + | case port.MenuActionCopyImage: | |
| 124 | + | labels = append(labels, "Copy Image", "Copy image") | |
| 125 | + | case port.MenuActionSaveImage: | |
| 126 | + | labels = append(labels, "Save Image", "Save image as...") | |
| 127 | + | case port.MenuActionInspectElement: | |
| 128 | + | labels = append(labels, "Inspect Element", "Inspect") | |
| 129 | + | case port.MenuActionCopySelection: | |
| 130 | + | labels = append(labels, "Copy Selection", "Copy") | |
| 131 | + | } | |
| 132 | + | return labels | |
| 133 | + | } | |
| 110 | 134 | ||
| 111 | - | closedCb := func(_ gtk.Popover) { | |
| 112 | - | if !selected { | |
| 113 | - | callback.Cancel() | |
| 114 | - | } | |
| 115 | - | popover.Unparent() | |
| 135 | + | func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext { | |
| 136 | + | menuContext := port.MenuContext{} | |
| 137 | + | if wv != nil { | |
| 138 | + | menuContext.CanGoBack = wv.CanGoBack() | |
| 139 | + | menuContext.CanGoForward = wv.CanGoForward() | |
| 140 | + | menuContext.PageURI = wv.URI() | |
| 141 | + | } | |
| 142 | + | if params == nil { | |
| 143 | + | return menuContext | |
| 116 | 144 | } | |
| 117 | - | popover.ConnectClosed(&closedCb) | |
| 118 | 145 | ||
| 119 | - | popover.Popup() | |
| 146 | + | if pageURI := params.GetPageURL(); pageURI != "" { | |
| 147 | + | menuContext.PageURI = pageURI | |
| 148 | + | } | |
| 149 | + | menuContext.LinkURI = params.GetLinkURL() | |
| 150 | + | if params.HasImageContents() { | |
| 151 | + | menuContext.ImageURI = params.GetSourceURL() | |
| 152 | + | } | |
| 153 | + | menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != "" | |
| 154 | + | menuContext.IsEditable = params.IsEditable() | |
| 155 | + | menuContext.X = int(params.GetXcoord()) | |
| 156 | + | menuContext.Y = int(params.GetYcoord()) | |
| 157 | + | return menuContext | |
| 120 | 158 | } | |
| 121 | 159 | ||
| 122 | 160 | func (h *handlerSet) OnContextMenuCommand( | |
| @@ -1,17 +1,14 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | purecef "github.com/bnema/purego-cef/cef" | 5 | purecef "github.com/bnema/purego-cef/cef" | ||
| 5 | - | "github.com/bnema/puregotk/v4/gdk" | 6 | + | "strings" |
| 6 | - | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | - | ) | |||
| 8 | 7 | ||||
| 9 | - | // menuEntry holds a snapshot of one CEF menu item. | 8 | + | "github.com/bnema/dumber/internal/application/port" |
| 10 | - | type menuEntry struct { | 9 | + | "github.com/bnema/dumber/internal/application/usecase" |
| 11 | - | label string | 10 | + | "github.com/bnema/dumber/internal/infrastructure/contextmenu" |
| 12 | - | commandID int32 | 11 | + | ) |
| 13 | - | isSep bool | |||
| 14 | - | } | |||
| 15 | 12 | ||||
| 16 | // =========================================================================== | 13 | // =========================================================================== | ||
| 17 | // ContextMenuHandler (implemented on handlerSet) | 14 | // ContextMenuHandler (implemented on handlerSet) | ||
| @@ -25,98 +22,139 @@ func (h *handlerSet) RunContextMenu( | |||||
| 25 | params purecef.ContextMenuParams, model purecef.MenuModel, | 22 | params purecef.ContextMenuParams, model purecef.MenuModel, | ||
| 26 | callback purecef.RunContextMenuCallback, | 23 | callback purecef.RunContextMenuCallback, | ||
| 27 | ) int32 { | 24 | ) int32 { | ||
| 25 | + | if h == nil || callback == nil || model == nil { | |||
| 26 | + | if callback != nil { | |||
| 27 | + | callback.Cancel() | |||
| 28 | + | } | |||
| 29 | + | return 1 | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | menuContext := buildMenuContext(h.wv, params) | |||
| 33 | + | items := usecase.NewBuildContextMenuUseCase().Build(context.Background(), menuContext) | |||
| 34 | + | if len(items) == 0 { | |||
| 35 | + | callback.Cancel() | |||
| 36 | + | return 1 | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Snapshot the command IDs from CEF — model/params pointers are only valid during this call. | |||
| 28 | count := model.GetCount() | 40 | count := model.GetCount() | ||
| 29 | if count == 0 { | 41 | if count == 0 { | ||
| 30 | callback.Cancel() | 42 | callback.Cancel() | ||
| 31 | return 1 | 43 | return 1 | ||
| 32 | } | 44 | } | ||
| 33 | - | 45 | + | commandIDsByLabel := make(map[string]int32, count) | |
| 34 | - | // Snapshot menu items — model/params pointers are only valid during this call. | |||
| 35 | - | items := make([]menuEntry, 0, count) | |||
| 36 | for i := range count { | 46 | for i := range count { | ||
| 37 | t := model.GetTypeAt(i) | 47 | t := model.GetTypeAt(i) | ||
| 38 | if t == purecef.MenuItemTypeMenuitemtypeSeparator { | 48 | if t == purecef.MenuItemTypeMenuitemtypeSeparator { | ||
| 39 | - | items = append(items, menuEntry{isSep: true}) | |||
| 40 | continue | 49 | continue | ||
| 41 | } | 50 | } | ||
| 42 | if t == purecef.MenuItemTypeMenuitemtypeCommand { | 51 | if t == purecef.MenuItemTypeMenuitemtypeCommand { | ||
| 43 | - | items = append(items, menuEntry{ | 52 | + | label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i))) |
| 44 | - | label: model.GetLabelAt(i), | 53 | + | if label != "" { |
| 45 | - | commandID: model.GetCommandIDAt(i), | 54 | + | commandIDsByLabel[label] = model.GetCommandIDAt(i) |
| 46 | - | }) | 55 | + | } |
| 47 | } | 56 | } | ||
| 48 | } | 57 | } | ||
| 49 | - | if len(items) == 0 { | 58 | + | commandIDByAction := make(map[port.MenuAction]int32, len(items)) |
| 59 | + | for _, item := range items { | |||
| 60 | + | if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok { | |||
| 61 | + | commandIDByAction[item.Action] = cmdID | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil { | |||
| 50 | callback.Cancel() | 65 | callback.Cancel() | ||
| 51 | return 1 | 66 | return 1 | ||
| 52 | } | 67 | } | ||
| 53 | 68 | ||||
| 54 | x := params.GetXcoord() | 69 | x := params.GetXcoord() | ||
| 55 | y := params.GetYcoord() | 70 | y := params.GetYcoord() | ||
| 56 | - | 71 | + | glArea := h.wv.pipeline.glArea | |
| 57 | - | h.wv.runOnGTK(func() { | 72 | + | |
| 58 | - | showContextMenu(h.wv, items, x, y, callback) | 73 | + | contextmenu.NewRenderer(h.wv.runOnGTK).Show( |
| 59 | - | }) | 74 | + | items, |
| 75 | + | &glArea.Widget, | |||
| 76 | + | x, | |||
| 77 | + | y, | |||
| 78 | + | func(item port.MenuItem) { dispatchContextMenuSelection(callback, commandIDByAction, item) }, | |||
| 79 | + | func() { | |||
| 80 | + | callback.Cancel() | |||
| 81 | + | }, | |||
| 82 | + | ) | |||
| 60 | return 1 | 83 | return 1 | ||
| 61 | } | 84 | } | ||
| 62 | 85 | ||||
| 63 | - | func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purecef.RunContextMenuCallback) { | 86 | + | func dispatchContextMenuSelection( |
| 64 | - | glArea := wv.pipeline.glArea | 87 | + | callback purecef.RunContextMenuCallback, |
| 65 | - | if glArea == nil { | 88 | + | commandIDByAction map[port.MenuAction]int32, |
| 66 | - | callback.Cancel() | 89 | + | item port.MenuItem, |
| 90 | + | ) { | |||
| 91 | + | if callback == nil { | |||
| 67 | return | 92 | return | ||
| 68 | } | 93 | } | ||
| 94 | + | if cmdID, ok := commandIDByAction[item.Action]; ok { | |||
| 95 | + | callback.Cont(cmdID, 0) | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | callback.Cancel() | |||
| 99 | + | } | |||
| 69 | 100 | ||||
| 70 | - | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | 101 | + | func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) { |
| 71 | - | box.AddCssClass("context-menu") | 102 | + | for _, candidate := range contextMenuActionLabels(action, label) { |
| 72 | - | 103 | + | if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok { | |
| 73 | - | selected := false | 104 | + | return cmdID, true |
| 74 | - | var popover *gtk.Popover | |||
| 75 | - | ||||
| 76 | - | for _, item := range items { | |||
| 77 | - | if item.isSep { | |||
| 78 | - | sep := gtk.NewSeparator(gtk.OrientationHorizontalValue) | |||
| 79 | - | box.Append(&sep.Widget) | |||
| 80 | - | continue | |||
| 81 | } | 105 | } | ||
| 82 | - | ||||
| 83 | - | btn := gtk.NewButton() | |||
| 84 | - | btn.SetLabel(item.label) | |||
| 85 | - | btn.AddCssClass("flat") | |||
| 86 | - | ||||
| 87 | - | cmdID := item.commandID | |||
| 88 | - | // clickCb captures popover by reference; this is safe because the | |||
| 89 | - | // button cannot be clicked until after Popup() is called below, | |||
| 90 | - | // at which point popover is fully initialized. | |||
| 91 | - | clickCb := func(_ gtk.Button) { | |||
| 92 | - | selected = true | |||
| 93 | - | callback.Cont(cmdID, 0) | |||
| 94 | - | if popover != nil { | |||
| 95 | - | popover.Popdown() | |||
| 96 | - | } | |||
| 97 | - | } | |||
| 98 | - | btn.ConnectClicked(&clickCb) | |||
| 99 | - | box.Append(&btn.Widget) | |||
| 100 | } | 106 | } | ||
| 107 | + | return 0, false | |||
| 108 | + | } | |||
| 101 | 109 | ||||
| 102 | - | popover = gtk.NewPopover() | 110 | + | func contextMenuActionLabels(action port.MenuAction, label string) []string { |
| 103 | - | popover.SetChild(&box.Widget) | 111 | + | labels := []string{label} |
| 104 | - | popover.SetParent(&glArea.Widget) | 112 | + | switch action { |
| 105 | - | popover.SetHasArrow(false) | 113 | + | case port.MenuActionBack: |
| 106 | - | popover.SetAutohide(true) | 114 | + | labels = append(labels, "Back") |
| 107 | - | 115 | + | case port.MenuActionForward: | |
| 108 | - | rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1} | 116 | + | labels = append(labels, "Forward") |
| 109 | - | popover.SetPointingTo(rect) | 117 | + | case port.MenuActionReload: |
| 118 | + | labels = append(labels, "Reload") | |||
| 119 | + | case port.MenuActionOpenLinkNewTab: | |||
| 120 | + | labels = append(labels, "Open Link in New Tab", "Open link in new tab") | |||
| 121 | + | case port.MenuActionCopyLink: | |||
| 122 | + | labels = append(labels, "Copy Link", "Copy link address") | |||
| 123 | + | case port.MenuActionCopyImage: | |||
| 124 | + | labels = append(labels, "Copy Image", "Copy image") | |||
| 125 | + | case port.MenuActionSaveImage: | |||
| 126 | + | labels = append(labels, "Save Image", "Save image as...") | |||
| 127 | + | case port.MenuActionInspectElement: | |||
| 128 | + | labels = append(labels, "Inspect Element", "Inspect") | |||
| 129 | + | case port.MenuActionCopySelection: | |||
| 130 | + | labels = append(labels, "Copy Selection", "Copy") | |||
| 131 | + | } | |||
| 132 | + | return labels | |||
| 133 | + | } | |||
| 110 | 134 | ||||
| 111 | - | closedCb := func(_ gtk.Popover) { | 135 | + | func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext { |
| 112 | - | if !selected { | 136 | + | menuContext := port.MenuContext{} |
| 113 | - | callback.Cancel() | 137 | + | if wv != nil { |
| 114 | - | } | 138 | + | menuContext.CanGoBack = wv.CanGoBack() |
| 115 | - | popover.Unparent() | 139 | + | menuContext.CanGoForward = wv.CanGoForward() |
| 140 | + | menuContext.PageURI = wv.URI() | |||
| 141 | + | } | |||
| 142 | + | if params == nil { | |||
| 143 | + | return menuContext | |||
| 116 | } | 144 | } | ||
| 117 | - | popover.ConnectClosed(&closedCb) | |||
| 118 | 145 | ||||
| 119 | - | popover.Popup() | 146 | + | if pageURI := params.GetPageURL(); pageURI != "" { |
| 147 | + | menuContext.PageURI = pageURI | |||
| 148 | + | } | |||
| 149 | + | menuContext.LinkURI = params.GetLinkURL() | |||
| 150 | + | if params.HasImageContents() { | |||
| 151 | + | menuContext.ImageURI = params.GetSourceURL() | |||
| 152 | + | } | |||
| 153 | + | menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != "" | |||
| 154 | + | menuContext.IsEditable = params.IsEditable() | |||
| 155 | + | menuContext.X = int(params.GetXcoord()) | |||
| 156 | + | menuContext.Y = int(params.GetYcoord()) | |||
| 157 | + | return menuContext | |||
| 120 | } | 158 | } | ||
| 121 | 159 | ||||
| 122 | func (h *handlerSet) OnContextMenuCommand( | 160 | func (h *handlerSet) OnContextMenuCommand( | ||
A internal/infrastructure/cef/context_menu_adapter_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type stubContextMenuParams struct { | |
| 13 | + | x, y int32 | |
| 14 | + | pageURL string | |
| 15 | + | linkURL string | |
| 16 | + | sourceURL string | |
| 17 | + | selection string | |
| 18 | + | editable bool | |
| 19 | + | imageContent bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (p stubContextMenuParams) GetXcoord() int32 { return p.x } | |
| 23 | + | func (p stubContextMenuParams) GetYcoord() int32 { return p.y } | |
| 24 | + | func (p stubContextMenuParams) GetTypeFlags() purecef.ContextMenuTypeFlags { return 0 } | |
| 25 | + | func (p stubContextMenuParams) GetLinkURL() string { return p.linkURL } | |
| 26 | + | func (p stubContextMenuParams) GetUnfilteredLinkURL() string { return p.linkURL } | |
| 27 | + | func (p stubContextMenuParams) GetSourceURL() string { return p.sourceURL } | |
| 28 | + | func (p stubContextMenuParams) HasImageContents() bool { return p.imageContent } | |
| 29 | + | func (p stubContextMenuParams) GetTitleText() string { return "" } | |
| 30 | + | func (p stubContextMenuParams) GetPageURL() string { return p.pageURL } | |
| 31 | + | func (p stubContextMenuParams) GetFrameURL() string { return "" } | |
| 32 | + | func (p stubContextMenuParams) GetFrameCharset() string { return "" } | |
| 33 | + | func (p stubContextMenuParams) GetMediaType() purecef.ContextMenuMediaType { return 0 } | |
| 34 | + | func (p stubContextMenuParams) GetMediaStateFlags() purecef.ContextMenuMediaStateFlags { | |
| 35 | + | return 0 | |
| 36 | + | } | |
| 37 | + | func (p stubContextMenuParams) GetSelectionText() string { return p.selection } | |
| 38 | + | func (p stubContextMenuParams) GetMisspelledWord() string { return "" } | |
| 39 | + | func (p stubContextMenuParams) GetDictionarySuggestions(uintptr) int32 { return 0 } | |
| 40 | + | func (p stubContextMenuParams) IsEditable() bool { return p.editable } | |
| 41 | + | func (p stubContextMenuParams) IsSpellCheckEnabled() bool { return false } | |
| 42 | + | func (p stubContextMenuParams) GetEditStateFlags() purecef.ContextMenuEditStateFlags { | |
| 43 | + | return 0 | |
| 44 | + | } | |
| 45 | + | func (p stubContextMenuParams) IsCustomMenu() bool { return false } | |
| 46 | + | ||
| 47 | + | func TestBuildMenuContextFromCEFParams(t *testing.T) { | |
| 48 | + | wv := &WebView{} | |
| 49 | + | wv.updateURI("https://example.com/page") | |
| 50 | + | wv.updateLoadState(false, true, false) | |
| 51 | + | ||
| 52 | + | ctx := buildMenuContext(wv, stubContextMenuParams{ | |
| 53 | + | x: 17, | |
| 54 | + | y: 42, | |
| 55 | + | pageURL: "https://example.com/page", | |
| 56 | + | linkURL: "https://example.com/link", | |
| 57 | + | sourceURL: "https://example.com/image.png", | |
| 58 | + | selection: "selected text", | |
| 59 | + | editable: true, | |
| 60 | + | imageContent: true, | |
| 61 | + | }) | |
| 62 | + | ||
| 63 | + | require.Equal(t, "https://example.com/page", ctx.PageURI) | |
| 64 | + | require.Equal(t, "https://example.com/link", ctx.LinkURI) | |
| 65 | + | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | |
| 66 | + | require.True(t, ctx.HasSelection) | |
| 67 | + | require.True(t, ctx.IsEditable) | |
| 68 | + | require.True(t, ctx.CanGoBack) | |
| 69 | + | require.False(t, ctx.CanGoForward) | |
| 70 | + | require.Equal(t, 17, ctx.X) | |
| 71 | + | require.Equal(t, 42, ctx.Y) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | type stubRunContextMenuCallback struct { | |
| 75 | + | contCalls int | |
| 76 | + | cancelCalls int | |
| 77 | + | commandID int32 | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) { | |
| 81 | + | c.contCalls++ | |
| 82 | + | c.commandID = commandID | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (c *stubRunContextMenuCallback) Cancel() { | |
| 86 | + | c.cancelCalls++ | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |
| 90 | + | callback := &stubRunContextMenuCallback{} | |
| 91 | + | ||
| 92 | + | dispatchContextMenuSelection(callback, map[port.MenuAction]int32{ | |
| 93 | + | port.MenuActionReload: 102, | |
| 94 | + | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |
| 95 | + | ||
| 96 | + | require.Zero(t, callback.contCalls) | |
| 97 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type stubContextMenuParams struct { | |||
| 13 | + | x, y int32 | |||
| 14 | + | pageURL string | |||
| 15 | + | linkURL string | |||
| 16 | + | sourceURL string | |||
| 17 | + | selection string | |||
| 18 | + | editable bool | |||
| 19 | + | imageContent bool | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p stubContextMenuParams) GetXcoord() int32 { return p.x } | |||
| 23 | + | func (p stubContextMenuParams) GetYcoord() int32 { return p.y } | |||
| 24 | + | func (p stubContextMenuParams) GetTypeFlags() purecef.ContextMenuTypeFlags { return 0 } | |||
| 25 | + | func (p stubContextMenuParams) GetLinkURL() string { return p.linkURL } | |||
| 26 | + | func (p stubContextMenuParams) GetUnfilteredLinkURL() string { return p.linkURL } | |||
| 27 | + | func (p stubContextMenuParams) GetSourceURL() string { return p.sourceURL } | |||
| 28 | + | func (p stubContextMenuParams) HasImageContents() bool { return p.imageContent } | |||
| 29 | + | func (p stubContextMenuParams) GetTitleText() string { return "" } | |||
| 30 | + | func (p stubContextMenuParams) GetPageURL() string { return p.pageURL } | |||
| 31 | + | func (p stubContextMenuParams) GetFrameURL() string { return "" } | |||
| 32 | + | func (p stubContextMenuParams) GetFrameCharset() string { return "" } | |||
| 33 | + | func (p stubContextMenuParams) GetMediaType() purecef.ContextMenuMediaType { return 0 } | |||
| 34 | + | func (p stubContextMenuParams) GetMediaStateFlags() purecef.ContextMenuMediaStateFlags { | |||
| 35 | + | return 0 | |||
| 36 | + | } | |||
| 37 | + | func (p stubContextMenuParams) GetSelectionText() string { return p.selection } | |||
| 38 | + | func (p stubContextMenuParams) GetMisspelledWord() string { return "" } | |||
| 39 | + | func (p stubContextMenuParams) GetDictionarySuggestions(uintptr) int32 { return 0 } | |||
| 40 | + | func (p stubContextMenuParams) IsEditable() bool { return p.editable } | |||
| 41 | + | func (p stubContextMenuParams) IsSpellCheckEnabled() bool { return false } | |||
| 42 | + | func (p stubContextMenuParams) GetEditStateFlags() purecef.ContextMenuEditStateFlags { | |||
| 43 | + | return 0 | |||
| 44 | + | } | |||
| 45 | + | func (p stubContextMenuParams) IsCustomMenu() bool { return false } | |||
| 46 | + | ||||
| 47 | + | func TestBuildMenuContextFromCEFParams(t *testing.T) { | |||
| 48 | + | wv := &WebView{} | |||
| 49 | + | wv.updateURI("https://example.com/page") | |||
| 50 | + | wv.updateLoadState(false, true, false) | |||
| 51 | + | ||||
| 52 | + | ctx := buildMenuContext(wv, stubContextMenuParams{ | |||
| 53 | + | x: 17, | |||
| 54 | + | y: 42, | |||
| 55 | + | pageURL: "https://example.com/page", | |||
| 56 | + | linkURL: "https://example.com/link", | |||
| 57 | + | sourceURL: "https://example.com/image.png", | |||
| 58 | + | selection: "selected text", | |||
| 59 | + | editable: true, | |||
| 60 | + | imageContent: true, | |||
| 61 | + | }) | |||
| 62 | + | ||||
| 63 | + | require.Equal(t, "https://example.com/page", ctx.PageURI) | |||
| 64 | + | require.Equal(t, "https://example.com/link", ctx.LinkURI) | |||
| 65 | + | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | |||
| 66 | + | require.True(t, ctx.HasSelection) | |||
| 67 | + | require.True(t, ctx.IsEditable) | |||
| 68 | + | require.True(t, ctx.CanGoBack) | |||
| 69 | + | require.False(t, ctx.CanGoForward) | |||
| 70 | + | require.Equal(t, 17, ctx.X) | |||
| 71 | + | require.Equal(t, 42, ctx.Y) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | type stubRunContextMenuCallback struct { | |||
| 75 | + | contCalls int | |||
| 76 | + | cancelCalls int | |||
| 77 | + | commandID int32 | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) { | |||
| 81 | + | c.contCalls++ | |||
| 82 | + | c.commandID = commandID | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (c *stubRunContextMenuCallback) Cancel() { | |||
| 86 | + | c.cancelCalls++ | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||
| 90 | + | callback := &stubRunContextMenuCallback{} | |||
| 91 | + | ||||
| 92 | + | dispatchContextMenuSelection(callback, map[port.MenuAction]int32{ | |||
| 93 | + | port.MenuActionReload: 102, | |||
| 94 | + | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}) | |||
| 95 | + | ||||
| 96 | + | require.Zero(t, callback.contCalls) | |||
| 97 | + | require.Equal(t, 1, callback.cancelCalls) | |||
| 98 | + | } | |||
M internal/infrastructure/cef/engine_init.go +6 -8
| @@ -62,7 +62,6 @@ func NewEngine( | |||
| 62 | 62 | Bool("external_begin_frame", externalBeginFrameEnabled()). | |
| 63 | 63 | Bool("trace_handlers", cfg.TraceHandlers). | |
| 64 | 64 | Bool("enable_audio_handler", cfg.EnableAudioHandler). | |
| 65 | - | Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler). | |
| 66 | 65 | Msg("cef: configured engine") | |
| 67 | 66 | ||
| 68 | 67 | if err := initializeCEF(eng, settings, logger); err != nil { | |
| @@ -170,7 +169,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||
| 170 | 169 | ||
| 171 | 170 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | |
| 172 | 171 | func wireEngine( | |
| 173 | - | ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | |
| 172 | + | ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | |
| 174 | 173 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 175 | 174 | mediaClassifier MediaClassifier, | |
| 176 | 175 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| @@ -212,12 +211,11 @@ func wireEngine( | |||
| 212 | 211 | } | |
| 213 | 212 | ||
| 214 | 213 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 215 | - | scale: scale, | |
| 216 | - | windowlessFrameRate: windowlessFrameRate, | |
| 217 | - | enableContextMenuHandler: cfg.EnableContextMenuHandler, | |
| 218 | - | transcoder: mediaTranscoder, | |
| 219 | - | mediaClassifier: mediaClassifier, | |
| 220 | - | audioOutputFactory: audioFactory, | |
| 214 | + | scale: scale, | |
| 215 | + | windowlessFrameRate: windowlessFrameRate, | |
| 216 | + | transcoder: mediaTranscoder, | |
| 217 | + | mediaClassifier: mediaClassifier, | |
| 218 | + | audioOutputFactory: audioFactory, | |
| 221 | 219 | }) | |
| 222 | 220 | pool := newWebViewPool(factory) | |
| 223 | 221 | ||
| @@ -62,7 +62,6 @@ func NewEngine( | |||||
| 62 | Bool("external_begin_frame", externalBeginFrameEnabled()). | 62 | Bool("external_begin_frame", externalBeginFrameEnabled()). | ||
| 63 | Bool("trace_handlers", cfg.TraceHandlers). | 63 | Bool("trace_handlers", cfg.TraceHandlers). | ||
| 64 | Bool("enable_audio_handler", cfg.EnableAudioHandler). | 64 | Bool("enable_audio_handler", cfg.EnableAudioHandler). | ||
| 65 | - | Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler). | |||
| 66 | Msg("cef: configured engine") | 65 | Msg("cef: configured engine") | ||
| 67 | 66 | ||||
| 68 | if err := initializeCEF(eng, settings, logger); err != nil { | 67 | if err := initializeCEF(eng, settings, logger); err != nil { | ||
| @@ -170,7 +169,7 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||||
| 170 | 169 | ||||
| 171 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | 170 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | ||
| 172 | func wireEngine( | 171 | func wireEngine( | ||
| 173 | - | ctx context.Context, eng *Engine, cfg RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | 172 | + | ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, |
| 174 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | 173 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | ||
| 175 | mediaClassifier MediaClassifier, | 174 | mediaClassifier MediaClassifier, | ||
| 176 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | 175 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | ||
| @@ -212,12 +211,11 @@ func wireEngine( | |||||
| 212 | } | 211 | } | ||
| 213 | 212 | ||||
| 214 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | 213 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | ||
| 215 | - | scale: scale, | 214 | + | scale: scale, |
| 216 | - | windowlessFrameRate: windowlessFrameRate, | 215 | + | windowlessFrameRate: windowlessFrameRate, |
| 217 | - | enableContextMenuHandler: cfg.EnableContextMenuHandler, | 216 | + | transcoder: mediaTranscoder, |
| 218 | - | transcoder: mediaTranscoder, | 217 | + | mediaClassifier: mediaClassifier, |
| 219 | - | mediaClassifier: mediaClassifier, | 218 | + | audioOutputFactory: audioFactory, |
| 220 | - | audioOutputFactory: audioFactory, | |||
| 221 | }) | 219 | }) | ||
| 222 | pool := newWebViewPool(factory) | 220 | pool := newWebViewPool(factory) | ||
| 223 | 221 | ||||
M internal/infrastructure/cef/factory.go +23 -27
| @@ -17,25 +17,23 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||
| 17 | 17 | // gets a unique ID, its own renderPipeline and inputBridge, and an | |
| 18 | 18 | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | |
| 19 | 19 | type WebViewFactory struct { | |
| 20 | - | engine *Engine | |
| 21 | - | gl *glLoader | |
| 22 | - | nextID atomic.Uint64 | |
| 23 | - | scale int32 | |
| 24 | - | windowlessFrameRate int32 | |
| 25 | - | enableContextMenuHandler bool | |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 27 | - | transcoder port.MediaTranscoder | |
| 28 | - | mediaClassifier MediaClassifier | |
| 29 | - | audioOutputFactory port.AudioOutputFactory | |
| 20 | + | engine *Engine | |
| 21 | + | gl *glLoader | |
| 22 | + | nextID atomic.Uint64 | |
| 23 | + | scale int32 | |
| 24 | + | windowlessFrameRate int32 | |
| 25 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 26 | + | transcoder port.MediaTranscoder | |
| 27 | + | mediaClassifier MediaClassifier | |
| 28 | + | audioOutputFactory port.AudioOutputFactory | |
| 30 | 29 | } | |
| 31 | 30 | ||
| 32 | 31 | type webViewFactoryOptions struct { | |
| 33 | - | scale int32 | |
| 34 | - | windowlessFrameRate int32 | |
| 35 | - | enableContextMenuHandler bool | |
| 36 | - | transcoder port.MediaTranscoder | |
| 37 | - | mediaClassifier MediaClassifier | |
| 38 | - | audioOutputFactory port.AudioOutputFactory | |
| 32 | + | scale int32 | |
| 33 | + | windowlessFrameRate int32 | |
| 34 | + | transcoder port.MediaTranscoder | |
| 35 | + | mediaClassifier MediaClassifier | |
| 36 | + | audioOutputFactory port.AudioOutputFactory | |
| 39 | 37 | } | |
| 40 | 38 | ||
| 41 | 39 | type resizeNotifiableBrowserHost interface { | |
| @@ -53,14 +51,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 53 | 51 | opts.windowlessFrameRate = 60 | |
| 54 | 52 | } | |
| 55 | 53 | return &WebViewFactory{ | |
| 56 | - | engine: engine, | |
| 57 | - | gl: gl, | |
| 58 | - | scale: opts.scale, | |
| 59 | - | windowlessFrameRate: opts.windowlessFrameRate, | |
| 60 | - | enableContextMenuHandler: opts.enableContextMenuHandler, | |
| 61 | - | transcoder: opts.transcoder, | |
| 62 | - | mediaClassifier: opts.mediaClassifier, | |
| 63 | - | audioOutputFactory: opts.audioOutputFactory, | |
| 54 | + | engine: engine, | |
| 55 | + | gl: gl, | |
| 56 | + | scale: opts.scale, | |
| 57 | + | windowlessFrameRate: opts.windowlessFrameRate, | |
| 58 | + | transcoder: opts.transcoder, | |
| 59 | + | mediaClassifier: opts.mediaClassifier, | |
| 60 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 64 | 61 | } | |
| 65 | 62 | } | |
| 66 | 63 | ||
| @@ -99,9 +96,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 99 | 96 | } | |
| 100 | 97 | ||
| 101 | 98 | handlers := &handlerSet{ | |
| 102 | - | wv: wv, | |
| 103 | - | enableContextMenuHandler: f.enableContextMenuHandler, | |
| 104 | - | transcodingHandler: transcodingHandler, | |
| 99 | + | wv: wv, | |
| 100 | + | transcodingHandler: transcodingHandler, | |
| 105 | 101 | } | |
| 106 | 102 | wv.handlers = handlers | |
| 107 | 103 | wv.findCtrl = newFindController() | |
| @@ -17,25 +17,23 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||||
| 17 | // gets a unique ID, its own renderPipeline and inputBridge, and an | 17 | // gets a unique ID, its own renderPipeline and inputBridge, and an | ||
| 18 | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | 18 | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | ||
| 19 | type WebViewFactory struct { | 19 | type WebViewFactory struct { | ||
| 20 | - | engine *Engine | 20 | + | engine *Engine |
| 21 | - | gl *glLoader | 21 | + | gl *glLoader |
| 22 | - | nextID atomic.Uint64 | 22 | + | nextID atomic.Uint64 |
| 23 | - | scale int32 | 23 | + | scale int32 |
| 24 | - | windowlessFrameRate int32 | 24 | + | windowlessFrameRate int32 |
| 25 | - | enableContextMenuHandler bool | 25 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 26 | + | transcoder port.MediaTranscoder |
| 27 | - | transcoder port.MediaTranscoder | 27 | + | mediaClassifier MediaClassifier |
| 28 | - | mediaClassifier MediaClassifier | 28 | + | audioOutputFactory port.AudioOutputFactory |
| 29 | - | audioOutputFactory port.AudioOutputFactory | |||
| 30 | } | 29 | } | ||
| 31 | 30 | ||||
| 32 | type webViewFactoryOptions struct { | 31 | type webViewFactoryOptions struct { | ||
| 33 | - | scale int32 | 32 | + | scale int32 |
| 34 | - | windowlessFrameRate int32 | 33 | + | windowlessFrameRate int32 |
| 35 | - | enableContextMenuHandler bool | 34 | + | transcoder port.MediaTranscoder |
| 36 | - | transcoder port.MediaTranscoder | 35 | + | mediaClassifier MediaClassifier |
| 37 | - | mediaClassifier MediaClassifier | 36 | + | audioOutputFactory port.AudioOutputFactory |
| 38 | - | audioOutputFactory port.AudioOutputFactory | |||
| 39 | } | 37 | } | ||
| 40 | 38 | ||||
| 41 | type resizeNotifiableBrowserHost interface { | 39 | type resizeNotifiableBrowserHost interface { | ||
| @@ -53,14 +51,13 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||||
| 53 | opts.windowlessFrameRate = 60 | 51 | opts.windowlessFrameRate = 60 | ||
| 54 | } | 52 | } | ||
| 55 | return &WebViewFactory{ | 53 | return &WebViewFactory{ | ||
| 56 | - | engine: engine, | 54 | + | engine: engine, |
| 57 | - | gl: gl, | 55 | + | gl: gl, |
| 58 | - | scale: opts.scale, | 56 | + | scale: opts.scale, |
| 59 | - | windowlessFrameRate: opts.windowlessFrameRate, | 57 | + | windowlessFrameRate: opts.windowlessFrameRate, |
| 60 | - | enableContextMenuHandler: opts.enableContextMenuHandler, | 58 | + | transcoder: opts.transcoder, |
| 61 | - | transcoder: opts.transcoder, | 59 | + | mediaClassifier: opts.mediaClassifier, |
| 62 | - | mediaClassifier: opts.mediaClassifier, | 60 | + | audioOutputFactory: opts.audioOutputFactory, |
| 63 | - | audioOutputFactory: opts.audioOutputFactory, | |||
| 64 | } | 61 | } | ||
| 65 | } | 62 | } | ||
| 66 | 63 | ||||
| @@ -99,9 +96,8 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 99 | } | 96 | } | ||
| 100 | 97 | ||||
| 101 | handlers := &handlerSet{ | 98 | handlers := &handlerSet{ | ||
| 102 | - | wv: wv, | 99 | + | wv: wv, |
| 103 | - | enableContextMenuHandler: f.enableContextMenuHandler, | 100 | + | transcodingHandler: transcodingHandler, |
| 104 | - | transcodingHandler: transcodingHandler, | |||
| 105 | } | 101 | } | ||
| 106 | wv.handlers = handlers | 102 | wv.handlers = handlers | ||
| 107 | wv.findCtrl = newFindController() | 103 | wv.findCtrl = newFindController() | ||
M internal/infrastructure/cef/runtime_config.go +6 -7
| @@ -14,13 +14,12 @@ var ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | type RuntimeConfig struct { | |
| 17 | - | CEFDir string | |
| 18 | - | LogFile string | |
| 19 | - | LogSeverity int32 | |
| 20 | - | WindowlessFrameRate int32 | |
| 21 | - | EnableAudioHandler bool | |
| 22 | - | EnableContextMenuHandler bool | |
| 23 | - | TraceHandlers bool | |
| 17 | + | CEFDir string | |
| 18 | + | LogFile string | |
| 19 | + | LogSeverity int32 | |
| 20 | + | WindowlessFrameRate int32 | |
| 21 | + | EnableAudioHandler bool | |
| 22 | + | TraceHandlers bool | |
| 24 | 23 | } | |
| 25 | 24 | ||
| 26 | 25 | type TranscodingRuntimeConfig struct { | |
| @@ -14,13 +14,12 @@ var ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | type RuntimeConfig struct { | 16 | type RuntimeConfig struct { | ||
| 17 | - | CEFDir string | 17 | + | CEFDir string |
| 18 | - | LogFile string | 18 | + | LogFile string |
| 19 | - | LogSeverity int32 | 19 | + | LogSeverity int32 |
| 20 | - | WindowlessFrameRate int32 | 20 | + | WindowlessFrameRate int32 |
| 21 | - | EnableAudioHandler bool | 21 | + | EnableAudioHandler bool |
| 22 | - | EnableContextMenuHandler bool | 22 | + | TraceHandlers bool |
| 23 | - | TraceHandlers bool | |||
| 24 | } | 23 | } | ||
| 25 | 24 | ||||
| 26 | type TranscodingRuntimeConfig struct { | 25 | type TranscodingRuntimeConfig struct { | ||
M internal/infrastructure/cef/webview_handlers.go +19 -26
| @@ -21,9 +21,8 @@ const ( | |||
| 21 | 21 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 22 | 22 | // methods can return the same receiver, avoiding extra allocations. | |
| 23 | 23 | type handlerSet struct { | |
| 24 | - | wv *WebView | |
| 25 | - | enableContextMenuHandler bool | |
| 26 | - | transcodingHandler purecef.ResourceRequestHandler | |
| 24 | + | wv *WebView | |
| 25 | + | transcodingHandler purecef.ResourceRequestHandler | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | 28 | // Compile-time interface checks. | |
| @@ -50,28 +49,23 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||
| 50 | 49 | // audio backend (PipeWire/PulseAudio) is wired up. | |
| 51 | 50 | return h | |
| 52 | 51 | } | |
| 53 | - | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 54 | - | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { | |
| 55 | - | if h.enableContextMenuHandler { | |
| 56 | - | return h | |
| 57 | - | } | |
| 58 | - | return nil | |
| 59 | - | } | |
| 60 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 61 | - | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 62 | - | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil } | |
| 63 | - | func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil } | |
| 64 | - | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | |
| 65 | - | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| 66 | - | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | |
| 67 | - | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | |
| 68 | - | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | |
| 69 | - | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | |
| 70 | - | func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h } | |
| 71 | - | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |
| 72 | - | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } | |
| 73 | - | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 74 | - | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 52 | + | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 53 | + | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | |
| 54 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 55 | + | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 56 | + | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil } | |
| 57 | + | func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil } | |
| 58 | + | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | |
| 59 | + | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| 60 | + | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | |
| 61 | + | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | |
| 62 | + | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | |
| 63 | + | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | |
| 64 | + | func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h } | |
| 65 | + | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |
| 66 | + | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } | |
| 67 | + | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 68 | + | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 75 | 69 | ||
| 76 | 70 | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | |
| 77 | 71 | return 0 | |
| @@ -558,7 +552,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 558 | 552 | browserID := browser.GetIdentifier() | |
| 559 | 553 | log.Debug(). | |
| 560 | 554 | Int32("browser_id", browserID). | |
| 561 | - | Bool("context_menu_handler_enabled", h.enableContextMenuHandler). | |
| 562 | 555 | Msg("cef: OnAfterCreated") | |
| 563 | 556 | if h.wv.engine != nil { | |
| 564 | 557 | h.wv.engine.recordBrowserAfterCreated(browser) | |
| @@ -21,9 +21,8 @@ const ( | |||||
| 21 | // owning WebView. A single struct is used so that the Client's Get*Handler | 21 | // owning WebView. A single struct is used so that the Client's Get*Handler | ||
| 22 | // methods can return the same receiver, avoiding extra allocations. | 22 | // methods can return the same receiver, avoiding extra allocations. | ||
| 23 | type handlerSet struct { | 23 | type handlerSet struct { | ||
| 24 | - | wv *WebView | 24 | + | wv *WebView |
| 25 | - | enableContextMenuHandler bool | 25 | + | transcodingHandler purecef.ResourceRequestHandler |
| 26 | - | transcodingHandler purecef.ResourceRequestHandler | |||
| 27 | } | 26 | } | ||
| 28 | 27 | ||||
| 29 | // Compile-time interface checks. | 28 | // Compile-time interface checks. | ||
| @@ -50,28 +49,23 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||||
| 50 | // audio backend (PipeWire/PulseAudio) is wired up. | 49 | // audio backend (PipeWire/PulseAudio) is wired up. | ||
| 51 | return h | 50 | return h | ||
| 52 | } | 51 | } | ||
| 53 | - | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | 52 | + | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } |
| 54 | - | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { | 53 | + | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } |
| 55 | - | if h.enableContextMenuHandler { | 54 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } |
| 56 | - | return h | 55 | + | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } |
| 57 | - | } | 56 | + | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil } |
| 58 | - | return nil | 57 | + | func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil } |
| 59 | - | } | 58 | + | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } |
| 60 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | 59 | + | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } |
| 61 | - | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | 60 | + | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } |
| 62 | - | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil } | 61 | + | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } |
| 63 | - | func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil } | 62 | + | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } |
| 64 | - | func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h } | 63 | + | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } |
| 65 | - | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | 64 | + | func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h } |
| 66 | - | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | 65 | + | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } |
| 67 | - | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | 66 | + | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } |
| 68 | - | func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil } | 67 | + | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } |
| 69 | - | func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil } | 68 | + | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } |
| 70 | - | func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h } | |||
| 71 | - | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |||
| 72 | - | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } | |||
| 73 | - | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |||
| 74 | - | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |||
| 75 | 69 | ||||
| 76 | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | 70 | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | ||
| 77 | return 0 | 71 | return 0 | ||
| @@ -558,7 +552,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 558 | browserID := browser.GetIdentifier() | 552 | browserID := browser.GetIdentifier() | ||
| 559 | log.Debug(). | 553 | log.Debug(). | ||
| 560 | Int32("browser_id", browserID). | 554 | Int32("browser_id", browserID). | ||
| 561 | - | Bool("context_menu_handler_enabled", h.enableContextMenuHandler). | |||
| 562 | Msg("cef: OnAfterCreated") | 555 | Msg("cef: OnAfterCreated") | ||
| 563 | if h.wv.engine != nil { | 556 | if h.wv.engine != nil { | ||
| 564 | h.wv.engine.recordBrowserAfterCreated(browser) | 557 | h.wv.engine.recordBrowserAfterCreated(browser) | ||
M internal/infrastructure/cef/webview_handlers_test.go +1 -5
| @@ -126,15 +126,11 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |||
| 126 | 126 | require.Equal(t, int32(300), info.AvailableRect.Height) | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | func TestOptionalHandlersRespectFactoryFlags(t *testing.T) { | |
| 129 | + | func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |
| 130 | 130 | h := &handlerSet{} | |
| 131 | 131 | ||
| 132 | 132 | // AudioHandler is always enabled (required for media decoding). | |
| 133 | 133 | require.Same(t, h, h.GetAudioHandler()) | |
| 134 | - | require.Nil(t, h.GetContextMenuHandler()) | |
| 135 | - | ||
| 136 | - | h.enableContextMenuHandler = true | |
| 137 | - | ||
| 138 | 134 | require.Same(t, h, h.GetContextMenuHandler()) | |
| 139 | 135 | } | |
| 140 | 136 | ||
| @@ -126,15 +126,11 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |||||
| 126 | require.Equal(t, int32(300), info.AvailableRect.Height) | 126 | require.Equal(t, int32(300), info.AvailableRect.Height) | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | func TestOptionalHandlersRespectFactoryFlags(t *testing.T) { | 129 | + | func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { |
| 130 | h := &handlerSet{} | 130 | h := &handlerSet{} | ||
| 131 | 131 | ||||
| 132 | // AudioHandler is always enabled (required for media decoding). | 132 | // AudioHandler is always enabled (required for media decoding). | ||
| 133 | require.Same(t, h, h.GetAudioHandler()) | 133 | require.Same(t, h, h.GetAudioHandler()) | ||
| 134 | - | require.Nil(t, h.GetContextMenuHandler()) | |||
| 135 | - | ||||
| 136 | - | h.enableContextMenuHandler = true | |||
| 137 | - | ||||
| 138 | require.Same(t, h, h.GetContextMenuHandler()) | 134 | require.Same(t, h, h.GetContextMenuHandler()) | ||
| 139 | } | 135 | } | ||
| 140 | 136 | ||||
M internal/infrastructure/clipboard/clipboard.go +152 -24
| @@ -1,7 +1,8 @@ | |||
| 1 | - | // Package clipboard provides a clipboard adapter using wl-clipboard (Wayland) with X11 fallback. | |
| 1 | + | // Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback. | |
| 2 | 2 | package clipboard | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| @@ -10,48 +11,145 @@ import ( | |||
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 15 | + | "github.com/bnema/puregotk/v4/glib" | |
| 16 | + | "github.com/rs/zerolog" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| 15 | - | // Adapter implements port.Clipboard using system clipboard tools. | |
| 16 | - | // Uses wl-clipboard for Wayland, falls back to xclip for X11. | |
| 19 | + | type toolkitClipboard interface { | |
| 20 | + | WriteText(ctx context.Context, text string) error | |
| 21 | + | WriteImage(ctx context.Context, image port.ImageData) error | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Adapter implements port.Clipboard using the toolkit clipboard when available. | |
| 25 | + | // It falls back to wl-clipboard for Wayland, then xclip/xsel for X11. | |
| 17 | 26 | type Adapter struct { | |
| 27 | + | toolkit toolkitClipboard | |
| 18 | 28 | copyCmd string | |
| 19 | 29 | pasteCmd string | |
| 20 | 30 | } | |
| 21 | 31 | ||
| 32 | + | var ( | |
| 33 | + | lookPath = exec.LookPath | |
| 34 | + | commandContext = exec.CommandContext | |
| 35 | + | newToolkitClipboard = defaultToolkitClipboard | |
| 36 | + | ) | |
| 37 | + | ||
| 22 | 38 | // New creates a new clipboard adapter. | |
| 23 | 39 | // Detects Wayland vs X11 and selects appropriate clipboard tool. | |
| 24 | 40 | func New() port.Clipboard { | |
| 25 | - | a := &Adapter{} | |
| 41 | + | a := &Adapter{toolkit: newToolkitClipboard()} | |
| 42 | + | a.copyCmd, a.pasteCmd = selectSystemClipboard() | |
| 43 | + | return a | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func defaultToolkitClipboard() toolkitClipboard { | |
| 47 | + | display := gdk.DisplayGetDefault() | |
| 48 | + | if display == nil { | |
| 49 | + | return nil | |
| 50 | + | } | |
| 26 | 51 | ||
| 27 | - | // Check for Wayland first | |
| 52 | + | clipboard := display.GetClipboard() | |
| 53 | + | if clipboard == nil { | |
| 54 | + | return nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | return &gdkToolkitClipboard{clipboard: clipboard} | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | type gdkToolkitClipboard struct { | |
| 61 | + | clipboard *gdk.Clipboard | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error { | |
| 65 | + | if c == nil || c.clipboard == nil { | |
| 66 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 67 | + | } | |
| 68 | + | c.clipboard.SetText(text) | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |
| 73 | + | if c == nil || c.clipboard == nil { | |
| 74 | + | return fmt.Errorf("toolkit clipboard not available") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | |
| 78 | + | if err != nil { | |
| 79 | + | return err | |
| 80 | + | } | |
| 81 | + | if texture == nil { | |
| 82 | + | return fmt.Errorf("toolkit clipboard returned nil texture") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | c.clipboard.SetTexture(texture) | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func selectSystemClipboard() (copyCmd, pasteCmd string) { | |
| 90 | + | // Check for Wayland first. | |
| 28 | 91 | if os.Getenv("WAYLAND_DISPLAY") != "" { | |
| 29 | - | if path, err := exec.LookPath("wl-copy"); err == nil { | |
| 30 | - | a.copyCmd = path | |
| 31 | - | if pastePath, err := exec.LookPath("wl-paste"); err == nil { | |
| 32 | - | a.pasteCmd = pastePath | |
| 92 | + | if path, err := lookPath("wl-copy"); err == nil { | |
| 93 | + | copyCmd = path | |
| 94 | + | if pastePath, err := lookPath("wl-paste"); err == nil { | |
| 95 | + | pasteCmd = pastePath | |
| 33 | 96 | } | |
| 34 | 97 | } | |
| 35 | 98 | } | |
| 36 | 99 | ||
| 37 | - | // Fall back to X11 if Wayland tools not available | |
| 38 | - | if a.copyCmd == "" && os.Getenv("DISPLAY") != "" { | |
| 39 | - | if path, err := exec.LookPath("xclip"); err == nil { | |
| 40 | - | a.copyCmd = path | |
| 41 | - | a.pasteCmd = path | |
| 42 | - | } else if path, err := exec.LookPath("xsel"); err == nil { | |
| 43 | - | a.copyCmd = path | |
| 44 | - | a.pasteCmd = path | |
| 100 | + | // Fall back to X11 if Wayland tools are not available. | |
| 101 | + | if copyCmd == "" && os.Getenv("DISPLAY") != "" { | |
| 102 | + | if path, err := lookPath("xclip"); err == nil { | |
| 103 | + | copyCmd = path | |
| 104 | + | pasteCmd = path | |
| 105 | + | } else if path, err := lookPath("xsel"); err == nil { | |
| 106 | + | copyCmd = path | |
| 107 | + | pasteCmd = path | |
| 45 | 108 | } | |
| 46 | 109 | } | |
| 47 | 110 | ||
| 48 | - | return a | |
| 111 | + | return copyCmd, pasteCmd | |
| 49 | 112 | } | |
| 50 | 113 | ||
| 51 | 114 | // WriteText copies text to the clipboard. | |
| 52 | 115 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | |
| 53 | 116 | log := logging.FromContext(ctx) | |
| 117 | + | if a.toolkit != nil { | |
| 118 | + | err := a.toolkit.WriteText(ctx, text) | |
| 119 | + | if err == nil { | |
| 120 | + | log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success") | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back") | |
| 124 | + | } | |
| 54 | 125 | ||
| 126 | + | return a.writeTextWithCommand(ctx, text, log) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // WriteImage copies image bytes to the clipboard. | |
| 130 | + | // This path is PNG-only; xsel is not supported because the adapter has no | |
| 131 | + | // reliable binary image mode for it in this codebase. | |
| 132 | + | func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | |
| 133 | + | log := logging.FromContext(ctx) | |
| 134 | + | ||
| 135 | + | if len(image.Bytes) == 0 { | |
| 136 | + | err := fmt.Errorf("empty image data") | |
| 137 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | if a.toolkit != nil { | |
| 141 | + | err := a.toolkit.WriteImage(ctx, image) | |
| 142 | + | if err == nil { | |
| 143 | + | log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success") | |
| 144 | + | return nil | |
| 145 | + | } | |
| 146 | + | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | return a.writeImageWithCommand(ctx, image, log) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error { | |
| 55 | 153 | if a.copyCmd == "" { | |
| 56 | 154 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 57 | 155 | log.Error().Err(err).Msg("clipboard write failed") | |
| @@ -60,11 +158,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 60 | 158 | ||
| 61 | 159 | var cmd *exec.Cmd | |
| 62 | 160 | if strings.Contains(a.copyCmd, "wl-copy") { | |
| 63 | - | cmd = exec.CommandContext(ctx, a.copyCmd) | |
| 161 | + | cmd = commandContext(ctx, a.copyCmd) | |
| 64 | 162 | } else if strings.Contains(a.copyCmd, "xclip") { | |
| 65 | - | cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard") | |
| 163 | + | cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard") | |
| 66 | 164 | } else if strings.Contains(a.copyCmd, "xsel") { | |
| 67 | - | cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input") | |
| 165 | + | cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input") | |
| 68 | 166 | } else { | |
| 69 | 167 | err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd) | |
| 70 | 168 | log.Error().Err(err).Msg("clipboard write failed") | |
| @@ -73,6 +171,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 73 | 171 | ||
| 74 | 172 | cmd.Stdin = strings.NewReader(text) | |
| 75 | 173 | if err := cmd.Run(); err != nil { | |
| 174 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err) | |
| 76 | 175 | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed") | |
| 77 | 176 | return err | |
| 78 | 177 | } | |
| @@ -81,6 +180,35 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 81 | 180 | return nil | |
| 82 | 181 | } | |
| 83 | 182 | ||
| 183 | + | func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error { | |
| 184 | + | if a.copyCmd == "" { | |
| 185 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |
| 186 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 187 | + | return err | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | var cmd *exec.Cmd | |
| 191 | + | if strings.Contains(a.copyCmd, "wl-copy") { | |
| 192 | + | cmd = commandContext(ctx, a.copyCmd, "--type", "image/png") | |
| 193 | + | } else if strings.Contains(a.copyCmd, "xclip") { | |
| 194 | + | cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", "image/png", "-i") | |
| 195 | + | } else { | |
| 196 | + | err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd) | |
| 197 | + | log.Error().Err(err).Msg("clipboard image write failed") | |
| 198 | + | return err | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | cmd.Stdin = bytes.NewReader(image.Bytes) | |
| 202 | + | if err := cmd.Run(); err != nil { | |
| 203 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err) | |
| 204 | + | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed") | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success") | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | ||
| 84 | 212 | // ReadText reads text from the clipboard. | |
| 85 | 213 | func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |
| 86 | 214 | log := logging.FromContext(ctx) | |
| @@ -93,11 +221,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |||
| 93 | 221 | ||
| 94 | 222 | var cmd *exec.Cmd | |
| 95 | 223 | if strings.Contains(a.pasteCmd, "wl-paste") { | |
| 96 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline") | |
| 224 | + | cmd = commandContext(ctx, a.pasteCmd, "--no-newline") | |
| 97 | 225 | } else if strings.Contains(a.pasteCmd, "xclip") { | |
| 98 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o") | |
| 226 | + | cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o") | |
| 99 | 227 | } else if strings.Contains(a.pasteCmd, "xsel") { | |
| 100 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output") | |
| 228 | + | cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output") | |
| 101 | 229 | } else { | |
| 102 | 230 | err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd) | |
| 103 | 231 | log.Error().Err(err).Msg("clipboard read failed") | |
| @@ -1,7 +1,8 @@ | |||||
| 1 | - | // Package clipboard provides a clipboard adapter using wl-clipboard (Wayland) with X11 fallback. | 1 | + | // Package clipboard provides a clipboard adapter with toolkit preference and wl-clipboard/X11 fallback. |
| 2 | package clipboard | 2 | package clipboard | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "bytes" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "os" | 8 | "os" | ||
| @@ -10,48 +11,145 @@ import ( | |||||
| 10 | 11 | ||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 15 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 16 | + | "github.com/rs/zerolog" | |||
| 13 | ) | 17 | ) | ||
| 14 | 18 | ||||
| 15 | - | // Adapter implements port.Clipboard using system clipboard tools. | 19 | + | type toolkitClipboard interface { |
| 16 | - | // Uses wl-clipboard for Wayland, falls back to xclip for X11. | 20 | + | WriteText(ctx context.Context, text string) error |
| 21 | + | WriteImage(ctx context.Context, image port.ImageData) error | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Adapter implements port.Clipboard using the toolkit clipboard when available. | |||
| 25 | + | // It falls back to wl-clipboard for Wayland, then xclip/xsel for X11. | |||
| 17 | type Adapter struct { | 26 | type Adapter struct { | ||
| 27 | + | toolkit toolkitClipboard | |||
| 18 | copyCmd string | 28 | copyCmd string | ||
| 19 | pasteCmd string | 29 | pasteCmd string | ||
| 20 | } | 30 | } | ||
| 21 | 31 | ||||
| 32 | + | var ( | |||
| 33 | + | lookPath = exec.LookPath | |||
| 34 | + | commandContext = exec.CommandContext | |||
| 35 | + | newToolkitClipboard = defaultToolkitClipboard | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 22 | // New creates a new clipboard adapter. | 38 | // New creates a new clipboard adapter. | ||
| 23 | // Detects Wayland vs X11 and selects appropriate clipboard tool. | 39 | // Detects Wayland vs X11 and selects appropriate clipboard tool. | ||
| 24 | func New() port.Clipboard { | 40 | func New() port.Clipboard { | ||
| 25 | - | a := &Adapter{} | 41 | + | a := &Adapter{toolkit: newToolkitClipboard()} |
| 42 | + | a.copyCmd, a.pasteCmd = selectSystemClipboard() | |||
| 43 | + | return a | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func defaultToolkitClipboard() toolkitClipboard { | |||
| 47 | + | display := gdk.DisplayGetDefault() | |||
| 48 | + | if display == nil { | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 26 | 51 | ||||
| 27 | - | // Check for Wayland first | 52 | + | clipboard := display.GetClipboard() |
| 53 | + | if clipboard == nil { | |||
| 54 | + | return nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | return &gdkToolkitClipboard{clipboard: clipboard} | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | type gdkToolkitClipboard struct { | |||
| 61 | + | clipboard *gdk.Clipboard | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (c *gdkToolkitClipboard) WriteText(_ context.Context, text string) error { | |||
| 65 | + | if c == nil || c.clipboard == nil { | |||
| 66 | + | return fmt.Errorf("toolkit clipboard not available") | |||
| 67 | + | } | |||
| 68 | + | c.clipboard.SetText(text) | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (c *gdkToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |||
| 73 | + | if c == nil || c.clipboard == nil { | |||
| 74 | + | return fmt.Errorf("toolkit clipboard not available") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes)))) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | if texture == nil { | |||
| 82 | + | return fmt.Errorf("toolkit clipboard returned nil texture") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | c.clipboard.SetTexture(texture) | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func selectSystemClipboard() (copyCmd, pasteCmd string) { | |||
| 90 | + | // Check for Wayland first. | |||
| 28 | if os.Getenv("WAYLAND_DISPLAY") != "" { | 91 | if os.Getenv("WAYLAND_DISPLAY") != "" { | ||
| 29 | - | if path, err := exec.LookPath("wl-copy"); err == nil { | 92 | + | if path, err := lookPath("wl-copy"); err == nil { |
| 30 | - | a.copyCmd = path | 93 | + | copyCmd = path |
| 31 | - | if pastePath, err := exec.LookPath("wl-paste"); err == nil { | 94 | + | if pastePath, err := lookPath("wl-paste"); err == nil { |
| 32 | - | a.pasteCmd = pastePath | 95 | + | pasteCmd = pastePath |
| 33 | } | 96 | } | ||
| 34 | } | 97 | } | ||
| 35 | } | 98 | } | ||
| 36 | 99 | ||||
| 37 | - | // Fall back to X11 if Wayland tools not available | 100 | + | // Fall back to X11 if Wayland tools are not available. |
| 38 | - | if a.copyCmd == "" && os.Getenv("DISPLAY") != "" { | 101 | + | if copyCmd == "" && os.Getenv("DISPLAY") != "" { |
| 39 | - | if path, err := exec.LookPath("xclip"); err == nil { | 102 | + | if path, err := lookPath("xclip"); err == nil { |
| 40 | - | a.copyCmd = path | 103 | + | copyCmd = path |
| 41 | - | a.pasteCmd = path | 104 | + | pasteCmd = path |
| 42 | - | } else if path, err := exec.LookPath("xsel"); err == nil { | 105 | + | } else if path, err := lookPath("xsel"); err == nil { |
| 43 | - | a.copyCmd = path | 106 | + | copyCmd = path |
| 44 | - | a.pasteCmd = path | 107 | + | pasteCmd = path |
| 45 | } | 108 | } | ||
| 46 | } | 109 | } | ||
| 47 | 110 | ||||
| 48 | - | return a | 111 | + | return copyCmd, pasteCmd |
| 49 | } | 112 | } | ||
| 50 | 113 | ||||
| 51 | // WriteText copies text to the clipboard. | 114 | // WriteText copies text to the clipboard. | ||
| 52 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | 115 | func (a *Adapter) WriteText(ctx context.Context, text string) error { | ||
| 53 | log := logging.FromContext(ctx) | 116 | log := logging.FromContext(ctx) | ||
| 117 | + | if a.toolkit != nil { | |||
| 118 | + | err := a.toolkit.WriteText(ctx, text) | |||
| 119 | + | if err == nil { | |||
| 120 | + | log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success") | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back") | |||
| 124 | + | } | |||
| 54 | 125 | ||||
| 126 | + | return a.writeTextWithCommand(ctx, text, log) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // WriteImage copies image bytes to the clipboard. | |||
| 130 | + | // This path is PNG-only; xsel is not supported because the adapter has no | |||
| 131 | + | // reliable binary image mode for it in this codebase. | |||
| 132 | + | func (a *Adapter) WriteImage(ctx context.Context, image port.ImageData) error { | |||
| 133 | + | log := logging.FromContext(ctx) | |||
| 134 | + | ||||
| 135 | + | if len(image.Bytes) == 0 { | |||
| 136 | + | err := fmt.Errorf("empty image data") | |||
| 137 | + | log.Error().Err(err).Msg("clipboard image write failed") | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | if a.toolkit != nil { | |||
| 141 | + | err := a.toolkit.WriteImage(ctx, image) | |||
| 142 | + | if err == nil { | |||
| 143 | + | log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success") | |||
| 144 | + | return nil | |||
| 145 | + | } | |||
| 146 | + | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | return a.writeImageWithCommand(ctx, image, log) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error { | |||
| 55 | if a.copyCmd == "" { | 153 | if a.copyCmd == "" { | ||
| 56 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | 154 | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | ||
| 57 | log.Error().Err(err).Msg("clipboard write failed") | 155 | log.Error().Err(err).Msg("clipboard write failed") | ||
| @@ -60,11 +158,11 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||||
| 60 | 158 | ||||
| 61 | var cmd *exec.Cmd | 159 | var cmd *exec.Cmd | ||
| 62 | if strings.Contains(a.copyCmd, "wl-copy") { | 160 | if strings.Contains(a.copyCmd, "wl-copy") { | ||
| 63 | - | cmd = exec.CommandContext(ctx, a.copyCmd) | 161 | + | cmd = commandContext(ctx, a.copyCmd) |
| 64 | } else if strings.Contains(a.copyCmd, "xclip") { | 162 | } else if strings.Contains(a.copyCmd, "xclip") { | ||
| 65 | - | cmd = exec.CommandContext(ctx, a.copyCmd, "-selection", "clipboard") | 163 | + | cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard") |
| 66 | } else if strings.Contains(a.copyCmd, "xsel") { | 164 | } else if strings.Contains(a.copyCmd, "xsel") { | ||
| 67 | - | cmd = exec.CommandContext(ctx, a.copyCmd, "--clipboard", "--input") | 165 | + | cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input") |
| 68 | } else { | 166 | } else { | ||
| 69 | err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd) | 167 | err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd) | ||
| 70 | log.Error().Err(err).Msg("clipboard write failed") | 168 | log.Error().Err(err).Msg("clipboard write failed") | ||
| @@ -73,6 +171,7 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||||
| 73 | 171 | ||||
| 74 | cmd.Stdin = strings.NewReader(text) | 172 | cmd.Stdin = strings.NewReader(text) | ||
| 75 | if err := cmd.Run(); err != nil { | 173 | if err := cmd.Run(); err != nil { | ||
| 174 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err) | |||
| 76 | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed") | 175 | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed") | ||
| 77 | return err | 176 | return err | ||
| 78 | } | 177 | } | ||
| @@ -81,6 +180,35 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||||
| 81 | return nil | 180 | return nil | ||
| 82 | } | 181 | } | ||
| 83 | 182 | ||||
| 183 | + | func (a *Adapter) writeImageWithCommand(ctx context.Context, image port.ImageData, log *zerolog.Logger) error { | |||
| 184 | + | if a.copyCmd == "" { | |||
| 185 | + | err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)") | |||
| 186 | + | log.Error().Err(err).Msg("clipboard image write failed") | |||
| 187 | + | return err | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | var cmd *exec.Cmd | |||
| 191 | + | if strings.Contains(a.copyCmd, "wl-copy") { | |||
| 192 | + | cmd = commandContext(ctx, a.copyCmd, "--type", "image/png") | |||
| 193 | + | } else if strings.Contains(a.copyCmd, "xclip") { | |||
| 194 | + | cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", "image/png", "-i") | |||
| 195 | + | } else { | |||
| 196 | + | err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd) | |||
| 197 | + | log.Error().Err(err).Msg("clipboard image write failed") | |||
| 198 | + | return err | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | cmd.Stdin = bytes.NewReader(image.Bytes) | |||
| 202 | + | if err := cmd.Run(); err != nil { | |||
| 203 | + | err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err) | |||
| 204 | + | log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed") | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success") | |||
| 209 | + | return nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 84 | // ReadText reads text from the clipboard. | 212 | // ReadText reads text from the clipboard. | ||
| 85 | func (a *Adapter) ReadText(ctx context.Context) (string, error) { | 213 | func (a *Adapter) ReadText(ctx context.Context) (string, error) { | ||
| 86 | log := logging.FromContext(ctx) | 214 | log := logging.FromContext(ctx) | ||
| @@ -93,11 +221,11 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) { | |||||
| 93 | 221 | ||||
| 94 | var cmd *exec.Cmd | 222 | var cmd *exec.Cmd | ||
| 95 | if strings.Contains(a.pasteCmd, "wl-paste") { | 223 | if strings.Contains(a.pasteCmd, "wl-paste") { | ||
| 96 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "--no-newline") | 224 | + | cmd = commandContext(ctx, a.pasteCmd, "--no-newline") |
| 97 | } else if strings.Contains(a.pasteCmd, "xclip") { | 225 | } else if strings.Contains(a.pasteCmd, "xclip") { | ||
| 98 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o") | 226 | + | cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o") |
| 99 | } else if strings.Contains(a.pasteCmd, "xsel") { | 227 | } else if strings.Contains(a.pasteCmd, "xsel") { | ||
| 100 | - | cmd = exec.CommandContext(ctx, a.pasteCmd, "--clipboard", "--output") | 228 | + | cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output") |
| 101 | } else { | 229 | } else { | ||
| 102 | err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd) | 230 | err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd) | ||
| 103 | log.Error().Err(err).Msg("clipboard read failed") | 231 | log.Error().Err(err).Msg("clipboard read failed") | ||
A internal/infrastructure/clipboard/clipboard_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type fakeToolkitClipboard struct { | |
| 13 | + | writeTextCalls int | |
| 14 | + | writeImageCalls int | |
| 15 | + | text string | |
| 16 | + | image port.ImageData | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | |
| 20 | + | f.writeTextCalls++ | |
| 21 | + | f.text = text | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |
| 26 | + | f.writeImageCalls++ | |
| 27 | + | f.image = image | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestNew_PrefersToolkitClipboard(t *testing.T) { | |
| 32 | + | oldToolkitFactory := newToolkitClipboard | |
| 33 | + | oldLookPath := lookPath | |
| 34 | + | t.Cleanup(func() { | |
| 35 | + | newToolkitClipboard = oldToolkitFactory | |
| 36 | + | lookPath = oldLookPath | |
| 37 | + | }) | |
| 38 | + | ||
| 39 | + | toolkitChecked := false | |
| 40 | + | fake := &fakeToolkitClipboard{} | |
| 41 | + | newToolkitClipboard = func() toolkitClipboard { | |
| 42 | + | toolkitChecked = true | |
| 43 | + | return fake | |
| 44 | + | } | |
| 45 | + | lookPath = func(_ string) (string, error) { | |
| 46 | + | require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools") | |
| 47 | + | return "", errors.New("unexpected call") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |
| 51 | + | t.Setenv("DISPLAY", ":0") | |
| 52 | + | ||
| 53 | + | adapter := New().(*Adapter) | |
| 54 | + | require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}})) | |
| 55 | + | require.Equal(t, 1, fake.writeImageCalls) | |
| 56 | + | require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestNew_FallsBackWaylandBeforeX11(t *testing.T) { | |
| 60 | + | oldToolkitFactory := newToolkitClipboard | |
| 61 | + | oldLookPath := lookPath | |
| 62 | + | t.Cleanup(func() { | |
| 63 | + | newToolkitClipboard = oldToolkitFactory | |
| 64 | + | lookPath = oldLookPath | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | newToolkitClipboard = func() toolkitClipboard { return nil } | |
| 68 | + | lookups := make([]string, 0, 2) | |
| 69 | + | lookPath = func(name string) (string, error) { | |
| 70 | + | lookups = append(lookups, name) | |
| 71 | + | switch name { | |
| 72 | + | case "wl-copy": | |
| 73 | + | return "", errors.New("wl-copy missing") | |
| 74 | + | case "xclip": | |
| 75 | + | return "/usr/bin/xclip", nil | |
| 76 | + | default: | |
| 77 | + | return "", errors.New("unexpected lookPath call") | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |
| 82 | + | t.Setenv("DISPLAY", ":0") | |
| 83 | + | ||
| 84 | + | adapter := New().(*Adapter) | |
| 85 | + | require.Equal(t, "/usr/bin/xclip", adapter.copyCmd) | |
| 86 | + | require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd) | |
| 87 | + | require.Equal(t, []string{"wl-copy", "xclip"}, lookups) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |
| 91 | + | oldToolkitFactory := newToolkitClipboard | |
| 92 | + | oldLookPath := lookPath | |
| 93 | + | t.Cleanup(func() { | |
| 94 | + | newToolkitClipboard = oldToolkitFactory | |
| 95 | + | lookPath = oldLookPath | |
| 96 | + | }) | |
| 97 | + | ||
| 98 | + | fake := &fakeToolkitClipboard{} | |
| 99 | + | newToolkitClipboard = func() toolkitClipboard { return fake } | |
| 100 | + | lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil } | |
| 101 | + | ||
| 102 | + | err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{}) | |
| 103 | + | require.Error(t, err) | |
| 104 | + | require.Contains(t, err.Error(), "empty image data") | |
| 105 | + | require.Zero(t, fake.writeImageCalls) | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type fakeToolkitClipboard struct { | |||
| 13 | + | writeTextCalls int | |||
| 14 | + | writeImageCalls int | |||
| 15 | + | text string | |||
| 16 | + | image port.ImageData | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error { | |||
| 20 | + | f.writeTextCalls++ | |||
| 21 | + | f.text = text | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image port.ImageData) error { | |||
| 26 | + | f.writeImageCalls++ | |||
| 27 | + | f.image = image | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestNew_PrefersToolkitClipboard(t *testing.T) { | |||
| 32 | + | oldToolkitFactory := newToolkitClipboard | |||
| 33 | + | oldLookPath := lookPath | |||
| 34 | + | t.Cleanup(func() { | |||
| 35 | + | newToolkitClipboard = oldToolkitFactory | |||
| 36 | + | lookPath = oldLookPath | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 39 | + | toolkitChecked := false | |||
| 40 | + | fake := &fakeToolkitClipboard{} | |||
| 41 | + | newToolkitClipboard = func() toolkitClipboard { | |||
| 42 | + | toolkitChecked = true | |||
| 43 | + | return fake | |||
| 44 | + | } | |||
| 45 | + | lookPath = func(_ string) (string, error) { | |||
| 46 | + | require.True(t, toolkitChecked, "toolkit clipboard should be checked before system tools") | |||
| 47 | + | return "", errors.New("unexpected call") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |||
| 51 | + | t.Setenv("DISPLAY", ":0") | |||
| 52 | + | ||||
| 53 | + | adapter := New().(*Adapter) | |||
| 54 | + | require.NoError(t, adapter.WriteImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3}})) | |||
| 55 | + | require.Equal(t, 1, fake.writeImageCalls) | |||
| 56 | + | require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestNew_FallsBackWaylandBeforeX11(t *testing.T) { | |||
| 60 | + | oldToolkitFactory := newToolkitClipboard | |||
| 61 | + | oldLookPath := lookPath | |||
| 62 | + | t.Cleanup(func() { | |||
| 63 | + | newToolkitClipboard = oldToolkitFactory | |||
| 64 | + | lookPath = oldLookPath | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | newToolkitClipboard = func() toolkitClipboard { return nil } | |||
| 68 | + | lookups := make([]string, 0, 2) | |||
| 69 | + | lookPath = func(name string) (string, error) { | |||
| 70 | + | lookups = append(lookups, name) | |||
| 71 | + | switch name { | |||
| 72 | + | case "wl-copy": | |||
| 73 | + | return "", errors.New("wl-copy missing") | |||
| 74 | + | case "xclip": | |||
| 75 | + | return "/usr/bin/xclip", nil | |||
| 76 | + | default: | |||
| 77 | + | return "", errors.New("unexpected lookPath call") | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-0") | |||
| 82 | + | t.Setenv("DISPLAY", ":0") | |||
| 83 | + | ||||
| 84 | + | adapter := New().(*Adapter) | |||
| 85 | + | require.Equal(t, "/usr/bin/xclip", adapter.copyCmd) | |||
| 86 | + | require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd) | |||
| 87 | + | require.Equal(t, []string{"wl-copy", "xclip"}, lookups) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) { | |||
| 91 | + | oldToolkitFactory := newToolkitClipboard | |||
| 92 | + | oldLookPath := lookPath | |||
| 93 | + | t.Cleanup(func() { | |||
| 94 | + | newToolkitClipboard = oldToolkitFactory | |||
| 95 | + | lookPath = oldLookPath | |||
| 96 | + | }) | |||
| 97 | + | ||||
| 98 | + | fake := &fakeToolkitClipboard{} | |||
| 99 | + | newToolkitClipboard = func() toolkitClipboard { return fake } | |||
| 100 | + | lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil } | |||
| 101 | + | ||||
| 102 | + | err := New().(*Adapter).WriteImage(context.Background(), port.ImageData{}) | |||
| 103 | + | require.Error(t, err) | |||
| 104 | + | require.Contains(t, err.Error(), "empty image data") | |||
| 105 | + | require.Zero(t, fake.writeImageCalls) | |||
| 106 | + | } | |||
M internal/infrastructure/config/defaults_test.go +0 -1
| @@ -23,7 +23,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 23 | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | |
| 24 | 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | |
| 25 | 25 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 26 | - | assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler) | |
| 27 | 26 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | |
| 28 | 27 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | |
| 29 | 28 | ||
| @@ -23,7 +23,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | ||
| 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | 24 | assert.Empty(t, cfg.Engine.CEF.LogFile) | ||
| 25 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | 25 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | ||
| 26 | - | assert.False(t, cfg.Engine.CEF.EnableContextMenuHandler) | |||
| 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | 26 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | ||
| 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | 27 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | ||
| 29 | 28 | ||||
M internal/infrastructure/config/engine.go +0 -2
| @@ -95,8 +95,6 @@ type CEFEngineConfig struct { | |||
| 95 | 95 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | |
| 96 | 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 97 | 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 98 | - | // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge. | |
| 99 | - | EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit | |
| 100 | 98 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | |
| 101 | 99 | TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"` | |
| 102 | 100 | } | |
| @@ -95,8 +95,6 @@ type CEFEngineConfig struct { | |||||
| 95 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | 95 | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | ||
| 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | 96 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | ||
| 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | 97 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | ||
| 98 | - | // EnableContextMenuHandler opts into the experimental CEF ContextMenuHandler bridge. | |||
| 99 | - | EnableContextMenuHandler bool `mapstructure:"enable_context_menu_handler" toml:"enable_context_menu_handler" yaml:"enable_context_menu_handler"` //nolint:lll // struct tags exceed lll limit | |||
| 100 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | 98 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | ||
| 101 | TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"` | 99 | TraceHandlers bool `mapstructure:"trace_handlers" toml:"trace_handlers" yaml:"trace_handlers"` | ||
| 102 | } | 100 | } | ||
M internal/infrastructure/config/loader.go +1 -1
| @@ -148,6 +148,7 @@ func (m *Manager) transformLegacyActionBindings() { | |||
| 148 | 148 | rawConfig := m.viper.AllSettings() | |
| 149 | 149 | transformer := NewLegacyConfigTransformer() | |
| 150 | 150 | transformer.TransformLegacyActions(rawConfig) | |
| 151 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 151 | 152 | ||
| 152 | 153 | // Apply transformed config back to viper | |
| 153 | 154 | for key, value := range rawConfig { | |
| @@ -576,7 +577,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 576 | 577 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | |
| 577 | 578 | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | |
| 578 | 579 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | |
| 579 | - | m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler) | |
| 580 | 580 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | |
| 581 | 581 | ||
| 582 | 582 | wk := e.WebKit | |
| @@ -148,6 +148,7 @@ func (m *Manager) transformLegacyActionBindings() { | |||||
| 148 | rawConfig := m.viper.AllSettings() | 148 | rawConfig := m.viper.AllSettings() | ||
| 149 | transformer := NewLegacyConfigTransformer() | 149 | transformer := NewLegacyConfigTransformer() | ||
| 150 | transformer.TransformLegacyActions(rawConfig) | 150 | transformer.TransformLegacyActions(rawConfig) | ||
| 151 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 151 | 152 | ||||
| 152 | // Apply transformed config back to viper | 153 | // Apply transformed config back to viper | ||
| 153 | for key, value := range rawConfig { | 154 | for key, value := range rawConfig { | ||
| @@ -576,7 +577,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 576 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | 577 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | ||
| 577 | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | 578 | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | ||
| 578 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | 579 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | ||
| 579 | - | m.viper.SetDefault("engine.cef.enable_context_menu_handler", ce.EnableContextMenuHandler) | |||
| 580 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | 580 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | ||
| 581 | 581 | ||||
| 582 | wk := e.WebKit | 582 | wk := e.WebKit | ||
M internal/infrastructure/config/migrate.go +2 -0
| @@ -380,6 +380,7 @@ func (m *Migrator) Migrate() ([]string, error) { | |||
| 380 | 380 | // Transform legacy action bindings (slice -> ActionBinding struct) | |
| 381 | 381 | transformer := NewLegacyConfigTransformer() | |
| 382 | 382 | transformer.TransformLegacyActions(rawConfig) | |
| 383 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 383 | 384 | ||
| 384 | 385 | // Build sets of keys to remove and renames to apply | |
| 385 | 386 | keysToRemove := make(map[string]bool) | |
| @@ -670,6 +671,7 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||
| 670 | 671 | // Transform legacy action bindings before flattening | |
| 671 | 672 | transformer := NewLegacyConfigTransformer() | |
| 672 | 673 | transformer.TransformLegacyActions(rawConfig) | |
| 674 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 673 | 675 | ||
| 674 | 676 | // Flatten the map to dot-notation keys with values | |
| 675 | 677 | result := make(map[string]any) | |
| @@ -380,6 +380,7 @@ func (m *Migrator) Migrate() ([]string, error) { | |||||
| 380 | // Transform legacy action bindings (slice -> ActionBinding struct) | 380 | // Transform legacy action bindings (slice -> ActionBinding struct) | ||
| 381 | transformer := NewLegacyConfigTransformer() | 381 | transformer := NewLegacyConfigTransformer() | ||
| 382 | transformer.TransformLegacyActions(rawConfig) | 382 | transformer.TransformLegacyActions(rawConfig) | ||
| 383 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 383 | 384 | ||||
| 384 | // Build sets of keys to remove and renames to apply | 385 | // Build sets of keys to remove and renames to apply | ||
| 385 | keysToRemove := make(map[string]bool) | 386 | keysToRemove := make(map[string]bool) | ||
| @@ -670,6 +671,7 @@ func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]an | |||||
| 670 | // Transform legacy action bindings before flattening | 671 | // Transform legacy action bindings before flattening | ||
| 671 | transformer := NewLegacyConfigTransformer() | 672 | transformer := NewLegacyConfigTransformer() | ||
| 672 | transformer.TransformLegacyActions(rawConfig) | 673 | transformer.TransformLegacyActions(rawConfig) | ||
| 674 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 673 | 675 | ||||
| 674 | // Flatten the map to dot-notation keys with values | 676 | // Flatten the map to dot-notation keys with values | ||
| 675 | result := make(map[string]any) | 677 | result := make(map[string]any) | ||
M internal/infrastructure/config/schema_provider.go +0 -7
| @@ -887,13 +887,6 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 887 | 887 | Description: "Enable the experimental CEF audio handler bridge", | |
| 888 | 888 | Section: SectionDebug, | |
| 889 | 889 | }, | |
| 890 | - | { | |
| 891 | - | Key: "engine.cef.enable_context_menu_handler", | |
| 892 | - | Type: "bool", | |
| 893 | - | Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler), | |
| 894 | - | Description: "Enable the experimental CEF context menu handler bridge", | |
| 895 | - | Section: SectionDebug, | |
| 896 | - | }, | |
| 897 | 890 | } | |
| 898 | 891 | } | |
| 899 | 892 | ||
| @@ -887,13 +887,6 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||||
| 887 | Description: "Enable the experimental CEF audio handler bridge", | 887 | Description: "Enable the experimental CEF audio handler bridge", | ||
| 888 | Section: SectionDebug, | 888 | Section: SectionDebug, | ||
| 889 | }, | 889 | }, | ||
| 890 | - | { | |||
| 891 | - | Key: "engine.cef.enable_context_menu_handler", | |||
| 892 | - | Type: "bool", | |||
| 893 | - | Default: fmt.Sprintf("%t", defaults.Engine.CEF.EnableContextMenuHandler), | |||
| 894 | - | Description: "Enable the experimental CEF context menu handler bridge", | |||
| 895 | - | Section: SectionDebug, | |||
| 896 | - | }, | |||
| 897 | } | 890 | } | ||
| 898 | } | 891 | } | ||
| 899 | 892 | ||||
M internal/infrastructure/config/transform_legacy.go +15 -0
| @@ -41,6 +41,21 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an | |||
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | // TransformLegacyEngineConfig removes deprecated engine config keys. | |
| 45 | + | func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 46 | + | engine, ok := rawConfig["engine"].(map[string]any) | |
| 47 | + | if !ok { | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | cef, ok := engine["cef"].(map[string]any) | |
| 52 | + | if !ok { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | delete(cef, "enable_context_menu_handler") | |
| 57 | + | } | |
| 58 | + | ||
| 44 | 59 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |
| 45 | 60 | // Navigate to the parent of the actions section | |
| 46 | 61 | current := rawConfig | |
| @@ -41,6 +41,21 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an | |||||
| 41 | } | 41 | } | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | + | // TransformLegacyEngineConfig removes deprecated engine config keys. | |||
| 45 | + | func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |||
| 46 | + | engine, ok := rawConfig["engine"].(map[string]any) | |||
| 47 | + | if !ok { | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | cef, ok := engine["cef"].(map[string]any) | |||
| 52 | + | if !ok { | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | delete(cef, "enable_context_menu_handler") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 44 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | 59 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | ||
| 45 | // Navigate to the parent of the actions section | 60 | // Navigate to the parent of the actions section | ||
| 46 | current := rawConfig | 61 | current := rawConfig | ||
M internal/infrastructure/config/transform_legacy_test.go +18 -0
| @@ -170,6 +170,24 @@ func TestTransformLegacyActions_PartialPath(t *testing.T) { | |||
| 170 | 170 | require.NotNil(t, rawConfig["workspace"]) | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | + | func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *testing.T) { | |
| 174 | + | transformer := NewLegacyConfigTransformer() | |
| 175 | + | ||
| 176 | + | rawConfig := map[string]any{ | |
| 177 | + | "engine": map[string]any{ | |
| 178 | + | "cef": map[string]any{ | |
| 179 | + | "enable_context_menu_handler": true, | |
| 180 | + | }, | |
| 181 | + | }, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 185 | + | ||
| 186 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 187 | + | _, exists := cef["enable_context_menu_handler"] | |
| 188 | + | assert.False(t, exists) | |
| 189 | + | } | |
| 190 | + | ||
| 173 | 191 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |
| 174 | 192 | transformer := NewLegacyConfigTransformer() | |
| 175 | 193 | ||
| @@ -170,6 +170,24 @@ func TestTransformLegacyActions_PartialPath(t *testing.T) { | |||||
| 170 | require.NotNil(t, rawConfig["workspace"]) | 170 | require.NotNil(t, rawConfig["workspace"]) | ||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | + | func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *testing.T) { | |||
| 174 | + | transformer := NewLegacyConfigTransformer() | |||
| 175 | + | ||||
| 176 | + | rawConfig := map[string]any{ | |||
| 177 | + | "engine": map[string]any{ | |||
| 178 | + | "cef": map[string]any{ | |||
| 179 | + | "enable_context_menu_handler": true, | |||
| 180 | + | }, | |||
| 181 | + | }, | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 185 | + | ||||
| 186 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |||
| 187 | + | _, exists := cef["enable_context_menu_handler"] | |||
| 188 | + | assert.False(t, exists) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 173 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | 191 | func TestTransformLegacyActions_UnknownAction(t *testing.T) { | ||
| 174 | transformer := NewLegacyConfigTransformer() | 192 | transformer := NewLegacyConfigTransformer() | ||
| 175 | 193 | ||||
A internal/infrastructure/contextmenu/renderer.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package contextmenu | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/dumber/internal/application/port" | |
| 5 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type renderItem struct { | |
| 10 | + | item port.MenuItem | |
| 11 | + | separator bool | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // Renderer shows GTK context menus on the provided anchor widget. | |
| 15 | + | type Renderer struct { | |
| 16 | + | dispatch func(func()) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewRenderer creates a new Renderer. | |
| 20 | + | func NewRenderer(dispatch func(func())) *Renderer { | |
| 21 | + | return &Renderer{dispatch: dispatch} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func buildButtons(items []port.MenuItem) []renderItem { | |
| 25 | + | renderItems := make([]renderItem, 0, len(items)) | |
| 26 | + | for _, item := range items { | |
| 27 | + | if isSeparator(item) { | |
| 28 | + | renderItems = append(renderItems, renderItem{separator: true}) | |
| 29 | + | continue | |
| 30 | + | } | |
| 31 | + | renderItems = append(renderItems, renderItem{item: item}) | |
| 32 | + | } | |
| 33 | + | return renderItems | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func isSeparator(item port.MenuItem) bool { | |
| 37 | + | return item.Action == "" && item.Label == "" | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Show renders a popover menu on the GTK thread. | |
| 41 | + | func (r *Renderer) Show( | |
| 42 | + | items []port.MenuItem, | |
| 43 | + | anchor *gtk.Widget, | |
| 44 | + | x, y int32, | |
| 45 | + | onSelect func(port.MenuItem), | |
| 46 | + | onClose func(), | |
| 47 | + | ) { | |
| 48 | + | if r == nil { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | run := func() { | |
| 52 | + | showMenu(anchor, buildButtons(items), x, y, onSelect, onClose) | |
| 53 | + | } | |
| 54 | + | if r.dispatch == nil { | |
| 55 | + | run() | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | r.dispatch(run) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func showMenu( | |
| 62 | + | anchor *gtk.Widget, | |
| 63 | + | items []renderItem, | |
| 64 | + | x, y int32, | |
| 65 | + | onSelect func(port.MenuItem), | |
| 66 | + | onClose func(), | |
| 67 | + | ) { | |
| 68 | + | if anchor == nil { | |
| 69 | + | if onClose != nil { | |
| 70 | + | onClose() | |
| 71 | + | } | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 76 | + | box.AddCssClass("context-menu") | |
| 77 | + | ||
| 78 | + | selected := false | |
| 79 | + | var popover *gtk.Popover | |
| 80 | + | ||
| 81 | + | for _, item := range items { | |
| 82 | + | if item.separator { | |
| 83 | + | sep := gtk.NewSeparator(gtk.OrientationHorizontalValue) | |
| 84 | + | box.Append(&sep.Widget) | |
| 85 | + | continue | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | btn := gtk.NewButton() | |
| 89 | + | btn.SetLabel(item.item.Label) | |
| 90 | + | btn.AddCssClass("flat") | |
| 91 | + | ||
| 92 | + | menuItem := item.item | |
| 93 | + | clickCb := func(_ gtk.Button) { | |
| 94 | + | selected = true | |
| 95 | + | if onSelect != nil { | |
| 96 | + | onSelect(menuItem) | |
| 97 | + | } | |
| 98 | + | if popover != nil { | |
| 99 | + | popover.Popdown() | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | btn.ConnectClicked(&clickCb) | |
| 103 | + | box.Append(&btn.Widget) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if len(items) == 0 { | |
| 107 | + | if onClose != nil { | |
| 108 | + | onClose() | |
| 109 | + | } | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | popover = gtk.NewPopover() | |
| 114 | + | popover.SetChild(&box.Widget) | |
| 115 | + | popover.SetParent(anchor) | |
| 116 | + | popover.SetHasArrow(false) | |
| 117 | + | popover.SetAutohide(true) | |
| 118 | + | ||
| 119 | + | rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1} | |
| 120 | + | popover.SetPointingTo(rect) | |
| 121 | + | ||
| 122 | + | closedCb := func(_ gtk.Popover) { | |
| 123 | + | if !selected && onClose != nil { | |
| 124 | + | onClose() | |
| 125 | + | } | |
| 126 | + | popover.Unparent() | |
| 127 | + | } | |
| 128 | + | popover.ConnectClosed(&closedCb) | |
| 129 | + | ||
| 130 | + | popover.Popup() | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package contextmenu | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 5 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type renderItem struct { | |||
| 10 | + | item port.MenuItem | |||
| 11 | + | separator bool | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // Renderer shows GTK context menus on the provided anchor widget. | |||
| 15 | + | type Renderer struct { | |||
| 16 | + | dispatch func(func()) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewRenderer creates a new Renderer. | |||
| 20 | + | func NewRenderer(dispatch func(func())) *Renderer { | |||
| 21 | + | return &Renderer{dispatch: dispatch} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func buildButtons(items []port.MenuItem) []renderItem { | |||
| 25 | + | renderItems := make([]renderItem, 0, len(items)) | |||
| 26 | + | for _, item := range items { | |||
| 27 | + | if isSeparator(item) { | |||
| 28 | + | renderItems = append(renderItems, renderItem{separator: true}) | |||
| 29 | + | continue | |||
| 30 | + | } | |||
| 31 | + | renderItems = append(renderItems, renderItem{item: item}) | |||
| 32 | + | } | |||
| 33 | + | return renderItems | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func isSeparator(item port.MenuItem) bool { | |||
| 37 | + | return item.Action == "" && item.Label == "" | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Show renders a popover menu on the GTK thread. | |||
| 41 | + | func (r *Renderer) Show( | |||
| 42 | + | items []port.MenuItem, | |||
| 43 | + | anchor *gtk.Widget, | |||
| 44 | + | x, y int32, | |||
| 45 | + | onSelect func(port.MenuItem), | |||
| 46 | + | onClose func(), | |||
| 47 | + | ) { | |||
| 48 | + | if r == nil { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | run := func() { | |||
| 52 | + | showMenu(anchor, buildButtons(items), x, y, onSelect, onClose) | |||
| 53 | + | } | |||
| 54 | + | if r.dispatch == nil { | |||
| 55 | + | run() | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | r.dispatch(run) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func showMenu( | |||
| 62 | + | anchor *gtk.Widget, | |||
| 63 | + | items []renderItem, | |||
| 64 | + | x, y int32, | |||
| 65 | + | onSelect func(port.MenuItem), | |||
| 66 | + | onClose func(), | |||
| 67 | + | ) { | |||
| 68 | + | if anchor == nil { | |||
| 69 | + | if onClose != nil { | |||
| 70 | + | onClose() | |||
| 71 | + | } | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 76 | + | box.AddCssClass("context-menu") | |||
| 77 | + | ||||
| 78 | + | selected := false | |||
| 79 | + | var popover *gtk.Popover | |||
| 80 | + | ||||
| 81 | + | for _, item := range items { | |||
| 82 | + | if item.separator { | |||
| 83 | + | sep := gtk.NewSeparator(gtk.OrientationHorizontalValue) | |||
| 84 | + | box.Append(&sep.Widget) | |||
| 85 | + | continue | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | btn := gtk.NewButton() | |||
| 89 | + | btn.SetLabel(item.item.Label) | |||
| 90 | + | btn.AddCssClass("flat") | |||
| 91 | + | ||||
| 92 | + | menuItem := item.item | |||
| 93 | + | clickCb := func(_ gtk.Button) { | |||
| 94 | + | selected = true | |||
| 95 | + | if onSelect != nil { | |||
| 96 | + | onSelect(menuItem) | |||
| 97 | + | } | |||
| 98 | + | if popover != nil { | |||
| 99 | + | popover.Popdown() | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | btn.ConnectClicked(&clickCb) | |||
| 103 | + | box.Append(&btn.Widget) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if len(items) == 0 { | |||
| 107 | + | if onClose != nil { | |||
| 108 | + | onClose() | |||
| 109 | + | } | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | popover = gtk.NewPopover() | |||
| 114 | + | popover.SetChild(&box.Widget) | |||
| 115 | + | popover.SetParent(anchor) | |||
| 116 | + | popover.SetHasArrow(false) | |||
| 117 | + | popover.SetAutohide(true) | |||
| 118 | + | ||||
| 119 | + | rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1} | |||
| 120 | + | popover.SetPointingTo(rect) | |||
| 121 | + | ||||
| 122 | + | closedCb := func(_ gtk.Popover) { | |||
| 123 | + | if !selected && onClose != nil { | |||
| 124 | + | onClose() | |||
| 125 | + | } | |||
| 126 | + | popover.Unparent() | |||
| 127 | + | } | |||
| 128 | + | popover.ConnectClosed(&closedCb) | |||
| 129 | + | ||||
| 130 | + | popover.Popup() | |||
| 131 | + | } | |||
A internal/infrastructure/contextmenu/renderer_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package contextmenu | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestBuildButtons(t *testing.T) { | |
| 11 | + | items := []port.MenuItem{ | |
| 12 | + | {Action: port.MenuActionBack, Label: "Back"}, | |
| 13 | + | {}, | |
| 14 | + | {Action: port.MenuActionReload, Label: "Reload"}, | |
| 15 | + | {Action: port.MenuActionInspectElement, Label: "Inspect Element"}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | renderItems := buildButtons(items) | |
| 19 | + | require.Len(t, renderItems, 4) | |
| 20 | + | ||
| 21 | + | require.Equal(t, "Back", renderItems[0].item.Label) | |
| 22 | + | require.False(t, renderItems[0].separator) | |
| 23 | + | ||
| 24 | + | require.True(t, renderItems[1].separator) | |
| 25 | + | ||
| 26 | + | require.Equal(t, "Reload", renderItems[2].item.Label) | |
| 27 | + | require.False(t, renderItems[2].separator) | |
| 28 | + | ||
| 29 | + | require.Equal(t, "Inspect Element", renderItems[3].item.Label) | |
| 30 | + | require.False(t, renderItems[3].separator) | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package contextmenu | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestBuildButtons(t *testing.T) { | |||
| 11 | + | items := []port.MenuItem{ | |||
| 12 | + | {Action: port.MenuActionBack, Label: "Back"}, | |||
| 13 | + | {}, | |||
| 14 | + | {Action: port.MenuActionReload, Label: "Reload"}, | |||
| 15 | + | {Action: port.MenuActionInspectElement, Label: "Inspect Element"}, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | renderItems := buildButtons(items) | |||
| 19 | + | require.Len(t, renderItems, 4) | |||
| 20 | + | ||||
| 21 | + | require.Equal(t, "Back", renderItems[0].item.Label) | |||
| 22 | + | require.False(t, renderItems[0].separator) | |||
| 23 | + | ||||
| 24 | + | require.True(t, renderItems[1].separator) | |||
| 25 | + | ||||
| 26 | + | require.Equal(t, "Reload", renderItems[2].item.Label) | |||
| 27 | + | require.False(t, renderItems[2].separator) | |||
| 28 | + | ||||
| 29 | + | require.Equal(t, "Inspect Element", renderItems[3].item.Label) | |||
| 30 | + | require.False(t, renderItems[3].separator) | |||
| 31 | + | } | |||
A internal/infrastructure/contextmenu/resolved_image_saver.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | package contextmenu | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | resolvedImageDirPerm = 0o755 | |
| 14 | + | resolvedImageFilePerm = 0o644 | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ResolvedImageSaver prepares a download destination and writes resolved image bytes to disk. | |
| 18 | + | type ResolvedImageSaver struct { | |
| 19 | + | preparer port.DownloadPreparer | |
| 20 | + | downloadDir string | |
| 21 | + | menuCtx port.MenuContext | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewResolvedImageSaver creates a new ResolvedImageSaver helper. | |
| 25 | + | func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string, menuCtx port.MenuContext) *ResolvedImageSaver { | |
| 26 | + | return &ResolvedImageSaver{ | |
| 27 | + | preparer: preparer, | |
| 28 | + | downloadDir: downloadDir, | |
| 29 | + | menuCtx: menuCtx, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // SaveResolvedImage resolves a destination path and writes the image bytes. | |
| 34 | + | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error { | |
| 35 | + | if s == nil || s.preparer == nil { | |
| 36 | + | return fmt.Errorf("resolved image saver: download preparer not available") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | output := s.preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 40 | + | DownloadDir: s.downloadDir, | |
| 41 | + | // Leave SuggestedFilename empty so DownloadPreparer can fall back to | |
| 42 | + | // URI-derived naming when no explicit filename is available. | |
| 43 | + | Response: downloadResponse{ | |
| 44 | + | mimeType: "image/png", | |
| 45 | + | uri: s.menuCtx.ImageURI, | |
| 46 | + | }, | |
| 47 | + | }) | |
| 48 | + | if output == nil || output.DestinationPath == "" { | |
| 49 | + | return fmt.Errorf("resolved image saver: destination path not prepared") | |
| 50 | + | } | |
| 51 | + | if len(image.Bytes) == 0 { | |
| 52 | + | return fmt.Errorf("resolved image saver: empty image data") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | if err := os.WriteFile(output.DestinationPath, image.Bytes, resolvedImageFilePerm); err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type downloadResponse struct { | |
| 65 | + | mimeType string | |
| 66 | + | suggestedFilename string | |
| 67 | + | uri string | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (r downloadResponse) GetMimeType() string { return r.mimeType } | |
| 71 | + | func (r downloadResponse) GetSuggestedFilename() string { return r.suggestedFilename } | |
| 72 | + | func (r downloadResponse) GetUri() string { return r.uri } | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package contextmenu | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | resolvedImageDirPerm = 0o755 | |||
| 14 | + | resolvedImageFilePerm = 0o644 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // ResolvedImageSaver prepares a download destination and writes resolved image bytes to disk. | |||
| 18 | + | type ResolvedImageSaver struct { | |||
| 19 | + | preparer port.DownloadPreparer | |||
| 20 | + | downloadDir string | |||
| 21 | + | menuCtx port.MenuContext | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewResolvedImageSaver creates a new ResolvedImageSaver helper. | |||
| 25 | + | func NewResolvedImageSaver(preparer port.DownloadPreparer, downloadDir string, menuCtx port.MenuContext) *ResolvedImageSaver { | |||
| 26 | + | return &ResolvedImageSaver{ | |||
| 27 | + | preparer: preparer, | |||
| 28 | + | downloadDir: downloadDir, | |||
| 29 | + | menuCtx: menuCtx, | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // SaveResolvedImage resolves a destination path and writes the image bytes. | |||
| 34 | + | func (s *ResolvedImageSaver) SaveResolvedImage(ctx context.Context, image port.ImageData) error { | |||
| 35 | + | if s == nil || s.preparer == nil { | |||
| 36 | + | return fmt.Errorf("resolved image saver: download preparer not available") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | output := s.preparer.Execute(ctx, port.DownloadPrepareInput{ | |||
| 40 | + | DownloadDir: s.downloadDir, | |||
| 41 | + | // Leave SuggestedFilename empty so DownloadPreparer can fall back to | |||
| 42 | + | // URI-derived naming when no explicit filename is available. | |||
| 43 | + | Response: downloadResponse{ | |||
| 44 | + | mimeType: "image/png", | |||
| 45 | + | uri: s.menuCtx.ImageURI, | |||
| 46 | + | }, | |||
| 47 | + | }) | |||
| 48 | + | if output == nil || output.DestinationPath == "" { | |||
| 49 | + | return fmt.Errorf("resolved image saver: destination path not prepared") | |||
| 50 | + | } | |||
| 51 | + | if len(image.Bytes) == 0 { | |||
| 52 | + | return fmt.Errorf("resolved image saver: empty image data") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | if err := os.MkdirAll(filepath.Dir(output.DestinationPath), resolvedImageDirPerm); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | if err := os.WriteFile(output.DestinationPath, image.Bytes, resolvedImageFilePerm); err != nil { | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | return nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | type downloadResponse struct { | |||
| 65 | + | mimeType string | |||
| 66 | + | suggestedFilename string | |||
| 67 | + | uri string | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (r downloadResponse) GetMimeType() string { return r.mimeType } | |||
| 71 | + | func (r downloadResponse) GetSuggestedFilename() string { return r.suggestedFilename } | |||
| 72 | + | func (r downloadResponse) GetUri() string { return r.uri } | |||
A internal/infrastructure/contextmenu/resolved_image_saver_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package contextmenu | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type fakeDownloadPreparer struct { | |
| 14 | + | input port.DownloadPrepareInput | |
| 15 | + | output *port.DownloadPrepareOutput | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |
| 19 | + | f.input = input | |
| 20 | + | return f.output | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) { | |
| 24 | + | downloadDir := t.TempDir() | |
| 25 | + | destPath := filepath.Join(downloadDir, "image.png") | |
| 26 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}} | |
| 27 | + | saver := NewResolvedImageSaver(preparer, downloadDir, port.MenuContext{ImageURI: "https://example.com/assets/image"}) | |
| 28 | + | ||
| 29 | + | err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}}) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | require.Equal(t, downloadDir, preparer.input.DownloadDir) | |
| 32 | + | require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri()) | |
| 33 | + | require.Equal(t, "image/png", preparer.input.Response.GetMimeType()) | |
| 34 | + | require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath)) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) { | |
| 38 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}} | |
| 39 | + | saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{}) | |
| 40 | + | ||
| 41 | + | err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}) | |
| 42 | + | require.Error(t, err) | |
| 43 | + | require.Contains(t, err.Error(), "destination path") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) { | |
| 47 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}} | |
| 48 | + | saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{}) | |
| 49 | + | ||
| 50 | + | err := saver.SaveResolvedImage(context.Background(), port.ImageData{}) | |
| 51 | + | require.Error(t, err) | |
| 52 | + | require.Contains(t, err.Error(), "empty image data") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func readFile(t *testing.T, path string) []byte { | |
| 56 | + | t.Helper() | |
| 57 | + | data, err := os.ReadFile(path) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | return data | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package contextmenu | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type fakeDownloadPreparer struct { | |||
| 14 | + | input port.DownloadPrepareInput | |||
| 15 | + | output *port.DownloadPrepareOutput | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (f *fakeDownloadPreparer) Execute(_ context.Context, input port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |||
| 19 | + | f.input = input | |||
| 20 | + | return f.output | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestResolvedImageSaver_SaveResolvedImage(t *testing.T) { | |||
| 24 | + | downloadDir := t.TempDir() | |||
| 25 | + | destPath := filepath.Join(downloadDir, "image.png") | |||
| 26 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: destPath}} | |||
| 27 | + | saver := NewResolvedImageSaver(preparer, downloadDir, port.MenuContext{ImageURI: "https://example.com/assets/image"}) | |||
| 28 | + | ||||
| 29 | + | err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1, 2, 3, 4}}) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | require.Equal(t, downloadDir, preparer.input.DownloadDir) | |||
| 32 | + | require.Equal(t, "https://example.com/assets/image", preparer.input.Response.GetUri()) | |||
| 33 | + | require.Equal(t, "image/png", preparer.input.Response.GetMimeType()) | |||
| 34 | + | require.Equal(t, []byte{1, 2, 3, 4}, readFile(t, destPath)) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestResolvedImageSaver_SaveResolvedImageRequiresDestination(t *testing.T) { | |||
| 38 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png"}} | |||
| 39 | + | saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{}) | |||
| 40 | + | ||||
| 41 | + | err := saver.SaveResolvedImage(context.Background(), port.ImageData{Bytes: []byte{1}}) | |||
| 42 | + | require.Error(t, err) | |||
| 43 | + | require.Contains(t, err.Error(), "destination path") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestResolvedImageSaver_SaveResolvedImageRejectsEmptyBytes(t *testing.T) { | |||
| 47 | + | preparer := &fakeDownloadPreparer{output: &port.DownloadPrepareOutput{Filename: "image.png", DestinationPath: filepath.Join(t.TempDir(), "image.png")}} | |||
| 48 | + | saver := NewResolvedImageSaver(preparer, t.TempDir(), port.MenuContext{}) | |||
| 49 | + | ||||
| 50 | + | err := saver.SaveResolvedImage(context.Background(), port.ImageData{}) | |||
| 51 | + | require.Error(t, err) | |||
| 52 | + | require.Contains(t, err.Error(), "empty image data") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func readFile(t *testing.T, path string) []byte { | |||
| 56 | + | t.Helper() | |||
| 57 | + | data, err := os.ReadFile(path) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | return data | |||
| 60 | + | } | |||
A internal/infrastructure/webkit/context_menu.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | |
| 18 | + | // over HTTP. This is the WebKit-specific seam for the shared context menu | |
| 19 | + | // pipeline's copy-image and save-image actions. | |
| 20 | + | type contextMenuResolver struct { | |
| 21 | + | client *http.Client | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second} | |
| 25 | + | ||
| 26 | + | const maxImageFetchBytes = 50 * 1024 * 1024 | |
| 27 | + | ||
| 28 | + | // ResolveImageData fetches raw image bytes from the given URI. | |
| 29 | + | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) { | |
| 30 | + | if imageURI == "" { | |
| 31 | + | return port.ImageData{}, fmt.Errorf("empty image URI") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | client := r.client | |
| 35 | + | if client == nil { | |
| 36 | + | client = defaultImageFetchClient | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURI, http.NoBody) | |
| 40 | + | if err != nil { | |
| 41 | + | return port.ImageData{}, fmt.Errorf("build request: %w", err) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | resp, err := client.Do(req) | |
| 45 | + | if err != nil { | |
| 46 | + | return port.ImageData{}, fmt.Errorf("fetch image: %w", err) | |
| 47 | + | } | |
| 48 | + | defer func() { _ = resp.Body.Close() }() | |
| 49 | + | ||
| 50 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 51 | + | return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes)) | |
| 55 | + | if err != nil { | |
| 56 | + | return port.ImageData{}, fmt.Errorf("read image body: %w", err) | |
| 57 | + | } | |
| 58 | + | if len(data) == 0 { | |
| 59 | + | return port.ImageData{}, fmt.Errorf("read image body: empty image data") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | return port.ImageData{Bytes: data}, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Compile-time check that contextMenuResolver implements port.ImageDataResolver. | |
| 66 | + | var _ port.ImageDataResolver = (*contextMenuResolver)(nil) | |
| 67 | + | ||
| 68 | + | // buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext. | |
| 69 | + | func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext { | |
| 70 | + | ctx := port.MenuContext{ | |
| 71 | + | X: x, | |
| 72 | + | Y: y, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | if wv != nil { | |
| 76 | + | ctx.PageURI = wv.URI() | |
| 77 | + | ctx.CanGoBack = wv.CanGoBack() | |
| 78 | + | ctx.CanGoForward = wv.CanGoForward() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | if hit == nil { | |
| 82 | + | return ctx | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | if hit.ContextIsLink() { | |
| 86 | + | ctx.LinkURI = hit.GetLinkUri() | |
| 87 | + | } | |
| 88 | + | if hit.ContextIsImage() { | |
| 89 | + | ctx.ImageURI = hit.GetImageUri() | |
| 90 | + | } | |
| 91 | + | ctx.HasSelection = hit.ContextIsSelection() | |
| 92 | + | ctx.IsEditable = hit.ContextIsEditable() | |
| 93 | + | ||
| 94 | + | return ctx | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // contextMenuPipeline holds the dependencies needed to run the shared | |
| 98 | + | // context menu flow from inside a WebKit context-menu signal handler. | |
| 99 | + | type contextMenuPipeline struct { | |
| 100 | + | buildUC *usecase.BuildContextMenuUseCase | |
| 101 | + | executeUC *usecase.ExecuteContextMenuActionUseCase | |
| 102 | + | renderer *contextmenu.Renderer | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | |
| 106 | + | // shared context menu pipeline. When the user right-clicks, WebKit's native | |
| 107 | + | // menu is suppressed and replaced by the shared GTK popover menu. | |
| 108 | + | func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) { | |
| 109 | + | if pipeline == nil { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu | |
| 114 | + | hit := webkit.HitTestResultNewFromInternalPtr(hitTestPtr) | |
| 115 | + | ||
| 116 | + | // Determine click coordinates from the hit test result. | |
| 117 | + | // WebKit does not expose x/y directly on the context-menu signal; | |
| 118 | + | // we fall back to 0,0 if the hit test lacks positional data. | |
| 119 | + | // The renderer uses these as popover anchor coordinates. | |
| 120 | + | var x, y int | |
| 121 | + | ||
| 122 | + | menuContext := buildMenuContextFromHitTest(wv, hit, x, y) | |
| 123 | + | items := pipeline.buildUC.Build(context.Background(), menuContext) | |
| 124 | + | if len(items) == 0 { | |
| 125 | + | return true // suppress native menu, nothing to show | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Get the widget for anchoring the popover. | |
| 129 | + | if wv.inner == nil { | |
| 130 | + | return true | |
| 131 | + | } | |
| 132 | + | anchor := &wv.inner.Widget | |
| 133 | + | ||
| 134 | + | pipeline.renderer.Show( | |
| 135 | + | items, | |
| 136 | + | anchor, | |
| 137 | + | int32(x), | |
| 138 | + | int32(y), | |
| 139 | + | func(item port.MenuItem) { | |
| 140 | + | wv.executeContextMenuAction(pipeline.executeUC, item, menuContext) | |
| 141 | + | }, | |
| 142 | + | nil, // onClose — no action needed | |
| 143 | + | ) | |
| 144 | + | ||
| 145 | + | return true // suppress native WebKit context menu | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | sigID := wv.inner.ConnectContextMenu(&contextMenuCb) | |
| 149 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // executeContextMenuAction runs the selected menu action through the shared | |
| 153 | + | // execute use case. Runs in a goroutine to avoid blocking the GTK main thread. | |
| 154 | + | func (wv *WebView) executeContextMenuAction( | |
| 155 | + | executeUC *usecase.ExecuteContextMenuActionUseCase, | |
| 156 | + | item port.MenuItem, | |
| 157 | + | menuContext port.MenuContext, | |
| 158 | + | ) { | |
| 159 | + | if executeUC == nil { | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | go func() { | |
| 164 | + | ctx := logging.WithContext(context.Background(), wv.logger) | |
| 165 | + | input := usecase.ExecuteContextMenuActionInput{ | |
| 166 | + | Action: item.Action, | |
| 167 | + | Context: menuContext, | |
| 168 | + | } | |
| 169 | + | if err := executeUC.Execute(ctx, input); err != nil { | |
| 170 | + | wv.logger.Warn().Err(err). | |
| 171 | + | Str("action", string(item.Action)). | |
| 172 | + | Msg("context menu action failed") | |
| 173 | + | } | |
| 174 | + | }() | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // SetupContextMenu configures the context menu pipeline for this WebView. | |
| 178 | + | // Called during WebView setup when the shared dependencies are available. | |
| 179 | + | func (wv *WebView) SetupContextMenu( | |
| 180 | + | buildUC *usecase.BuildContextMenuUseCase, | |
| 181 | + | executeUC *usecase.ExecuteContextMenuActionUseCase, | |
| 182 | + | renderer *contextmenu.Renderer, | |
| 183 | + | ) { | |
| 184 | + | if wv == nil || wv.inner == nil { | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | pipeline := &contextMenuPipeline{ | |
| 188 | + | buildUC: buildUC, | |
| 189 | + | executeUC: executeUC, | |
| 190 | + | renderer: renderer, | |
| 191 | + | } | |
| 192 | + | wv.contextMenu = pipeline | |
| 193 | + | wv.connectContextMenuSignal(pipeline) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // NewContextMenuResolver creates a new ImageDataResolver for WebKit | |
| 197 | + | // that fetches image bytes over HTTP. | |
| 198 | + | func NewContextMenuResolver() port.ImageDataResolver { | |
| 199 | + | return &contextMenuResolver{} | |
| 200 | + | } | |
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 14 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | |||
| 18 | + | // over HTTP. This is the WebKit-specific seam for the shared context menu | |||
| 19 | + | // pipeline's copy-image and save-image actions. | |||
| 20 | + | type contextMenuResolver struct { | |||
| 21 | + | client *http.Client | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | var defaultImageFetchClient = &http.Client{Timeout: 15 * time.Second} | |||
| 25 | + | ||||
| 26 | + | const maxImageFetchBytes = 50 * 1024 * 1024 | |||
| 27 | + | ||||
| 28 | + | // ResolveImageData fetches raw image bytes from the given URI. | |||
| 29 | + | func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI string) (port.ImageData, error) { | |||
| 30 | + | if imageURI == "" { | |||
| 31 | + | return port.ImageData{}, fmt.Errorf("empty image URI") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | client := r.client | |||
| 35 | + | if client == nil { | |||
| 36 | + | client = defaultImageFetchClient | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURI, http.NoBody) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return port.ImageData{}, fmt.Errorf("build request: %w", err) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | resp, err := client.Do(req) | |||
| 45 | + | if err != nil { | |||
| 46 | + | return port.ImageData{}, fmt.Errorf("fetch image: %w", err) | |||
| 47 | + | } | |||
| 48 | + | defer func() { _ = resp.Body.Close() }() | |||
| 49 | + | ||||
| 50 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |||
| 51 | + | return port.ImageData{}, fmt.Errorf("fetch image: HTTP %d", resp.StatusCode) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageFetchBytes)) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return port.ImageData{}, fmt.Errorf("read image body: %w", err) | |||
| 57 | + | } | |||
| 58 | + | if len(data) == 0 { | |||
| 59 | + | return port.ImageData{}, fmt.Errorf("read image body: empty image data") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | return port.ImageData{Bytes: data}, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Compile-time check that contextMenuResolver implements port.ImageDataResolver. | |||
| 66 | + | var _ port.ImageDataResolver = (*contextMenuResolver)(nil) | |||
| 67 | + | ||||
| 68 | + | // buildMenuContextFromHitTest maps a WebKit HitTestResult into a port.MenuContext. | |||
| 69 | + | func buildMenuContextFromHitTest(wv *WebView, hit *webkit.HitTestResult, x, y int) port.MenuContext { | |||
| 70 | + | ctx := port.MenuContext{ | |||
| 71 | + | X: x, | |||
| 72 | + | Y: y, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | if wv != nil { | |||
| 76 | + | ctx.PageURI = wv.URI() | |||
| 77 | + | ctx.CanGoBack = wv.CanGoBack() | |||
| 78 | + | ctx.CanGoForward = wv.CanGoForward() | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | if hit == nil { | |||
| 82 | + | return ctx | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | if hit.ContextIsLink() { | |||
| 86 | + | ctx.LinkURI = hit.GetLinkUri() | |||
| 87 | + | } | |||
| 88 | + | if hit.ContextIsImage() { | |||
| 89 | + | ctx.ImageURI = hit.GetImageUri() | |||
| 90 | + | } | |||
| 91 | + | ctx.HasSelection = hit.ContextIsSelection() | |||
| 92 | + | ctx.IsEditable = hit.ContextIsEditable() | |||
| 93 | + | ||||
| 94 | + | return ctx | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // contextMenuPipeline holds the dependencies needed to run the shared | |||
| 98 | + | // context menu flow from inside a WebKit context-menu signal handler. | |||
| 99 | + | type contextMenuPipeline struct { | |||
| 100 | + | buildUC *usecase.BuildContextMenuUseCase | |||
| 101 | + | executeUC *usecase.ExecuteContextMenuActionUseCase | |||
| 102 | + | renderer *contextmenu.Renderer | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | |||
| 106 | + | // shared context menu pipeline. When the user right-clicks, WebKit's native | |||
| 107 | + | // menu is suppressed and replaced by the shared GTK popover menu. | |||
| 108 | + | func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) { | |||
| 109 | + | if pipeline == nil { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | contextMenuCb := func(_ webkit.WebView, _ uintptr, hitTestPtr uintptr) bool { //nolint:unparam // always true: suppress native menu | |||
| 114 | + | hit := webkit.HitTestResultNewFromInternalPtr(hitTestPtr) | |||
| 115 | + | ||||
| 116 | + | // Determine click coordinates from the hit test result. | |||
| 117 | + | // WebKit does not expose x/y directly on the context-menu signal; | |||
| 118 | + | // we fall back to 0,0 if the hit test lacks positional data. | |||
| 119 | + | // The renderer uses these as popover anchor coordinates. | |||
| 120 | + | var x, y int | |||
| 121 | + | ||||
| 122 | + | menuContext := buildMenuContextFromHitTest(wv, hit, x, y) | |||
| 123 | + | items := pipeline.buildUC.Build(context.Background(), menuContext) | |||
| 124 | + | if len(items) == 0 { | |||
| 125 | + | return true // suppress native menu, nothing to show | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Get the widget for anchoring the popover. | |||
| 129 | + | if wv.inner == nil { | |||
| 130 | + | return true | |||
| 131 | + | } | |||
| 132 | + | anchor := &wv.inner.Widget | |||
| 133 | + | ||||
| 134 | + | pipeline.renderer.Show( | |||
| 135 | + | items, | |||
| 136 | + | anchor, | |||
| 137 | + | int32(x), | |||
| 138 | + | int32(y), | |||
| 139 | + | func(item port.MenuItem) { | |||
| 140 | + | wv.executeContextMenuAction(pipeline.executeUC, item, menuContext) | |||
| 141 | + | }, | |||
| 142 | + | nil, // onClose — no action needed | |||
| 143 | + | ) | |||
| 144 | + | ||||
| 145 | + | return true // suppress native WebKit context menu | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | sigID := wv.inner.ConnectContextMenu(&contextMenuCb) | |||
| 149 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // executeContextMenuAction runs the selected menu action through the shared | |||
| 153 | + | // execute use case. Runs in a goroutine to avoid blocking the GTK main thread. | |||
| 154 | + | func (wv *WebView) executeContextMenuAction( | |||
| 155 | + | executeUC *usecase.ExecuteContextMenuActionUseCase, | |||
| 156 | + | item port.MenuItem, | |||
| 157 | + | menuContext port.MenuContext, | |||
| 158 | + | ) { | |||
| 159 | + | if executeUC == nil { | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | go func() { | |||
| 164 | + | ctx := logging.WithContext(context.Background(), wv.logger) | |||
| 165 | + | input := usecase.ExecuteContextMenuActionInput{ | |||
| 166 | + | Action: item.Action, | |||
| 167 | + | Context: menuContext, | |||
| 168 | + | } | |||
| 169 | + | if err := executeUC.Execute(ctx, input); err != nil { | |||
| 170 | + | wv.logger.Warn().Err(err). | |||
| 171 | + | Str("action", string(item.Action)). | |||
| 172 | + | Msg("context menu action failed") | |||
| 173 | + | } | |||
| 174 | + | }() | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // SetupContextMenu configures the context menu pipeline for this WebView. | |||
| 178 | + | // Called during WebView setup when the shared dependencies are available. | |||
| 179 | + | func (wv *WebView) SetupContextMenu( | |||
| 180 | + | buildUC *usecase.BuildContextMenuUseCase, | |||
| 181 | + | executeUC *usecase.ExecuteContextMenuActionUseCase, | |||
| 182 | + | renderer *contextmenu.Renderer, | |||
| 183 | + | ) { | |||
| 184 | + | if wv == nil || wv.inner == nil { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | pipeline := &contextMenuPipeline{ | |||
| 188 | + | buildUC: buildUC, | |||
| 189 | + | executeUC: executeUC, | |||
| 190 | + | renderer: renderer, | |||
| 191 | + | } | |||
| 192 | + | wv.contextMenu = pipeline | |||
| 193 | + | wv.connectContextMenuSignal(pipeline) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // NewContextMenuResolver creates a new ImageDataResolver for WebKit | |||
| 197 | + | // that fetches image bytes over HTTP. | |||
| 198 | + | func NewContextMenuResolver() port.ImageDataResolver { | |||
| 199 | + | return &contextMenuResolver{} | |||
| 200 | + | } | |||
A internal/infrastructure/webkit/context_menu_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // stubHitTest simulates a WebKit HitTestResult for testing. | |
| 15 | + | type stubHitTest struct { | |
| 16 | + | linkURI string | |
| 17 | + | imageURI string | |
| 18 | + | isLink bool | |
| 19 | + | isImage bool | |
| 20 | + | isEditable bool | |
| 21 | + | isSelection bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestBuildMenuContextFromHitTest(t *testing.T) { | |
| 25 | + | t.Run("image hit test populates ImageURI", func(t *testing.T) { | |
| 26 | + | hit := stubHitTest{ | |
| 27 | + | imageURI: "https://example.com/photo.png", | |
| 28 | + | isImage: true, | |
| 29 | + | } | |
| 30 | + | ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200) | |
| 31 | + | ||
| 32 | + | assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI) | |
| 33 | + | assert.Equal(t, 100, ctx.X) | |
| 34 | + | assert.Equal(t, 200, ctx.Y) | |
| 35 | + | assert.True(t, ctx.CanGoBack) | |
| 36 | + | assert.False(t, ctx.CanGoForward) | |
| 37 | + | }) | |
| 38 | + | ||
| 39 | + | t.Run("link hit test populates LinkURI", func(t *testing.T) { | |
| 40 | + | hit := stubHitTest{ | |
| 41 | + | linkURI: "https://example.com/page", | |
| 42 | + | isLink: true, | |
| 43 | + | } | |
| 44 | + | ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75) | |
| 45 | + | ||
| 46 | + | assert.Equal(t, "https://example.com/page", ctx.LinkURI) | |
| 47 | + | assert.Empty(t, ctx.ImageURI) | |
| 48 | + | assert.False(t, ctx.CanGoBack) | |
| 49 | + | assert.True(t, ctx.CanGoForward) | |
| 50 | + | }) | |
| 51 | + | ||
| 52 | + | t.Run("editable and selection flags propagate", func(t *testing.T) { | |
| 53 | + | hit := stubHitTest{ | |
| 54 | + | isEditable: true, | |
| 55 | + | isSelection: true, | |
| 56 | + | } | |
| 57 | + | ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0) | |
| 58 | + | ||
| 59 | + | assert.True(t, ctx.IsEditable) | |
| 60 | + | assert.True(t, ctx.HasSelection) | |
| 61 | + | }) | |
| 62 | + | ||
| 63 | + | t.Run("combined image and link", func(t *testing.T) { | |
| 64 | + | hit := stubHitTest{ | |
| 65 | + | linkURI: "https://example.com/link", | |
| 66 | + | imageURI: "https://example.com/img.jpg", | |
| 67 | + | isLink: true, | |
| 68 | + | isImage: true, | |
| 69 | + | } | |
| 70 | + | ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20) | |
| 71 | + | ||
| 72 | + | assert.Equal(t, "https://example.com/link", ctx.LinkURI) | |
| 73 | + | assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI) | |
| 74 | + | assert.True(t, ctx.CanGoBack) | |
| 75 | + | assert.True(t, ctx.CanGoForward) | |
| 76 | + | }) | |
| 77 | + | ||
| 78 | + | t.Run("empty hit test produces empty context", func(t *testing.T) { | |
| 79 | + | hit := stubHitTest{} | |
| 80 | + | ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0) | |
| 81 | + | ||
| 82 | + | assert.Empty(t, ctx.LinkURI) | |
| 83 | + | assert.Empty(t, ctx.ImageURI) | |
| 84 | + | assert.False(t, ctx.HasSelection) | |
| 85 | + | assert.False(t, ctx.IsEditable) | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestResolveImageData(t *testing.T) { | |
| 90 | + | t.Run("rejects empty image URI", func(t *testing.T) { | |
| 91 | + | resolver := &contextMenuResolver{} | |
| 92 | + | _, err := resolver.ResolveImageData(context.Background(), "") | |
| 93 | + | require.Error(t, err) | |
| 94 | + | assert.Contains(t, err.Error(), "empty image URI") | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | t.Run("fetches image bytes from HTTP", func(t *testing.T) { | |
| 98 | + | imageBytes := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic | |
| 99 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 100 | + | w.Header().Set("Content-Type", "image/png") | |
| 101 | + | w.Write(imageBytes) | |
| 102 | + | })) | |
| 103 | + | defer srv.Close() | |
| 104 | + | ||
| 105 | + | resolver := &contextMenuResolver{} | |
| 106 | + | data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png") | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | assert.Equal(t, imageBytes, data.Bytes) | |
| 109 | + | }) | |
| 110 | + | ||
| 111 | + | t.Run("returns error on HTTP failure", func(t *testing.T) { | |
| 112 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 113 | + | w.WriteHeader(http.StatusNotFound) | |
| 114 | + | })) | |
| 115 | + | defer srv.Close() | |
| 116 | + | ||
| 117 | + | resolver := &contextMenuResolver{} | |
| 118 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png") | |
| 119 | + | require.Error(t, err) | |
| 120 | + | assert.Contains(t, err.Error(), "404") | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | t.Run("returns error on invalid URL", func(t *testing.T) { | |
| 124 | + | resolver := &contextMenuResolver{} | |
| 125 | + | _, err := resolver.ResolveImageData(context.Background(), "://bad-url") | |
| 126 | + | require.Error(t, err) | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | |
| 131 | + | // that works with our stub type instead of requiring real WebKit objects. | |
| 132 | + | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | |
| 133 | + | ctx := port.MenuContext{ | |
| 134 | + | CanGoBack: canGoBack, | |
| 135 | + | CanGoForward: canGoForward, | |
| 136 | + | X: x, | |
| 137 | + | Y: y, | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | if hit.isLink { | |
| 141 | + | ctx.LinkURI = hit.linkURI | |
| 142 | + | } | |
| 143 | + | if hit.isImage { | |
| 144 | + | ctx.ImageURI = hit.imageURI | |
| 145 | + | } | |
| 146 | + | ctx.HasSelection = hit.isSelection | |
| 147 | + | ctx.IsEditable = hit.isEditable | |
| 148 | + | ||
| 149 | + | return ctx | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // stubHitTest simulates a WebKit HitTestResult for testing. | |||
| 15 | + | type stubHitTest struct { | |||
| 16 | + | linkURI string | |||
| 17 | + | imageURI string | |||
| 18 | + | isLink bool | |||
| 19 | + | isImage bool | |||
| 20 | + | isEditable bool | |||
| 21 | + | isSelection bool | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestBuildMenuContextFromHitTest(t *testing.T) { | |||
| 25 | + | t.Run("image hit test populates ImageURI", func(t *testing.T) { | |||
| 26 | + | hit := stubHitTest{ | |||
| 27 | + | imageURI: "https://example.com/photo.png", | |||
| 28 | + | isImage: true, | |||
| 29 | + | } | |||
| 30 | + | ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200) | |||
| 31 | + | ||||
| 32 | + | assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI) | |||
| 33 | + | assert.Equal(t, 100, ctx.X) | |||
| 34 | + | assert.Equal(t, 200, ctx.Y) | |||
| 35 | + | assert.True(t, ctx.CanGoBack) | |||
| 36 | + | assert.False(t, ctx.CanGoForward) | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 39 | + | t.Run("link hit test populates LinkURI", func(t *testing.T) { | |||
| 40 | + | hit := stubHitTest{ | |||
| 41 | + | linkURI: "https://example.com/page", | |||
| 42 | + | isLink: true, | |||
| 43 | + | } | |||
| 44 | + | ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75) | |||
| 45 | + | ||||
| 46 | + | assert.Equal(t, "https://example.com/page", ctx.LinkURI) | |||
| 47 | + | assert.Empty(t, ctx.ImageURI) | |||
| 48 | + | assert.False(t, ctx.CanGoBack) | |||
| 49 | + | assert.True(t, ctx.CanGoForward) | |||
| 50 | + | }) | |||
| 51 | + | ||||
| 52 | + | t.Run("editable and selection flags propagate", func(t *testing.T) { | |||
| 53 | + | hit := stubHitTest{ | |||
| 54 | + | isEditable: true, | |||
| 55 | + | isSelection: true, | |||
| 56 | + | } | |||
| 57 | + | ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0) | |||
| 58 | + | ||||
| 59 | + | assert.True(t, ctx.IsEditable) | |||
| 60 | + | assert.True(t, ctx.HasSelection) | |||
| 61 | + | }) | |||
| 62 | + | ||||
| 63 | + | t.Run("combined image and link", func(t *testing.T) { | |||
| 64 | + | hit := stubHitTest{ | |||
| 65 | + | linkURI: "https://example.com/link", | |||
| 66 | + | imageURI: "https://example.com/img.jpg", | |||
| 67 | + | isLink: true, | |||
| 68 | + | isImage: true, | |||
| 69 | + | } | |||
| 70 | + | ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20) | |||
| 71 | + | ||||
| 72 | + | assert.Equal(t, "https://example.com/link", ctx.LinkURI) | |||
| 73 | + | assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI) | |||
| 74 | + | assert.True(t, ctx.CanGoBack) | |||
| 75 | + | assert.True(t, ctx.CanGoForward) | |||
| 76 | + | }) | |||
| 77 | + | ||||
| 78 | + | t.Run("empty hit test produces empty context", func(t *testing.T) { | |||
| 79 | + | hit := stubHitTest{} | |||
| 80 | + | ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0) | |||
| 81 | + | ||||
| 82 | + | assert.Empty(t, ctx.LinkURI) | |||
| 83 | + | assert.Empty(t, ctx.ImageURI) | |||
| 84 | + | assert.False(t, ctx.HasSelection) | |||
| 85 | + | assert.False(t, ctx.IsEditable) | |||
| 86 | + | }) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestResolveImageData(t *testing.T) { | |||
| 90 | + | t.Run("rejects empty image URI", func(t *testing.T) { | |||
| 91 | + | resolver := &contextMenuResolver{} | |||
| 92 | + | _, err := resolver.ResolveImageData(context.Background(), "") | |||
| 93 | + | require.Error(t, err) | |||
| 94 | + | assert.Contains(t, err.Error(), "empty image URI") | |||
| 95 | + | }) | |||
| 96 | + | ||||
| 97 | + | t.Run("fetches image bytes from HTTP", func(t *testing.T) { | |||
| 98 | + | imageBytes := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic | |||
| 99 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 100 | + | w.Header().Set("Content-Type", "image/png") | |||
| 101 | + | w.Write(imageBytes) | |||
| 102 | + | })) | |||
| 103 | + | defer srv.Close() | |||
| 104 | + | ||||
| 105 | + | resolver := &contextMenuResolver{} | |||
| 106 | + | data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png") | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | assert.Equal(t, imageBytes, data.Bytes) | |||
| 109 | + | }) | |||
| 110 | + | ||||
| 111 | + | t.Run("returns error on HTTP failure", func(t *testing.T) { | |||
| 112 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 113 | + | w.WriteHeader(http.StatusNotFound) | |||
| 114 | + | })) | |||
| 115 | + | defer srv.Close() | |||
| 116 | + | ||||
| 117 | + | resolver := &contextMenuResolver{} | |||
| 118 | + | _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png") | |||
| 119 | + | require.Error(t, err) | |||
| 120 | + | assert.Contains(t, err.Error(), "404") | |||
| 121 | + | }) | |||
| 122 | + | ||||
| 123 | + | t.Run("returns error on invalid URL", func(t *testing.T) { | |||
| 124 | + | resolver := &contextMenuResolver{} | |||
| 125 | + | _, err := resolver.ResolveImageData(context.Background(), "://bad-url") | |||
| 126 | + | require.Error(t, err) | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | |||
| 131 | + | // that works with our stub type instead of requiring real WebKit objects. | |||
| 132 | + | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | |||
| 133 | + | ctx := port.MenuContext{ | |||
| 134 | + | CanGoBack: canGoBack, | |||
| 135 | + | CanGoForward: canGoForward, | |||
| 136 | + | X: x, | |||
| 137 | + | Y: y, | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | if hit.isLink { | |||
| 141 | + | ctx.LinkURI = hit.linkURI | |||
| 142 | + | } | |||
| 143 | + | if hit.isImage { | |||
| 144 | + | ctx.ImageURI = hit.imageURI | |||
| 145 | + | } | |||
| 146 | + | ctx.HasSelection = hit.isSelection | |||
| 147 | + | ctx.IsEditable = hit.isEditable | |||
| 148 | + | ||||
| 149 | + | return ctx | |||
| 150 | + | } | |||
M internal/infrastructure/webkit/engine.go +63 -0
| @@ -5,7 +5,9 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |
| 9 | 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 10 | 12 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 11 | 13 | "github.com/rs/zerolog" | |
| @@ -24,6 +26,7 @@ type Engine struct { | |||
| 24 | 26 | schemeHandler *DumbSchemeHandler | |
| 25 | 27 | schemePath string | |
| 26 | 28 | logger zerolog.Logger | |
| 29 | + | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | 32 | // Compile-time check that Engine implements port.Engine. | |
| @@ -92,6 +95,8 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||
| 92 | 95 | if e.messageRouter == nil { | |
| 93 | 96 | return fmt.Errorf("message router not initialized") | |
| 94 | 97 | } | |
| 98 | + | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | |
| 99 | + | e.clipboard = deps.Clipboard | |
| 95 | 100 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | |
| 96 | 101 | } | |
| 97 | 102 | ||
| @@ -113,6 +118,12 @@ func (e *Engine) ConfigureDownloads( | |||
| 113 | 118 | } | |
| 114 | 119 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | |
| 115 | 120 | e.wkCtx.SetDownloadHandler(ctx, handler) | |
| 121 | + | ||
| 122 | + | // Wire the shared context menu pipeline now that all dependencies are | |
| 123 | + | // available. RegisterHandlers (called earlier) captured the clipboard; | |
| 124 | + | // ConfigureDownloads supplies the download preparer and path. | |
| 125 | + | e.installContextMenuPipeline() | |
| 126 | + | ||
| 116 | 127 | return nil | |
| 117 | 128 | } | |
| 118 | 129 | ||
| @@ -146,3 +157,55 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU | |||
| 146 | 157 | } | |
| 147 | 158 | return nil | |
| 148 | 159 | } | |
| 160 | + | ||
| 161 | + | // installContextMenuPipeline creates the shared context menu pipeline and | |
| 162 | + | // propagates it to the factory (and pool). This is called once during startup, | |
| 163 | + | // after both RegisterHandlers (clipboard) and ConfigureDownloads (download | |
| 164 | + | // deps) have run. Kept on the concrete *Engine type because it is a | |
| 165 | + | // WebKit-specific concern. | |
| 166 | + | func (e *Engine) installContextMenuPipeline() { | |
| 167 | + | if e.clipboard == nil { | |
| 168 | + | e.logger.Warn().Msg("context menu: clipboard not available, skipping pipeline") | |
| 169 | + | return | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | buildUC := usecase.NewBuildContextMenuUseCase() | |
| 173 | + | resolver := NewContextMenuResolver() | |
| 174 | + | ||
| 175 | + | // The saver is nil for now: the ResolvedImageSaver port interface does not | |
| 176 | + | // carry the image URI needed for filename derivation, so save-image will | |
| 177 | + | // return a graceful error ("image saver not available"). Copy-image (the | |
| 178 | + | // primary goal) works without it. | |
| 179 | + | var saver port.ResolvedImageSaver | |
| 180 | + | ||
| 181 | + | executeUC := usecase.NewExecuteContextMenuActionUseCase( | |
| 182 | + | e.clipboard, | |
| 183 | + | resolver, | |
| 184 | + | saver, | |
| 185 | + | nil, // delegator — not yet implemented | |
| 186 | + | ) | |
| 187 | + | ||
| 188 | + | // WebKit's context-menu signal fires on the GTK main thread, so no | |
| 189 | + | // dispatch wrapper is needed. | |
| 190 | + | renderer := contextmenu.NewRenderer(nil) | |
| 191 | + | ||
| 192 | + | e.SetContextMenuPipeline(buildUC, executeUC, renderer) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // SetContextMenuPipeline configures the context menu pipeline for all WebViews | |
| 196 | + | // created by the factory (and pool). Call this after the use cases and renderer | |
| 197 | + | // are available in the bootstrap layer. | |
| 198 | + | func (e *Engine) SetContextMenuPipeline( | |
| 199 | + | buildUC *usecase.BuildContextMenuUseCase, | |
| 200 | + | executeUC *usecase.ExecuteContextMenuActionUseCase, | |
| 201 | + | renderer *contextmenu.Renderer, | |
| 202 | + | ) { | |
| 203 | + | pipeline := &contextMenuPipeline{ | |
| 204 | + | buildUC: buildUC, | |
| 205 | + | executeUC: executeUC, | |
| 206 | + | renderer: renderer, | |
| 207 | + | } | |
| 208 | + | if e.factory != nil { | |
| 209 | + | e.factory.SetContextMenuPipeline(pipeline) | |
| 210 | + | } | |
| 211 | + | } | |
| @@ -5,7 +5,9 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/contextmenu" | |||
| 9 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 10 | "github.com/bnema/dumber/internal/infrastructure/handlers" | 12 | "github.com/bnema/dumber/internal/infrastructure/handlers" | ||
| 11 | "github.com/rs/zerolog" | 13 | "github.com/rs/zerolog" | ||
| @@ -24,6 +26,7 @@ type Engine struct { | |||||
| 24 | schemeHandler *DumbSchemeHandler | 26 | schemeHandler *DumbSchemeHandler | ||
| 25 | schemePath string | 27 | schemePath string | ||
| 26 | logger zerolog.Logger | 28 | logger zerolog.Logger | ||
| 29 | + | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | |||
| 27 | } | 30 | } | ||
| 28 | 31 | ||||
| 29 | // Compile-time check that Engine implements port.Engine. | 32 | // Compile-time check that Engine implements port.Engine. | ||
| @@ -92,6 +95,8 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||||
| 92 | if e.messageRouter == nil { | 95 | if e.messageRouter == nil { | ||
| 93 | return fmt.Errorf("message router not initialized") | 96 | return fmt.Errorf("message router not initialized") | ||
| 94 | } | 97 | } | ||
| 98 | + | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | |||
| 99 | + | e.clipboard = deps.Clipboard | |||
| 95 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | 100 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | ||
| 96 | } | 101 | } | ||
| 97 | 102 | ||||
| @@ -113,6 +118,12 @@ func (e *Engine) ConfigureDownloads( | |||||
| 113 | } | 118 | } | ||
| 114 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | 119 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | ||
| 115 | e.wkCtx.SetDownloadHandler(ctx, handler) | 120 | e.wkCtx.SetDownloadHandler(ctx, handler) | ||
| 121 | + | ||||
| 122 | + | // Wire the shared context menu pipeline now that all dependencies are | |||
| 123 | + | // available. RegisterHandlers (called earlier) captured the clipboard; | |||
| 124 | + | // ConfigureDownloads supplies the download preparer and path. | |||
| 125 | + | e.installContextMenuPipeline() | |||
| 126 | + | ||||
| 116 | return nil | 127 | return nil | ||
| 117 | } | 128 | } | ||
| 118 | 129 | ||||
| @@ -146,3 +157,55 @@ func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsU | |||||
| 146 | } | 157 | } | ||
| 147 | return nil | 158 | return nil | ||
| 148 | } | 159 | } | ||
| 160 | + | ||||
| 161 | + | // installContextMenuPipeline creates the shared context menu pipeline and | |||
| 162 | + | // propagates it to the factory (and pool). This is called once during startup, | |||
| 163 | + | // after both RegisterHandlers (clipboard) and ConfigureDownloads (download | |||
| 164 | + | // deps) have run. Kept on the concrete *Engine type because it is a | |||
| 165 | + | // WebKit-specific concern. | |||
| 166 | + | func (e *Engine) installContextMenuPipeline() { | |||
| 167 | + | if e.clipboard == nil { | |||
| 168 | + | e.logger.Warn().Msg("context menu: clipboard not available, skipping pipeline") | |||
| 169 | + | return | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | buildUC := usecase.NewBuildContextMenuUseCase() | |||
| 173 | + | resolver := NewContextMenuResolver() | |||
| 174 | + | ||||
| 175 | + | // The saver is nil for now: the ResolvedImageSaver port interface does not | |||
| 176 | + | // carry the image URI needed for filename derivation, so save-image will | |||
| 177 | + | // return a graceful error ("image saver not available"). Copy-image (the | |||
| 178 | + | // primary goal) works without it. | |||
| 179 | + | var saver port.ResolvedImageSaver | |||
| 180 | + | ||||
| 181 | + | executeUC := usecase.NewExecuteContextMenuActionUseCase( | |||
| 182 | + | e.clipboard, | |||
| 183 | + | resolver, | |||
| 184 | + | saver, | |||
| 185 | + | nil, // delegator — not yet implemented | |||
| 186 | + | ) | |||
| 187 | + | ||||
| 188 | + | // WebKit's context-menu signal fires on the GTK main thread, so no | |||
| 189 | + | // dispatch wrapper is needed. | |||
| 190 | + | renderer := contextmenu.NewRenderer(nil) | |||
| 191 | + | ||||
| 192 | + | e.SetContextMenuPipeline(buildUC, executeUC, renderer) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // SetContextMenuPipeline configures the context menu pipeline for all WebViews | |||
| 196 | + | // created by the factory (and pool). Call this after the use cases and renderer | |||
| 197 | + | // are available in the bootstrap layer. | |||
| 198 | + | func (e *Engine) SetContextMenuPipeline( | |||
| 199 | + | buildUC *usecase.BuildContextMenuUseCase, | |||
| 200 | + | executeUC *usecase.ExecuteContextMenuActionUseCase, | |||
| 201 | + | renderer *contextmenu.Renderer, | |||
| 202 | + | ) { | |||
| 203 | + | pipeline := &contextMenuPipeline{ | |||
| 204 | + | buildUC: buildUC, | |||
| 205 | + | executeUC: executeUC, | |||
| 206 | + | renderer: renderer, | |||
| 207 | + | } | |||
| 208 | + | if e.factory != nil { | |||
| 209 | + | e.factory.SetContextMenuPipeline(pipeline) | |||
| 210 | + | } | |||
| 211 | + | } | |||
M internal/infrastructure/webkit/factory.go +23 -1
| @@ -17,7 +17,8 @@ type WebViewFactory struct { | |||
| 17 | 17 | pool *WebViewPool | |
| 18 | 18 | injector *ContentInjector | |
| 19 | 19 | router *MessageRouter | |
| 20 | - | filterApplier FilterApplier // Optional content filter applier | |
| 20 | + | filterApplier FilterApplier // Optional content filter applier | |
| 21 | + | ctxMenu *contextMenuPipeline // Optional context menu pipeline | |
| 21 | 22 | ||
| 22 | 23 | // Background color for WebViews (eliminates white flash) | |
| 23 | 24 | bg bgColor | |
| @@ -61,6 +62,15 @@ func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | |||
| 61 | 62 | } | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 65 | + | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |
| 66 | + | // Propagates to the pool if present. | |
| 67 | + | func (f *WebViewFactory) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |
| 68 | + | f.ctxMenu = pipeline | |
| 69 | + | if f.pool != nil { | |
| 70 | + | f.pool.SetContextMenuPipeline(pipeline) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 64 | 74 | // Create creates a new WebView instance. | |
| 65 | 75 | // If a pool is configured, it will try to acquire from the pool first. | |
| 66 | 76 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | |
| @@ -119,6 +129,12 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 119 | 129 | f.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 120 | 130 | } | |
| 121 | 131 | ||
| 132 | + | // Wire context menu if configured | |
| 133 | + | if f.ctxMenu != nil { | |
| 134 | + | wv.contextMenu = f.ctxMenu | |
| 135 | + | wv.connectContextMenuSignal(f.ctxMenu) | |
| 136 | + | } | |
| 137 | + | ||
| 122 | 138 | log.Debug(). | |
| 123 | 139 | Uint64("id", uint64(wv.ID())). | |
| 124 | 140 | Uint64("parent_id", uint64(parentID)). | |
| @@ -152,6 +168,12 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||
| 152 | 168 | f.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 153 | 169 | } | |
| 154 | 170 | ||
| 171 | + | // Wire context menu if configured | |
| 172 | + | if f.ctxMenu != nil { | |
| 173 | + | wv.contextMenu = f.ctxMenu | |
| 174 | + | wv.connectContextMenuSignal(f.ctxMenu) | |
| 175 | + | } | |
| 176 | + | ||
| 155 | 177 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly") | |
| 156 | 178 | return wv, nil | |
| 157 | 179 | } | |
| @@ -17,7 +17,8 @@ type WebViewFactory struct { | |||||
| 17 | pool *WebViewPool | 17 | pool *WebViewPool | ||
| 18 | injector *ContentInjector | 18 | injector *ContentInjector | ||
| 19 | router *MessageRouter | 19 | router *MessageRouter | ||
| 20 | - | filterApplier FilterApplier // Optional content filter applier | 20 | + | filterApplier FilterApplier // Optional content filter applier |
| 21 | + | ctxMenu *contextMenuPipeline // Optional context menu pipeline | |||
| 21 | 22 | ||||
| 22 | // Background color for WebViews (eliminates white flash) | 23 | // Background color for WebViews (eliminates white flash) | ||
| 23 | bg bgColor | 24 | bg bgColor | ||
| @@ -61,6 +62,15 @@ func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | |||||
| 61 | } | 62 | } | ||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 65 | + | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |||
| 66 | + | // Propagates to the pool if present. | |||
| 67 | + | func (f *WebViewFactory) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |||
| 68 | + | f.ctxMenu = pipeline | |||
| 69 | + | if f.pool != nil { | |||
| 70 | + | f.pool.SetContextMenuPipeline(pipeline) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 64 | // Create creates a new WebView instance. | 74 | // Create creates a new WebView instance. | ||
| 65 | // If a pool is configured, it will try to acquire from the pool first. | 75 | // If a pool is configured, it will try to acquire from the pool first. | ||
| 66 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | 76 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | ||
| @@ -119,6 +129,12 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 119 | f.filterApplier.ApplyTo(ctx, wv.ucm) | 129 | f.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| 120 | } | 130 | } | ||
| 121 | 131 | ||||
| 132 | + | // Wire context menu if configured | |||
| 133 | + | if f.ctxMenu != nil { | |||
| 134 | + | wv.contextMenu = f.ctxMenu | |||
| 135 | + | wv.connectContextMenuSignal(f.ctxMenu) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 122 | log.Debug(). | 138 | log.Debug(). | ||
| 123 | Uint64("id", uint64(wv.ID())). | 139 | Uint64("id", uint64(wv.ID())). | ||
| 124 | Uint64("parent_id", uint64(parentID)). | 140 | Uint64("parent_id", uint64(parentID)). | ||
| @@ -152,6 +168,12 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||||
| 152 | f.filterApplier.ApplyTo(ctx, wv.ucm) | 168 | f.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| 153 | } | 169 | } | ||
| 154 | 170 | ||||
| 171 | + | // Wire context menu if configured | |||
| 172 | + | if f.ctxMenu != nil { | |||
| 173 | + | wv.contextMenu = f.ctxMenu | |||
| 174 | + | wv.connectContextMenuSignal(f.ctxMenu) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 155 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly") | 177 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly") | ||
| 156 | return wv, nil | 178 | return wv, nil | ||
| 157 | } | 179 | } | ||
M internal/infrastructure/webkit/pool.go +17 -1
| @@ -67,7 +67,8 @@ type WebViewPool struct { | |||
| 67 | 67 | poolMu sync.Mutex | |
| 68 | 68 | injector *ContentInjector | |
| 69 | 69 | router *MessageRouter | |
| 70 | - | filterApplier FilterApplier // Optional content filter applier | |
| 70 | + | filterApplier FilterApplier // Optional content filter applier | |
| 71 | + | ctxMenu *contextMenuPipeline // Optional context menu pipeline | |
| 71 | 72 | ||
| 72 | 73 | // Background color for WebViews (eliminates white flash) | |
| 73 | 74 | bg bgColor | |
| @@ -145,6 +146,11 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||
| 145 | 146 | p.bg.set(r, g, b, a) | |
| 146 | 147 | } | |
| 147 | 148 | ||
| 149 | + | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |
| 150 | + | func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |
| 151 | + | p.ctxMenu = pipeline | |
| 152 | + | } | |
| 153 | + | ||
| 148 | 154 | // Acquire gets a WebView from the pool or creates a new one. | |
| 149 | 155 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |
| 150 | 156 | log := logging.FromContext(ctx) | |
| @@ -171,6 +177,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 171 | 177 | if p.filterApplier != nil { | |
| 172 | 178 | p.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 173 | 179 | } | |
| 180 | + | // Ensure context menu pipeline is set (may not have been available during prewarm) | |
| 181 | + | if p.ctxMenu != nil { | |
| 182 | + | wv.contextMenu = p.ctxMenu | |
| 183 | + | } | |
| 174 | 184 | wv.ReconnectSignals() | |
| 175 | 185 | p.acquireCount.Add(1) | |
| 176 | 186 | p.acquirePoolHits.Add(1) | |
| @@ -237,6 +247,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||
| 237 | 247 | p.filterApplier.ApplyTo(ctx, wv.ucm) | |
| 238 | 248 | } | |
| 239 | 249 | ||
| 250 | + | // Wire context menu if configured | |
| 251 | + | if p.ctxMenu != nil { | |
| 252 | + | wv.contextMenu = p.ctxMenu | |
| 253 | + | wv.connectContextMenuSignal(p.ctxMenu) | |
| 254 | + | } | |
| 255 | + | ||
| 240 | 256 | return wv, nil | |
| 241 | 257 | } | |
| 242 | 258 | ||
| @@ -67,7 +67,8 @@ type WebViewPool struct { | |||||
| 67 | poolMu sync.Mutex | 67 | poolMu sync.Mutex | ||
| 68 | injector *ContentInjector | 68 | injector *ContentInjector | ||
| 69 | router *MessageRouter | 69 | router *MessageRouter | ||
| 70 | - | filterApplier FilterApplier // Optional content filter applier | 70 | + | filterApplier FilterApplier // Optional content filter applier |
| 71 | + | ctxMenu *contextMenuPipeline // Optional context menu pipeline | |||
| 71 | 72 | ||||
| 72 | // Background color for WebViews (eliminates white flash) | 73 | // Background color for WebViews (eliminates white flash) | ||
| 73 | bg bgColor | 74 | bg bgColor | ||
| @@ -145,6 +146,11 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||||
| 145 | p.bg.set(r, g, b, a) | 146 | p.bg.set(r, g, b, a) | ||
| 146 | } | 147 | } | ||
| 147 | 148 | ||||
| 149 | + | // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. | |||
| 150 | + | func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { | |||
| 151 | + | p.ctxMenu = pipeline | |||
| 152 | + | } | |||
| 153 | + | ||||
| 148 | // Acquire gets a WebView from the pool or creates a new one. | 154 | // Acquire gets a WebView from the pool or creates a new one. | ||
| 149 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | 155 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | ||
| 150 | log := logging.FromContext(ctx) | 156 | log := logging.FromContext(ctx) | ||
| @@ -171,6 +177,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 171 | if p.filterApplier != nil { | 177 | if p.filterApplier != nil { | ||
| 172 | p.filterApplier.ApplyTo(ctx, wv.ucm) | 178 | p.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| 173 | } | 179 | } | ||
| 180 | + | // Ensure context menu pipeline is set (may not have been available during prewarm) | |||
| 181 | + | if p.ctxMenu != nil { | |||
| 182 | + | wv.contextMenu = p.ctxMenu | |||
| 183 | + | } | |||
| 174 | wv.ReconnectSignals() | 184 | wv.ReconnectSignals() | ||
| 175 | p.acquireCount.Add(1) | 185 | p.acquireCount.Add(1) | ||
| 176 | p.acquirePoolHits.Add(1) | 186 | p.acquirePoolHits.Add(1) | ||
| @@ -237,6 +247,12 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||||
| 237 | p.filterApplier.ApplyTo(ctx, wv.ucm) | 247 | p.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| 238 | } | 248 | } | ||
| 239 | 249 | ||||
| 250 | + | // Wire context menu if configured | |||
| 251 | + | if p.ctxMenu != nil { | |||
| 252 | + | wv.contextMenu = p.ctxMenu | |||
| 253 | + | wv.connectContextMenuSignal(p.ctxMenu) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 240 | return wv, nil | 256 | return wv, nil | ||
| 241 | } | 257 | } | ||
| 242 | 258 | ||||
M internal/infrastructure/webkit/webview.go +4 -0
| @@ -185,6 +185,9 @@ type WebView struct { | |||
| 185 | 185 | ||
| 186 | 186 | backForwardList *webkit.BackForwardList | |
| 187 | 187 | backForwardListSignalID uintptr | |
| 188 | + | ||
| 189 | + | // contextMenu holds the optional context menu pipeline for reconnection. | |
| 190 | + | contextMenu *contextMenuPipeline | |
| 188 | 191 | } | |
| 189 | 192 | ||
| 190 | 193 | type runJSErrorStat struct { | |
| @@ -429,6 +432,7 @@ func (wv *WebView) connectSignals() { | |||
| 429 | 432 | wv.connectBackForwardListChangedSignal() | |
| 430 | 433 | wv.connectWebProcessTerminatedSignal() | |
| 431 | 434 | wv.connectPermissionRequestSignal() | |
| 435 | + | wv.connectContextMenuSignal(wv.contextMenu) | |
| 432 | 436 | } | |
| 433 | 437 | ||
| 434 | 438 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -185,6 +185,9 @@ type WebView struct { | |||||
| 185 | 185 | ||||
| 186 | backForwardList *webkit.BackForwardList | 186 | backForwardList *webkit.BackForwardList | ||
| 187 | backForwardListSignalID uintptr | 187 | backForwardListSignalID uintptr | ||
| 188 | + | ||||
| 189 | + | // contextMenu holds the optional context menu pipeline for reconnection. | |||
| 190 | + | contextMenu *contextMenuPipeline | |||
| 188 | } | 191 | } | ||
| 189 | 192 | ||||
| 190 | type runJSErrorStat struct { | 193 | type runJSErrorStat struct { | ||
| @@ -429,6 +432,7 @@ func (wv *WebView) connectSignals() { | |||||
| 429 | wv.connectBackForwardListChangedSignal() | 432 | wv.connectBackForwardListChangedSignal() | ||
| 430 | wv.connectWebProcessTerminatedSignal() | 433 | wv.connectWebProcessTerminatedSignal() | ||
| 431 | wv.connectPermissionRequestSignal() | 434 | wv.connectPermissionRequestSignal() | ||
| 435 | + | wv.connectContextMenuSignal(wv.contextMenu) | |||
| 432 | } | 436 | } | ||
| 433 | 437 | ||||
| 434 | func (wv *WebView) connectLoadChangedSignal() { | 438 | func (wv *WebView) connectLoadChangedSignal() { | ||