detached 0545eb9

fix(context-menu): finish smoke-test regressions

Route shared actions through the working engine paths, anchor custom menus with a supported GTK host, and make image copy reliable across WebKit and CEF.
commit
0545eb92e602652b7f4d851b5c9a0bc8e059430c
author
brice · · 2026-04-14 05:09 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
e0d4a99
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +667 -47
M internal/bootstrap/engine.go +9 -5
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
1111 "github.com/bnema/dumber/internal/infrastructure/cef"
12+ clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
1213 "github.com/bnema/dumber/internal/infrastructure/config"
1314 "github.com/bnema/dumber/internal/infrastructure/env"
1415 "github.com/bnema/dumber/internal/infrastructure/handlers"
@@ -83,11 +84,14 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
8384 }
8485 }
8586 deps := cef.EngineDependencies{
86- RegisterHandlers: handlers.RegisterAll,
87- RegisterAccentHandlers: handlers.RegisterAccentHandlers,
88- CurrentConfigPayload: buildConfigPayload(config.Get),
89- DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
90- ContextMenuBuilder: contextMenuBuilder,
87+ RegisterHandlers: handlers.RegisterAll,
88+ RegisterAccentHandlers: handlers.RegisterAccentHandlers,
89+ CurrentConfigPayload: buildConfigPayload(config.Get),
90+ DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
91+ ContextMenuBuilder: contextMenuBuilder,
92+ ContextMenuExecutorFactory: contextMenuExecutorFactory,
93+ Clipboard: clipboardinfra.New(),
94+ ImageDataResolver: webkit.NewContextMenuResolver(),
9195 MediaClassifier: cef.MediaClassifier{
9296 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
9397 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"10 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
11 "github.com/bnema/dumber/internal/infrastructure/cef"11 "github.com/bnema/dumber/internal/infrastructure/cef"
12+ clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
12 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
13 "github.com/bnema/dumber/internal/infrastructure/env"14 "github.com/bnema/dumber/internal/infrastructure/env"
14 "github.com/bnema/dumber/internal/infrastructure/handlers"15 "github.com/bnema/dumber/internal/infrastructure/handlers"
@@ -83,11 +84,14 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
83 }84 }
84 }85 }
85 deps := cef.EngineDependencies{86 deps := cef.EngineDependencies{
86- RegisterHandlers: handlers.RegisterAll,87+ RegisterHandlers: handlers.RegisterAll,
87- RegisterAccentHandlers: handlers.RegisterAccentHandlers,88+ RegisterAccentHandlers: handlers.RegisterAccentHandlers,
88- CurrentConfigPayload: buildConfigPayload(config.Get),89+ CurrentConfigPayload: buildConfigPayload(config.Get),
89- DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),90+ DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
90- ContextMenuBuilder: contextMenuBuilder,91+ ContextMenuBuilder: contextMenuBuilder,
92+ ContextMenuExecutorFactory: contextMenuExecutorFactory,
93+ Clipboard: clipboardinfra.New(),
94+ ImageDataResolver: webkit.NewContextMenuResolver(),
91 MediaClassifier: cef.MediaClassifier{95 MediaClassifier: cef.MediaClassifier{
92 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,96 IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME,
93 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,97 IsOpenVideoMIME: transcoder.IsOpenVideoMIME,
M internal/infrastructure/cef/context_menu.go +138 -3
@@ -2,11 +2,14 @@ package cef
22
33 import (
44 "context"
5+ "fmt"
56 "strings"
67
78 purecef "github.com/bnema/purego-cef/cef"
89
910 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/puregotk/v4/gtk"
1013 )
1114
1215 // ===========================================================================
@@ -69,16 +72,19 @@ func (h *handlerSet) RunContextMenu(
6972 return 1
7073 }
7174
72- x := params.GetXcoord()
73- y := params.GetYcoord()
75+ x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale)
7476 glArea := h.wv.pipeline.glArea
77+ logContextMenuPopupRequest(h, glArea, params, x, y)
78+ executor := h.contextMenuExecutor()
7579
7680 NewRenderer(h.wv.runOnGTK).Show(
7781 items,
7882 &glArea.Widget,
7983 x,
8084 y,
81- func(item port.MenuItem) { dispatchContextMenuSelection(callback, commandIDByAction, item) },
85+ func(item port.MenuItem) {
86+ dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext)
87+ },
8288 func() {
8389 callback.Cancel()
8490 },
@@ -86,21 +92,150 @@ func (h *handlerSet) RunContextMenu(
8692 return 1
8793 }
8894
95+func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
96+ if h == nil || h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuExecutorFactory == nil {
97+ return nil
98+ }
99+ return h.wv.engine.ctxMenuExecutorFactory.NewContextMenuActionExecutor(
100+ h.wv.engine.clipboard,
101+ h.wv.engine.resolver,
102+ nil,
103+ &cefMenuDelegator{wv: h.wv},
104+ )
105+}
106+
107+func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) {
108+ if params == nil {
109+ return 0, 0
110+ }
111+ if scale <= 0 {
112+ scale = 1
113+ }
114+ return params.GetXcoord() / scale, params.GetYcoord() / scale
115+}
116+
117+func logContextMenuPopupRequest(
118+ h *handlerSet,
119+ glArea *gtk.GLArea,
120+ params purecef.ContextMenuParams,
121+ x, y int32,
122+) {
123+ if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {
124+ return
125+ }
126+ parent := glArea.GetParent()
127+ logging.FromContext(h.wv.ctx).Debug().
128+ Int32("raw_x", params.GetXcoord()).
129+ Int32("raw_y", params.GetYcoord()).
130+ Int32("popup_x", x).
131+ Int32("popup_y", y).
132+ Int32("scale", h.wv.pipeline.scale).
133+ Int("anchor_width", glArea.Widget.GetAllocatedWidth()).
134+ Int("anchor_height", glArea.Widget.GetAllocatedHeight()).
135+ Int("parent_width", cefWidgetAllocatedWidth(parent)).
136+ Int("parent_height", cefWidgetAllocatedHeight(parent)).
137+ Msg("cef: context menu popup request")
138+}
139+
140+func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
141+ if widget == nil {
142+ return 0
143+ }
144+ return widget.GetAllocatedWidth()
145+}
146+
147+func cefWidgetAllocatedHeight(widget *gtk.Widget) int {
148+ if widget == nil {
149+ return 0
150+ }
151+ return widget.GetAllocatedHeight()
152+}
153+
89154 func dispatchContextMenuSelection(
155+ ctx context.Context,
156+ executor port.ContextMenuActionExecutor,
90157 callback purecef.RunContextMenuCallback,
91158 commandIDByAction map[port.MenuAction]int32,
92159 item port.MenuItem,
160+ menuContext port.MenuContext,
93161 ) {
162+ log := logging.FromContext(ctx)
94163 if callback == nil {
95164 return
96165 }
166+ log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
167+ if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
168+ if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
169+ log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
170+ callback.Cancel()
171+ return
172+ }
173+ log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
174+ return
175+ }
97176 if cmdID, ok := commandIDByAction[item.Action]; ok {
177+ log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
98178 callback.Cont(cmdID, 0)
99179 return
100180 }
181+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
101182 callback.Cancel()
102183 }
103184
185+func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
186+ switch action {
187+ case port.MenuActionBack,
188+ port.MenuActionForward,
189+ port.MenuActionReload,
190+ port.MenuActionOpenLinkNewTab,
191+ port.MenuActionCopyLink,
192+ port.MenuActionCopyImage,
193+ port.MenuActionInspectElement,
194+ port.MenuActionCopySelection:
195+ return true
196+ default:
197+ return false
198+ }
199+}
200+
201+type cefMenuDelegator struct {
202+ wv *WebView
203+}
204+
205+func (d *cefMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
206+ if d == nil || d.wv == nil {
207+ return fmt.Errorf("cef menu delegator: webview not available")
208+ }
209+ switch action {
210+ case port.MenuActionBack:
211+ return d.wv.GoBack(ctx)
212+ case port.MenuActionForward:
213+ return d.wv.GoForward(ctx)
214+ case port.MenuActionReload:
215+ return d.wv.Reload(ctx)
216+ case port.MenuActionOpenLinkNewTab:
217+ if menuContext.LinkURI == "" {
218+ return fmt.Errorf("open link in new tab: link URI not available")
219+ }
220+ d.wv.mu.RLock()
221+ cb := d.wv.callbacks
222+ d.wv.mu.RUnlock()
223+ if cb == nil || cb.OnLinkMiddleClick == nil {
224+ return fmt.Errorf("open link in new tab: middle-click handler not available")
225+ }
226+ cb.OnLinkMiddleClick(menuContext.LinkURI)
227+ return nil
228+ case port.MenuActionInspectElement:
229+ d.wv.OpenDevTools()
230+ return nil
231+ case port.MenuActionCopySelection:
232+ d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
233+ return nil
234+ default:
235+ return fmt.Errorf("cef menu delegator: unsupported action %s", action)
236+ }
237+}
238+
104239 func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
105240 for _, candidate := range contextMenuActionLabels(action, label) {
106241 if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
@@ -2,11 +2,14 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "strings"6 "strings"
6 7
7 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/puregotk/v4/gtk"
10 )13 )
11 14
12 // ===========================================================================15 // ===========================================================================
@@ -69,16 +72,19 @@ func (h *handlerSet) RunContextMenu(
69 return 172 return 1
70 }73 }
71 74
72- x := params.GetXcoord()75+ x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale)
73- y := params.GetYcoord()
74 glArea := h.wv.pipeline.glArea76 glArea := h.wv.pipeline.glArea
77+ logContextMenuPopupRequest(h, glArea, params, x, y)
78+ executor := h.contextMenuExecutor()
75 79
76 NewRenderer(h.wv.runOnGTK).Show(80 NewRenderer(h.wv.runOnGTK).Show(
77 items,81 items,
78 &glArea.Widget,82 &glArea.Widget,
79 x,83 x,
80 y,84 y,
81- func(item port.MenuItem) { dispatchContextMenuSelection(callback, commandIDByAction, item) },85+ func(item port.MenuItem) {
86+ dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext)
87+ },
82 func() {88 func() {
83 callback.Cancel()89 callback.Cancel()
84 },90 },
@@ -86,21 +92,150 @@ func (h *handlerSet) RunContextMenu(
86 return 192 return 1
87 }93 }
88 94
95+func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
96+ if h == nil || h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuExecutorFactory == nil {
97+ return nil
98+ }
99+ return h.wv.engine.ctxMenuExecutorFactory.NewContextMenuActionExecutor(
100+ h.wv.engine.clipboard,
101+ h.wv.engine.resolver,
102+ nil,
103+ &cefMenuDelegator{wv: h.wv},
104+ )
105+}
106+
107+func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) {
108+ if params == nil {
109+ return 0, 0
110+ }
111+ if scale <= 0 {
112+ scale = 1
113+ }
114+ return params.GetXcoord() / scale, params.GetYcoord() / scale
115+}
116+
117+func logContextMenuPopupRequest(
118+ h *handlerSet,
119+ glArea *gtk.GLArea,
120+ params purecef.ContextMenuParams,
121+ x, y int32,
122+) {
123+ if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {
124+ return
125+ }
126+ parent := glArea.GetParent()
127+ logging.FromContext(h.wv.ctx).Debug().
128+ Int32("raw_x", params.GetXcoord()).
129+ Int32("raw_y", params.GetYcoord()).
130+ Int32("popup_x", x).
131+ Int32("popup_y", y).
132+ Int32("scale", h.wv.pipeline.scale).
133+ Int("anchor_width", glArea.Widget.GetAllocatedWidth()).
134+ Int("anchor_height", glArea.Widget.GetAllocatedHeight()).
135+ Int("parent_width", cefWidgetAllocatedWidth(parent)).
136+ Int("parent_height", cefWidgetAllocatedHeight(parent)).
137+ Msg("cef: context menu popup request")
138+}
139+
140+func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
141+ if widget == nil {
142+ return 0
143+ }
144+ return widget.GetAllocatedWidth()
145+}
146+
147+func cefWidgetAllocatedHeight(widget *gtk.Widget) int {
148+ if widget == nil {
149+ return 0
150+ }
151+ return widget.GetAllocatedHeight()
152+}
153+
89 func dispatchContextMenuSelection(154 func dispatchContextMenuSelection(
155+ ctx context.Context,
156+ executor port.ContextMenuActionExecutor,
90 callback purecef.RunContextMenuCallback,157 callback purecef.RunContextMenuCallback,
91 commandIDByAction map[port.MenuAction]int32,158 commandIDByAction map[port.MenuAction]int32,
92 item port.MenuItem,159 item port.MenuItem,
160+ menuContext port.MenuContext,
93 ) {161 ) {
162+ log := logging.FromContext(ctx)
94 if callback == nil {163 if callback == nil {
95 return164 return
96 }165 }
166+ log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
167+ if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
168+ if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
169+ log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
170+ callback.Cancel()
171+ return
172+ }
173+ log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
174+ return
175+ }
97 if cmdID, ok := commandIDByAction[item.Action]; ok {176 if cmdID, ok := commandIDByAction[item.Action]; ok {
177+ log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
98 callback.Cont(cmdID, 0)178 callback.Cont(cmdID, 0)
99 return179 return
100 }180 }
181+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
101 callback.Cancel()182 callback.Cancel()
102 }183 }
103 184
185+func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
186+ switch action {
187+ case port.MenuActionBack,
188+ port.MenuActionForward,
189+ port.MenuActionReload,
190+ port.MenuActionOpenLinkNewTab,
191+ port.MenuActionCopyLink,
192+ port.MenuActionCopyImage,
193+ port.MenuActionInspectElement,
194+ port.MenuActionCopySelection:
195+ return true
196+ default:
197+ return false
198+ }
199+}
200+
201+type cefMenuDelegator struct {
202+ wv *WebView
203+}
204+
205+func (d *cefMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
206+ if d == nil || d.wv == nil {
207+ return fmt.Errorf("cef menu delegator: webview not available")
208+ }
209+ switch action {
210+ case port.MenuActionBack:
211+ return d.wv.GoBack(ctx)
212+ case port.MenuActionForward:
213+ return d.wv.GoForward(ctx)
214+ case port.MenuActionReload:
215+ return d.wv.Reload(ctx)
216+ case port.MenuActionOpenLinkNewTab:
217+ if menuContext.LinkURI == "" {
218+ return fmt.Errorf("open link in new tab: link URI not available")
219+ }
220+ d.wv.mu.RLock()
221+ cb := d.wv.callbacks
222+ d.wv.mu.RUnlock()
223+ if cb == nil || cb.OnLinkMiddleClick == nil {
224+ return fmt.Errorf("open link in new tab: middle-click handler not available")
225+ }
226+ cb.OnLinkMiddleClick(menuContext.LinkURI)
227+ return nil
228+ case port.MenuActionInspectElement:
229+ d.wv.OpenDevTools()
230+ return nil
231+ case port.MenuActionCopySelection:
232+ d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
233+ return nil
234+ default:
235+ return fmt.Errorf("cef menu delegator: unsupported action %s", action)
236+ }
237+}
238+
104 func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {239 func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
105 for _, candidate := range contextMenuActionLabels(action, label) {240 for _, candidate := range contextMenuActionLabels(action, label) {
106 if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {241 if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
M internal/infrastructure/cef/context_menu_adapter_test.go +68 -4
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
67 purecef "github.com/bnema/purego-cef/cef"
@@ -77,6 +78,20 @@ type stubRunContextMenuCallback struct {
7778 commandID int32
7879 }
7980
81+type stubContextMenuExecutor struct {
82+ executeCalls int
83+ action port.MenuAction
84+ menuContext port.MenuContext
85+ err error
86+}
87+
88+func (s *stubContextMenuExecutor) ExecuteMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error {
89+ s.executeCalls++
90+ s.action = action
91+ s.menuContext = menuContext
92+ return s.err
93+}
94+
8095 func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) {
8196 c.contCalls++
8297 c.commandID = commandID
@@ -89,9 +104,9 @@ func (c *stubRunContextMenuCallback) Cancel() {
89104 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
90105 callback := &stubRunContextMenuCallback{}
91106
92- dispatchContextMenuSelection(callback, map[port.MenuAction]int32{
107+ dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
93108 port.MenuActionReload: 102,
94- }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
109+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
95110
96111 require.Zero(t, callback.contCalls)
97112 require.Equal(t, 1, callback.cancelCalls)
@@ -100,11 +115,60 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
100115 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
101116 callback := &stubRunContextMenuCallback{}
102117
103- dispatchContextMenuSelection(callback, map[port.MenuAction]int32{
118+ dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
104119 port.MenuActionInspectElement: 204,
105- }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})
120+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
106121
107122 require.Equal(t, 1, callback.contCalls)
108123 require.Zero(t, callback.cancelCalls)
109124 require.Equal(t, int32(204), callback.commandID)
110125 }
126+
127+func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testing.T) {
128+ callback := &stubRunContextMenuCallback{}
129+ executor := &stubContextMenuExecutor{}
130+ menuContext := port.MenuContext{PageURI: "https://example.com"}
131+
132+ dispatchContextMenuSelection(
133+ context.Background(),
134+ executor,
135+ callback,
136+ map[port.MenuAction]int32{port.MenuActionReload: 102},
137+ port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
138+ menuContext,
139+ )
140+
141+ require.Equal(t, 1, executor.executeCalls)
142+ require.Equal(t, port.MenuActionReload, executor.action)
143+ require.Equal(t, menuContext, executor.menuContext)
144+ require.Zero(t, callback.contCalls)
145+ require.Zero(t, callback.cancelCalls)
146+}
147+
148+func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) {
149+ callback := &stubRunContextMenuCallback{}
150+ executor := &stubContextMenuExecutor{}
151+ menuContext := port.MenuContext{ImageURI: "https://example.com/image.png"}
152+
153+ dispatchContextMenuSelection(
154+ context.Background(),
155+ executor,
156+ callback,
157+ map[port.MenuAction]int32{},
158+ port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
159+ menuContext,
160+ )
161+
162+ require.Equal(t, 1, executor.executeCalls)
163+ require.Equal(t, port.MenuActionCopyImage, executor.action)
164+ require.Equal(t, menuContext, executor.menuContext)
165+ require.Zero(t, callback.contCalls)
166+ require.Zero(t, callback.cancelCalls)
167+}
168+
169+func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
170+ x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 2)
171+
172+ require.Equal(t, int32(160), x)
173+ require.Equal(t, int32(90), y)
174+}
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
@@ -77,6 +78,20 @@ type stubRunContextMenuCallback struct {
77 commandID int3278 commandID int32
78 }79 }
79 80
81+type stubContextMenuExecutor struct {
82+ executeCalls int
83+ action port.MenuAction
84+ menuContext port.MenuContext
85+ err error
86+}
87+
88+func (s *stubContextMenuExecutor) ExecuteMenuAction(_ context.Context, action port.MenuAction, menuContext port.MenuContext) error {
89+ s.executeCalls++
90+ s.action = action
91+ s.menuContext = menuContext
92+ return s.err
93+}
94+
80 func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) {95 func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) {
81 c.contCalls++96 c.contCalls++
82 c.commandID = commandID97 c.commandID = commandID
@@ -89,9 +104,9 @@ func (c *stubRunContextMenuCallback) Cancel() {
89 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {104 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
90 callback := &stubRunContextMenuCallback{}105 callback := &stubRunContextMenuCallback{}
91 106
92- dispatchContextMenuSelection(callback, map[port.MenuAction]int32{107+ dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
93 port.MenuActionReload: 102,108 port.MenuActionReload: 102,
94- }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})109+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
95 110
96 require.Zero(t, callback.contCalls)111 require.Zero(t, callback.contCalls)
97 require.Equal(t, 1, callback.cancelCalls)112 require.Equal(t, 1, callback.cancelCalls)
@@ -100,11 +115,60 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
100 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {115 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
101 callback := &stubRunContextMenuCallback{}116 callback := &stubRunContextMenuCallback{}
102 117
103- dispatchContextMenuSelection(callback, map[port.MenuAction]int32{118+ dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
104 port.MenuActionInspectElement: 204,119 port.MenuActionInspectElement: 204,
105- }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"})120+ }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
106 121
107 require.Equal(t, 1, callback.contCalls)122 require.Equal(t, 1, callback.contCalls)
108 require.Zero(t, callback.cancelCalls)123 require.Zero(t, callback.cancelCalls)
109 require.Equal(t, int32(204), callback.commandID)124 require.Equal(t, int32(204), callback.commandID)
110 }125 }
126+
127+func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testing.T) {
128+ callback := &stubRunContextMenuCallback{}
129+ executor := &stubContextMenuExecutor{}
130+ menuContext := port.MenuContext{PageURI: "https://example.com"}
131+
132+ dispatchContextMenuSelection(
133+ context.Background(),
134+ executor,
135+ callback,
136+ map[port.MenuAction]int32{port.MenuActionReload: 102},
137+ port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
138+ menuContext,
139+ )
140+
141+ require.Equal(t, 1, executor.executeCalls)
142+ require.Equal(t, port.MenuActionReload, executor.action)
143+ require.Equal(t, menuContext, executor.menuContext)
144+ require.Zero(t, callback.contCalls)
145+ require.Zero(t, callback.cancelCalls)
146+}
147+
148+func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) {
149+ callback := &stubRunContextMenuCallback{}
150+ executor := &stubContextMenuExecutor{}
151+ menuContext := port.MenuContext{ImageURI: "https://example.com/image.png"}
152+
153+ dispatchContextMenuSelection(
154+ context.Background(),
155+ executor,
156+ callback,
157+ map[port.MenuAction]int32{},
158+ port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
159+ menuContext,
160+ )
161+
162+ require.Equal(t, 1, executor.executeCalls)
163+ require.Equal(t, port.MenuActionCopyImage, executor.action)
164+ require.Equal(t, menuContext, executor.menuContext)
165+ require.Zero(t, callback.contCalls)
166+ require.Zero(t, callback.cancelCalls)
167+}
168+
169+func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
170+ x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 2)
171+
172+ require.Equal(t, int32(160), x)
173+ require.Equal(t, int32(90), y)
174+}
M internal/infrastructure/cef/engine.go +3 -0
@@ -32,6 +32,9 @@ type Engine struct {
3232 currentConfigPayload func() ([]byte, error)
3333 defaultConfigPayload func() ([]byte, error)
3434 ctxMenuBuilder port.ContextMenuBuilder
35+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
36+ clipboard port.Clipboard
37+ resolver port.ImageDataResolver
3538 mediaClassifier MediaClassifier
3639
3740 // activeWebViews tracks all live webviews for CSS broadcast.
@@ -32,6 +32,9 @@ type Engine struct {
32 currentConfigPayload func() ([]byte, error)32 currentConfigPayload func() ([]byte, error)
33 defaultConfigPayload func() ([]byte, error)33 defaultConfigPayload func() ([]byte, error)
34 ctxMenuBuilder port.ContextMenuBuilder34 ctxMenuBuilder port.ContextMenuBuilder
35+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
36+ clipboard port.Clipboard
37+ resolver port.ImageDataResolver
35 mediaClassifier MediaClassifier38 mediaClassifier MediaClassifier
36 39
37 // activeWebViews tracks all live webviews for CSS broadcast.40 // activeWebViews tracks all live webviews for CSS broadcast.
M internal/infrastructure/cef/engine_init.go +3 -0
@@ -55,6 +55,9 @@ func NewEngine(
5555 currentConfigPayload: deps.CurrentConfigPayload,
5656 defaultConfigPayload: deps.DefaultConfigPayload,
5757 ctxMenuBuilder: deps.ContextMenuBuilder,
58+ ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
59+ clipboard: deps.Clipboard,
60+ resolver: deps.ImageDataResolver,
5861 mediaClassifier: deps.MediaClassifier,
5962 }
6063
@@ -55,6 +55,9 @@ func NewEngine(
55 currentConfigPayload: deps.CurrentConfigPayload,55 currentConfigPayload: deps.CurrentConfigPayload,
56 defaultConfigPayload: deps.DefaultConfigPayload,56 defaultConfigPayload: deps.DefaultConfigPayload,
57 ctxMenuBuilder: deps.ContextMenuBuilder,57 ctxMenuBuilder: deps.ContextMenuBuilder,
58+ ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
59+ clipboard: deps.Clipboard,
60+ resolver: deps.ImageDataResolver,
58 mediaClassifier: deps.MediaClassifier,61 mediaClassifier: deps.MediaClassifier,
59 }62 }
60 63
M internal/infrastructure/cef/renderer.go +89 -4
@@ -83,6 +83,8 @@ func showMenu(
8383
8484 selected := false
8585 var popover *gtk.Popover
86+ parent := choosePopoverParent(anchor, anchor.GetParent())
87+ popoverHost, overlay := createPopoverHost(parent, x, y)
8688
8789 for _, item := range items {
8890 if item.separator {
@@ -111,21 +113,104 @@ func showMenu(
111113
112114 popover = gtk.NewPopover()
113115 popover.SetChild(&box.Widget)
114- popover.SetParent(anchor)
116+ attachPopover(popover, popoverHost, parent)
115117 popover.SetHasArrow(false)
116118 popover.SetAutohide(true)
117119
118- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
119- popover.SetPointingTo(rect)
120+ if popoverHost == nil {
121+ rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122+ if anchor == nil || parent == nil || anchor == parent {
123+ return srcX, srcY, false
124+ }
125+ var destX, destY float64
126+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127+ return srcX, srcY, false
128+ }
129+ return destX, destY, true
130+ })
131+ popover.SetPointingTo(rect)
132+ }
120133
121134 closedCb := func(_ gtk.Popover) {
122135 if !selected && onClose != nil {
123136 onClose()
124137 }
125- popover.Unparent()
138+ cleanupPopover(popover, popoverHost, overlay)
126139 }
127140 popover.ConnectClosed(&closedCb)
128141
142+ popupPopover(popover, popoverHost)
143+}
144+
145+func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146+ if parent != nil {
147+ return parent
148+ }
149+ return anchor
150+}
151+
152+func popoverPointingRect(
153+ x, y int32,
154+ translate func(srcX, srcY float64) (float64, float64, bool),
155+) *gdk.Rectangle {
156+ destX, destY := float64(x), float64(y)
157+ if translate != nil {
158+ if tx, ty, ok := translate(destX, destY); ok {
159+ destX, destY = tx, ty
160+ }
161+ }
162+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163+}
164+
165+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166+ overlay := overlayFromWidget(parent)
167+ if overlay == nil {
168+ return nil, nil
169+ }
170+ host := gtk.NewMenuButton()
171+ host.SetCanFocus(false)
172+ host.SetHalign(gtk.AlignStartValue)
173+ host.SetValign(gtk.AlignStartValue)
174+ host.SetMarginStart(int(x))
175+ host.SetMarginTop(int(y))
176+ host.SetOpacity(0)
177+ host.SetSizeRequest(1, 1)
178+ host.SetVisible(true)
179+ overlay.AddOverlay(&host.Widget)
180+ overlay.SetClipOverlay(&host.Widget, false)
181+ overlay.SetMeasureOverlay(&host.Widget, false)
182+ return host, overlay
183+}
184+
185+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186+ if widget == nil || widget.GoPointer() == 0 {
187+ return nil
188+ }
189+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190+}
191+
192+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193+ if host != nil {
194+ host.SetPopover(popover)
195+ return
196+ }
197+ popover.SetParent(parent)
198+}
199+
200+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201+ if host != nil && overlay != nil {
202+ host.SetPopover(nil)
203+ overlay.RemoveOverlay(&host.Widget)
204+ return
205+ }
206+ popover.Unparent()
207+}
208+
209+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210+ if host != nil {
211+ host.Popup()
212+ return
213+ }
129214 popover.Popup()
130215 }
131216
@@ -83,6 +83,8 @@ func showMenu(
83 83
84 selected := false84 selected := false
85 var popover *gtk.Popover85 var popover *gtk.Popover
86+ parent := choosePopoverParent(anchor, anchor.GetParent())
87+ popoverHost, overlay := createPopoverHost(parent, x, y)
86 88
87 for _, item := range items {89 for _, item := range items {
88 if item.separator {90 if item.separator {
@@ -111,21 +113,104 @@ func showMenu(
111 113
112 popover = gtk.NewPopover()114 popover = gtk.NewPopover()
113 popover.SetChild(&box.Widget)115 popover.SetChild(&box.Widget)
114- popover.SetParent(anchor)116+ attachPopover(popover, popoverHost, parent)
115 popover.SetHasArrow(false)117 popover.SetHasArrow(false)
116 popover.SetAutohide(true)118 popover.SetAutohide(true)
117 119
118- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}120+ if popoverHost == nil {
119- popover.SetPointingTo(rect)121+ rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122+ if anchor == nil || parent == nil || anchor == parent {
123+ return srcX, srcY, false
124+ }
125+ var destX, destY float64
126+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127+ return srcX, srcY, false
128+ }
129+ return destX, destY, true
130+ })
131+ popover.SetPointingTo(rect)
132+ }
120 133
121 closedCb := func(_ gtk.Popover) {134 closedCb := func(_ gtk.Popover) {
122 if !selected && onClose != nil {135 if !selected && onClose != nil {
123 onClose()136 onClose()
124 }137 }
125- popover.Unparent()138+ cleanupPopover(popover, popoverHost, overlay)
126 }139 }
127 popover.ConnectClosed(&closedCb)140 popover.ConnectClosed(&closedCb)
128 141
142+ popupPopover(popover, popoverHost)
143+}
144+
145+func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146+ if parent != nil {
147+ return parent
148+ }
149+ return anchor
150+}
151+
152+func popoverPointingRect(
153+ x, y int32,
154+ translate func(srcX, srcY float64) (float64, float64, bool),
155+) *gdk.Rectangle {
156+ destX, destY := float64(x), float64(y)
157+ if translate != nil {
158+ if tx, ty, ok := translate(destX, destY); ok {
159+ destX, destY = tx, ty
160+ }
161+ }
162+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163+}
164+
165+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166+ overlay := overlayFromWidget(parent)
167+ if overlay == nil {
168+ return nil, nil
169+ }
170+ host := gtk.NewMenuButton()
171+ host.SetCanFocus(false)
172+ host.SetHalign(gtk.AlignStartValue)
173+ host.SetValign(gtk.AlignStartValue)
174+ host.SetMarginStart(int(x))
175+ host.SetMarginTop(int(y))
176+ host.SetOpacity(0)
177+ host.SetSizeRequest(1, 1)
178+ host.SetVisible(true)
179+ overlay.AddOverlay(&host.Widget)
180+ overlay.SetClipOverlay(&host.Widget, false)
181+ overlay.SetMeasureOverlay(&host.Widget, false)
182+ return host, overlay
183+}
184+
185+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186+ if widget == nil || widget.GoPointer() == 0 {
187+ return nil
188+ }
189+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190+}
191+
192+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193+ if host != nil {
194+ host.SetPopover(popover)
195+ return
196+ }
197+ popover.SetParent(parent)
198+}
199+
200+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201+ if host != nil && overlay != nil {
202+ host.SetPopover(nil)
203+ overlay.RemoveOverlay(&host.Widget)
204+ return
205+ }
206+ popover.Unparent()
207+}
208+
209+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210+ if host != nil {
211+ host.Popup()
212+ return
213+ }
129 popover.Popup()214 popover.Popup()
130 }215 }
131 216
A internal/infrastructure/cef/renderer_test.go +40 -0
@@ -0,0 +1,40 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestChoosePopoverParent(t *testing.T) {
11+ anchor := &gtk.Widget{}
12+ parent := &gtk.Widget{}
13+
14+ require.Same(t, parent, choosePopoverParent(anchor, parent))
15+ require.Same(t, anchor, choosePopoverParent(anchor, nil))
16+}
17+
18+func TestPopoverPointingRect(t *testing.T) {
19+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
20+ rect := popoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
21+ require.InDelta(t, 64.0, srcX, 0.0001)
22+ require.InDelta(t, 96.0, srcY, 0.0001)
23+ return 24, 48, true
24+ })
25+
26+ require.Equal(t, 24, rect.X)
27+ require.Equal(t, 48, rect.Y)
28+ require.Equal(t, 1, rect.Width)
29+ require.Equal(t, 1, rect.Height)
30+ })
31+
32+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
33+ rect := popoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
34+ return srcX, srcY, false
35+ })
36+
37+ require.Equal(t, 10, rect.X)
38+ require.Equal(t, 20, rect.Y)
39+ })
40+}
@@ -0,0 +1,40 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestChoosePopoverParent(t *testing.T) {
11+ anchor := &gtk.Widget{}
12+ parent := &gtk.Widget{}
13+
14+ require.Same(t, parent, choosePopoverParent(anchor, parent))
15+ require.Same(t, anchor, choosePopoverParent(anchor, nil))
16+}
17+
18+func TestPopoverPointingRect(t *testing.T) {
19+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
20+ rect := popoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
21+ require.InDelta(t, 64.0, srcX, 0.0001)
22+ require.InDelta(t, 96.0, srcY, 0.0001)
23+ return 24, 48, true
24+ })
25+
26+ require.Equal(t, 24, rect.X)
27+ require.Equal(t, 48, rect.Y)
28+ require.Equal(t, 1, rect.Width)
29+ require.Equal(t, 1, rect.Height)
30+ })
31+
32+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
33+ rect := popoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
34+ return srcX, srcY, false
35+ })
36+
37+ require.Equal(t, 10, rect.X)
38+ require.Equal(t, 20, rect.Y)
39+ })
40+}
M internal/infrastructure/cef/runtime_config.go +9 -6
@@ -65,10 +65,13 @@ func (m MediaClassifier) normalize() MediaClassifier {
6565 }
6666
6767 type EngineDependencies struct {
68- RegisterHandlers HandlerRegistrar
69- RegisterAccentHandlers AccentHandlerRegistrar
70- CurrentConfigPayload func() ([]byte, error)
71- DefaultConfigPayload func() ([]byte, error)
72- ContextMenuBuilder port.ContextMenuBuilder
73- MediaClassifier MediaClassifier
68+ RegisterHandlers HandlerRegistrar
69+ RegisterAccentHandlers AccentHandlerRegistrar
70+ CurrentConfigPayload func() ([]byte, error)
71+ DefaultConfigPayload func() ([]byte, error)
72+ ContextMenuBuilder port.ContextMenuBuilder
73+ ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
74+ Clipboard port.Clipboard
75+ ImageDataResolver port.ImageDataResolver
76+ MediaClassifier MediaClassifier
7477 }
@@ -65,10 +65,13 @@ func (m MediaClassifier) normalize() MediaClassifier {
65 }65 }
66 66
67 type EngineDependencies struct {67 type EngineDependencies struct {
68- RegisterHandlers HandlerRegistrar68+ RegisterHandlers HandlerRegistrar
69- RegisterAccentHandlers AccentHandlerRegistrar69+ RegisterAccentHandlers AccentHandlerRegistrar
70- CurrentConfigPayload func() ([]byte, error)70+ CurrentConfigPayload func() ([]byte, error)
71- DefaultConfigPayload func() ([]byte, error)71+ DefaultConfigPayload func() ([]byte, error)
72- ContextMenuBuilder port.ContextMenuBuilder72+ ContextMenuBuilder port.ContextMenuBuilder
73- MediaClassifier MediaClassifier73+ ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
74+ Clipboard port.Clipboard
75+ ImageDataResolver port.ImageDataResolver
76+ MediaClassifier MediaClassifier
74 }77 }
M internal/infrastructure/clipboard/clipboard.go +23 -9
@@ -30,6 +30,16 @@ type Adapter struct {
3030 pasteCmd string
3131 }
3232
33+func (a *Adapter) ensureToolkitClipboard() toolkitClipboard {
34+ if a == nil {
35+ return nil
36+ }
37+ if a.toolkit == nil {
38+ a.toolkit = newToolkitClipboard()
39+ }
40+ return a.toolkit
41+}
42+
3343 var (
3444 lookPath = exec.LookPath
3545 commandContext = exec.CommandContext
@@ -147,8 +157,8 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
147157 }
148158 commandErr = err
149159 }
150- if a.toolkit != nil {
151- err := a.toolkit.WriteText(ctx, text)
160+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
161+ err := toolkit.WriteText(ctx, text)
152162 if err == nil {
153163 log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
154164 return nil
@@ -175,20 +185,24 @@ func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error
175185 log.Error().Err(err).Msg("clipboard image write failed")
176186 return err
177187 }
178- if a.copyCmd != "" {
179- err := a.writeImageWithCommand(ctx, image, log)
188+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
189+ err := toolkit.WriteImage(ctx, image)
180190 if err == nil {
191+ log.Debug().
192+ Str("backend", "toolkit").
193+ Str("mime_type", image.MimeType).
194+ Int("bytes", len(image.Bytes)).
195+ Msg("clipboard image write success")
181196 return nil
182197 }
183- commandErr = err
198+ log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
184199 }
185- if a.toolkit != nil {
186- err := a.toolkit.WriteImage(ctx, image)
200+ if a.copyCmd != "" {
201+ err := a.writeImageWithCommand(ctx, image, log)
187202 if err == nil {
188- log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
189203 return nil
190204 }
191- log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
205+ commandErr = err
192206 }
193207 if commandErr != nil {
194208 return commandErr
@@ -30,6 +30,16 @@ type Adapter struct {
30 pasteCmd string30 pasteCmd string
31 }31 }
32 32
33+func (a *Adapter) ensureToolkitClipboard() toolkitClipboard {
34+ if a == nil {
35+ return nil
36+ }
37+ if a.toolkit == nil {
38+ a.toolkit = newToolkitClipboard()
39+ }
40+ return a.toolkit
41+}
42+
33 var (43 var (
34 lookPath = exec.LookPath44 lookPath = exec.LookPath
35 commandContext = exec.CommandContext45 commandContext = exec.CommandContext
@@ -147,8 +157,8 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error {
147 }157 }
148 commandErr = err158 commandErr = err
149 }159 }
150- if a.toolkit != nil {160+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
151- err := a.toolkit.WriteText(ctx, text)161+ err := toolkit.WriteText(ctx, text)
152 if err == nil {162 if err == nil {
153 log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")163 log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
154 return nil164 return nil
@@ -175,20 +185,24 @@ func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error
175 log.Error().Err(err).Msg("clipboard image write failed")185 log.Error().Err(err).Msg("clipboard image write failed")
176 return err186 return err
177 }187 }
178- if a.copyCmd != "" {188+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
179- err := a.writeImageWithCommand(ctx, image, log)189+ err := toolkit.WriteImage(ctx, image)
180 if err == nil {190 if err == nil {
191+ log.Debug().
192+ Str("backend", "toolkit").
193+ Str("mime_type", image.MimeType).
194+ Int("bytes", len(image.Bytes)).
195+ Msg("clipboard image write success")
181 return nil196 return nil
182 }197 }
183- commandErr = err198+ log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
184 }199 }
185- if a.toolkit != nil {200+ if a.copyCmd != "" {
186- err := a.toolkit.WriteImage(ctx, image)201+ err := a.writeImageWithCommand(ctx, image, log)
187 if err == nil {202 if err == nil {
188- log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
189 return nil203 return nil
190 }204 }
191- log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")205+ commandErr = err
192 }206 }
193 if commandErr != nil {207 if commandErr != nil {
194 return commandErr208 return commandErr
M internal/infrastructure/clipboard/clipboard_test.go +28 -5
@@ -56,20 +56,43 @@ func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T)
5656 require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
5757 }
5858
59-func TestAdapter_WriteImagePrefersSystemClipboardOverToolkit(t *testing.T) {
59+func TestAdapter_WriteImagePrefersToolkitClipboardWhenAvailable(t *testing.T) {
60+ oldToolkitFactory := newToolkitClipboard
61+ t.Cleanup(func() {
62+ newToolkitClipboard = oldToolkitFactory
63+ })
64+
6065 dir := t.TempDir()
6166 scriptPath := filepath.Join(dir, "wl-copy")
62- argsPath := filepath.Join(dir, "args.txt")
6367 stdinPath := filepath.Join(dir, "stdin.bin")
64- script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
68+ script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
6569 require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
6670
6771 fake := &fakeToolkitClipboard{}
68- adapter := &Adapter{toolkit: fake, copyCmd: scriptPath}
72+ newToolkitClipboard = func() toolkitClipboard { return fake }
73+ adapter := &Adapter{copyCmd: scriptPath}
74+ image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
75+
76+ require.NoError(t, adapter.WriteImage(context.Background(), image))
77+ require.Equal(t, 1, fake.writeImageCalls)
78+ require.Equal(t, image, fake.image)
79+
80+ _, err := os.Stat(stdinPath)
81+ require.Error(t, err)
82+ require.True(t, os.IsNotExist(err))
83+}
84+
85+func TestAdapter_WriteImageFallsBackToSystemClipboardWhenToolkitUnavailable(t *testing.T) {
86+ dir := t.TempDir()
87+ scriptPath := filepath.Join(dir, "wl-copy")
88+ stdinPath := filepath.Join(dir, "stdin.bin")
89+ script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
90+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
91+
92+ adapter := &Adapter{copyCmd: scriptPath}
6993 image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
7094
7195 require.NoError(t, adapter.WriteImage(context.Background(), image))
72- require.Zero(t, fake.writeImageCalls)
7396
7497 stdin, err := os.ReadFile(stdinPath)
7598 require.NoError(t, err)
@@ -56,20 +56,43 @@ func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T)
56 require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)56 require.Equal(t, []byte{1, 2, 3}, fake.image.Bytes)
57 }57 }
58 58
59-func TestAdapter_WriteImagePrefersSystemClipboardOverToolkit(t *testing.T) {59+func TestAdapter_WriteImagePrefersToolkitClipboardWhenAvailable(t *testing.T) {
60+ oldToolkitFactory := newToolkitClipboard
61+ t.Cleanup(func() {
62+ newToolkitClipboard = oldToolkitFactory
63+ })
64+
60 dir := t.TempDir()65 dir := t.TempDir()
61 scriptPath := filepath.Join(dir, "wl-copy")66 scriptPath := filepath.Join(dir, "wl-copy")
62- argsPath := filepath.Join(dir, "args.txt")
63 stdinPath := filepath.Join(dir, "stdin.bin")67 stdinPath := filepath.Join(dir, "stdin.bin")
64- script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"68+ script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
65 require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))69 require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
66 70
67 fake := &fakeToolkitClipboard{}71 fake := &fakeToolkitClipboard{}
68- adapter := &Adapter{toolkit: fake, copyCmd: scriptPath}72+ newToolkitClipboard = func() toolkitClipboard { return fake }
73+ adapter := &Adapter{copyCmd: scriptPath}
74+ image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
75+
76+ require.NoError(t, adapter.WriteImage(context.Background(), image))
77+ require.Equal(t, 1, fake.writeImageCalls)
78+ require.Equal(t, image, fake.image)
79+
80+ _, err := os.Stat(stdinPath)
81+ require.Error(t, err)
82+ require.True(t, os.IsNotExist(err))
83+}
84+
85+func TestAdapter_WriteImageFallsBackToSystemClipboardWhenToolkitUnavailable(t *testing.T) {
86+ dir := t.TempDir()
87+ scriptPath := filepath.Join(dir, "wl-copy")
88+ stdinPath := filepath.Join(dir, "stdin.bin")
89+ script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
90+ require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
91+
92+ adapter := &Adapter{copyCmd: scriptPath}
69 image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}93 image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
70 94
71 require.NoError(t, adapter.WriteImage(context.Background(), image))95 require.NoError(t, adapter.WriteImage(context.Background(), image))
72- require.Zero(t, fake.writeImageCalls)
73 96
74 stdin, err := os.ReadFile(stdinPath)97 stdin, err := os.ReadFile(stdinPath)
75 require.NoError(t, err)98 require.NoError(t, err)
M internal/infrastructure/webkit/context_menu.go +90 -7
@@ -2,6 +2,7 @@ package webkit
22
33 import (
44 "context"
5+ "encoding/base64"
56 "fmt"
67 "io"
78 "mime"
@@ -15,6 +16,7 @@ import (
1516 "github.com/bnema/dumber/internal/domain/entity"
1617 "github.com/bnema/dumber/internal/logging"
1718 "github.com/bnema/puregotk-webkit/webkit"
19+ "github.com/bnema/puregotk/v4/gtk"
1820 )
1921
2022 // contextMenuResolver implements port.ImageDataResolver by fetching image bytes
@@ -42,6 +44,8 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
4244 return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
4345 }
4446 switch strings.ToLower(parsed.Scheme) {
47+ case "data":
48+ return resolveDataURIImageData(imageURI)
4549 case "http", "https":
4650 default:
4751 return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
@@ -71,6 +75,61 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
7175 if err != nil {
7276 return entity.ImageData{}, fmt.Errorf("read image body: %w", err)
7377 }
78+ if len(data) == 0 {
79+ return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
80+ }
81+ if len(data) > maxImageFetchBytes {
82+ return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
83+ }
84+ responseURL := parsed.String()
85+ if resp.Request != nil && resp.Request.URL != nil {
86+ responseURL = resp.Request.URL.String()
87+ }
88+ return resolvedImageData(data, resp.Header.Get("Content-Type"), responseURL)
89+}
90+
91+func resolveDataURIImageData(imageURI string) (entity.ImageData, error) {
92+ payload, ok := strings.CutPrefix(imageURI, "data:")
93+ if !ok {
94+ return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", "")
95+ }
96+ meta, dataPart, found := strings.Cut(payload, ",")
97+ if !found {
98+ return entity.ImageData{}, fmt.Errorf("parse image URI: invalid data URI")
99+ }
100+
101+ isBase64 := false
102+ mimeType := ""
103+ for _, part := range strings.Split(meta, ";") {
104+ part = strings.TrimSpace(part)
105+ if part == "" {
106+ continue
107+ }
108+ if strings.EqualFold(part, "base64") {
109+ isBase64 = true
110+ continue
111+ }
112+ if mimeType == "" {
113+ mimeType = part
114+ }
115+ }
116+
117+ var data []byte
118+ var err error
119+ if isBase64 {
120+ data, err = base64.StdEncoding.DecodeString(dataPart)
121+ } else {
122+ var decoded string
123+ decoded, err = url.PathUnescape(dataPart)
124+ data = []byte(decoded)
125+ }
126+ if err != nil {
127+ return entity.ImageData{}, fmt.Errorf("read image body: decode data URI: %w", err)
128+ }
129+ return resolvedImageData(data, mimeType, imageURI)
130+}
131+
132+func resolvedImageData(data []byte, headerType, source string) (entity.ImageData, error) {
74133 if len(data) == 0 {
75134 return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
76135 }
@@ -80,23 +139,17 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
80139 rawDetectedType := strings.ToLower(http.DetectContentType(data))
81140 detectedType := validatedImageMimeType(rawDetectedType)
82141 mimeType := detectedType
83- headerType := resp.Header.Get("Content-Type")
84142 if mimeType == "" && rawDetectedType == "application/octet-stream" {
85143 mimeType = validatedImageMimeType(headerType)
86144 }
87145 if mimeType == "" {
88- responseURL := parsed.String()
89- if resp.Request != nil && resp.Request.URL != nil {
90- responseURL = resp.Request.URL.String()
91- }
92146 return entity.ImageData{}, fmt.Errorf(
93147 "read image body: content is not an image (detected=%q, header=%q, url=%q)",
94148 rawDetectedType,
95149 headerType,
96- responseURL,
150+ source,
97151 )
98152 }
99-
100153 return entity.ImageData{Bytes: data, MimeType: mimeType}, nil
101154 }
102155
@@ -345,6 +398,15 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
345398 }
346399
347400 x, y := contextMenuPosition(contextMenu)
401+ parent := anchorParentWidget(&wv.inner.Widget)
402+ wv.logger.Debug().
403+ Int("raw_x", x).
404+ Int("raw_y", y).
405+ Int("anchor_width", wv.inner.Widget.GetAllocatedWidth()).
406+ Int("anchor_height", wv.inner.Widget.GetAllocatedHeight()).
407+ Int("parent_width", widgetAllocatedWidth(parent)).
408+ Int("parent_height", widgetAllocatedHeight(parent)).
409+ Msg("webkit: context menu popup request")
348410
349411 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
350412 ctx := logging.WithContext(context.Background(), wv.logger)
@@ -400,6 +462,27 @@ func contextMenuPosition(menu *webkit.ContextMenu) (int, int) {
400462 return int(x), int(y)
401463 }
402464
465+func anchorParentWidget(anchor *gtk.Widget) *gtk.Widget {
466+ if anchor == nil {
467+ return nil
468+ }
469+ return anchor.GetParent()
470+}
471+
472+func widgetAllocatedWidth(widget *gtk.Widget) int {
473+ if widget == nil {
474+ return 0
475+ }
476+ return widget.GetAllocatedWidth()
477+}
478+
479+func widgetAllocatedHeight(widget *gtk.Widget) int {
480+ if widget == nil {
481+ return 0
482+ }
483+ return widget.GetAllocatedHeight()
484+}
485+
403486 // executeContextMenuAction runs the selected menu action through the shared
404487 // executor. GTK-thread-sensitive actions run inline; image fetch/save work
405488 // stays off-thread.
@@ -2,6 +2,7 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/base64"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "mime"8 "mime"
@@ -15,6 +16,7 @@ import (
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
16 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/puregotk-webkit/webkit"18 "github.com/bnema/puregotk-webkit/webkit"
19+ "github.com/bnema/puregotk/v4/gtk"
18 )20 )
19 21
20 // contextMenuResolver implements port.ImageDataResolver by fetching image bytes22 // contextMenuResolver implements port.ImageDataResolver by fetching image bytes
@@ -42,6 +44,8 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
42 return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)44 return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr)
43 }45 }
44 switch strings.ToLower(parsed.Scheme) {46 switch strings.ToLower(parsed.Scheme) {
47+ case "data":
48+ return resolveDataURIImageData(imageURI)
45 case "http", "https":49 case "http", "https":
46 default:50 default:
47 return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)51 return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", parsed.Scheme)
@@ -71,6 +75,61 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
71 if err != nil {75 if err != nil {
72 return entity.ImageData{}, fmt.Errorf("read image body: %w", err)76 return entity.ImageData{}, fmt.Errorf("read image body: %w", err)
73 }77 }
78+ if len(data) == 0 {
79+ return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
80+ }
81+ if len(data) > maxImageFetchBytes {
82+ return entity.ImageData{}, fmt.Errorf("read image body: image too large (limit %d bytes)", maxImageFetchBytes)
83+ }
84+ responseURL := parsed.String()
85+ if resp.Request != nil && resp.Request.URL != nil {
86+ responseURL = resp.Request.URL.String()
87+ }
88+ return resolvedImageData(data, resp.Header.Get("Content-Type"), responseURL)
89+}
90+
91+func resolveDataURIImageData(imageURI string) (entity.ImageData, error) {
92+ payload, ok := strings.CutPrefix(imageURI, "data:")
93+ if !ok {
94+ return entity.ImageData{}, fmt.Errorf("fetch image: unsupported URI scheme %q", "")
95+ }
96+ meta, dataPart, found := strings.Cut(payload, ",")
97+ if !found {
98+ return entity.ImageData{}, fmt.Errorf("parse image URI: invalid data URI")
99+ }
100+
101+ isBase64 := false
102+ mimeType := ""
103+ for _, part := range strings.Split(meta, ";") {
104+ part = strings.TrimSpace(part)
105+ if part == "" {
106+ continue
107+ }
108+ if strings.EqualFold(part, "base64") {
109+ isBase64 = true
110+ continue
111+ }
112+ if mimeType == "" {
113+ mimeType = part
114+ }
115+ }
116+
117+ var data []byte
118+ var err error
119+ if isBase64 {
120+ data, err = base64.StdEncoding.DecodeString(dataPart)
121+ } else {
122+ var decoded string
123+ decoded, err = url.PathUnescape(dataPart)
124+ data = []byte(decoded)
125+ }
126+ if err != nil {
127+ return entity.ImageData{}, fmt.Errorf("read image body: decode data URI: %w", err)
128+ }
129+ return resolvedImageData(data, mimeType, imageURI)
130+}
131+
132+func resolvedImageData(data []byte, headerType, source string) (entity.ImageData, error) {
74 if len(data) == 0 {133 if len(data) == 0 {
75 return entity.ImageData{}, fmt.Errorf("read image body: empty image data")134 return entity.ImageData{}, fmt.Errorf("read image body: empty image data")
76 }135 }
@@ -80,23 +139,17 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str
80 rawDetectedType := strings.ToLower(http.DetectContentType(data))139 rawDetectedType := strings.ToLower(http.DetectContentType(data))
81 detectedType := validatedImageMimeType(rawDetectedType)140 detectedType := validatedImageMimeType(rawDetectedType)
82 mimeType := detectedType141 mimeType := detectedType
83- headerType := resp.Header.Get("Content-Type")
84 if mimeType == "" && rawDetectedType == "application/octet-stream" {142 if mimeType == "" && rawDetectedType == "application/octet-stream" {
85 mimeType = validatedImageMimeType(headerType)143 mimeType = validatedImageMimeType(headerType)
86 }144 }
87 if mimeType == "" {145 if mimeType == "" {
88- responseURL := parsed.String()
89- if resp.Request != nil && resp.Request.URL != nil {
90- responseURL = resp.Request.URL.String()
91- }
92 return entity.ImageData{}, fmt.Errorf(146 return entity.ImageData{}, fmt.Errorf(
93 "read image body: content is not an image (detected=%q, header=%q, url=%q)",147 "read image body: content is not an image (detected=%q, header=%q, url=%q)",
94 rawDetectedType,148 rawDetectedType,
95 headerType,149 headerType,
96- responseURL,150+ source,
97 )151 )
98 }152 }
99-
100 return entity.ImageData{Bytes: data, MimeType: mimeType}, nil153 return entity.ImageData{Bytes: data, MimeType: mimeType}, nil
101 }154 }
102 155
@@ -345,6 +398,15 @@ func (wv *WebView) connectContextMenuSignal(pipeline *contextMenuPipeline) {
345 }398 }
346 399
347 x, y := contextMenuPosition(contextMenu)400 x, y := contextMenuPosition(contextMenu)
401+ parent := anchorParentWidget(&wv.inner.Widget)
402+ wv.logger.Debug().
403+ Int("raw_x", x).
404+ Int("raw_y", y).
405+ Int("anchor_width", wv.inner.Widget.GetAllocatedWidth()).
406+ Int("anchor_height", wv.inner.Widget.GetAllocatedHeight()).
407+ Int("parent_width", widgetAllocatedWidth(parent)).
408+ Int("parent_height", widgetAllocatedHeight(parent)).
409+ Msg("webkit: context menu popup request")
348 410
349 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)411 menuContext := buildMenuContextFromHitTest(wv, hit, x, y)
350 ctx := logging.WithContext(context.Background(), wv.logger)412 ctx := logging.WithContext(context.Background(), wv.logger)
@@ -400,6 +462,27 @@ func contextMenuPosition(menu *webkit.ContextMenu) (int, int) {
400 return int(x), int(y)462 return int(x), int(y)
401 }463 }
402 464
465+func anchorParentWidget(anchor *gtk.Widget) *gtk.Widget {
466+ if anchor == nil {
467+ return nil
468+ }
469+ return anchor.GetParent()
470+}
471+
472+func widgetAllocatedWidth(widget *gtk.Widget) int {
473+ if widget == nil {
474+ return 0
475+ }
476+ return widget.GetAllocatedWidth()
477+}
478+
479+func widgetAllocatedHeight(widget *gtk.Widget) int {
480+ if widget == nil {
481+ return 0
482+ }
483+ return widget.GetAllocatedHeight()
484+}
485+
403 // executeContextMenuAction runs the selected menu action through the shared486 // executeContextMenuAction runs the selected menu action through the shared
404 // executor. GTK-thread-sensitive actions run inline; image fetch/save work487 // executor. GTK-thread-sensitive actions run inline; image fetch/save work
405 // stays off-thread.488 // stays off-thread.
M internal/infrastructure/webkit/context_menu_test.go +12 -0
@@ -2,6 +2,7 @@ package webkit
22
33 import (
44 "context"
5+ "encoding/base64"
56 "errors"
67 "io"
78 "net"
@@ -104,6 +105,17 @@ func TestResolveImageData(t *testing.T) {
104105 assert.Contains(t, err.Error(), "unsupported URI scheme")
105106 })
106107
108+ t.Run("decodes data URI images", func(t *testing.T) {
109+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
110+ uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
111+
112+ resolver := &contextMenuResolver{}
113+ data, err := resolver.ResolveImageData(context.Background(), uri)
114+ require.NoError(t, err)
115+ assert.Equal(t, imageBytes, data.Bytes)
116+ assert.Equal(t, "image/png", data.MimeType)
117+ })
118+
107119 t.Run("fetches image bytes from HTTP", func(t *testing.T) {
108120 imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
109121 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -2,6 +2,7 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/base64"
5 "errors"6 "errors"
6 "io"7 "io"
7 "net"8 "net"
@@ -104,6 +105,17 @@ func TestResolveImageData(t *testing.T) {
104 assert.Contains(t, err.Error(), "unsupported URI scheme")105 assert.Contains(t, err.Error(), "unsupported URI scheme")
105 })106 })
106 107
108+ t.Run("decodes data URI images", func(t *testing.T) {
109+ imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
110+ uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
111+
112+ resolver := &contextMenuResolver{}
113+ data, err := resolver.ResolveImageData(context.Background(), uri)
114+ require.NoError(t, err)
115+ assert.Equal(t, imageBytes, data.Bytes)
116+ assert.Equal(t, "image/png", data.MimeType)
117+ })
118+
107 t.Run("fetches image bytes from HTTP", func(t *testing.T) {119 t.Run("fetches image bytes from HTTP", func(t *testing.T) {
108 imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}120 imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
109 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {121 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
M internal/infrastructure/webkit/pool.go +7 -0
@@ -186,6 +186,9 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
186186 p.ctxMenuMu.RUnlock()
187187 if ctxMenu != nil {
188188 wv.contextMenu = ctxMenu
189+ if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) {
190+ wv.connectContextMenuSignal(ctxMenu)
191+ }
189192 }
190193 wv.ReconnectSignals()
191194 p.acquireCount.Add(1)
@@ -230,6 +233,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
230233 return wv, nil
231234 }
232235
236+func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
237+ return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
238+}
239+
233240 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
234241 log := logging.FromContext(ctx)
235242
@@ -186,6 +186,9 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
186 p.ctxMenuMu.RUnlock()186 p.ctxMenuMu.RUnlock()
187 if ctxMenu != nil {187 if ctxMenu != nil {
188 wv.contextMenu = ctxMenu188 wv.contextMenu = ctxMenu
189+ if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) {
190+ wv.connectContextMenuSignal(ctxMenu)
191+ }
189 }192 }
190 wv.ReconnectSignals()193 wv.ReconnectSignals()
191 p.acquireCount.Add(1)194 p.acquireCount.Add(1)
@@ -230,6 +233,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
230 return wv, nil233 return wv, nil
231 }234 }
232 235
236+func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
237+ return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
238+}
239+
233 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {240 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
234 log := logging.FromContext(ctx)241 log := logging.FromContext(ctx)
235 242
A internal/infrastructure/webkit/pool_context_menu_test.go +26 -0
@@ -0,0 +1,26 @@
1+package webkit
2+
3+import "testing"
4+
5+func TestNeedsExplicitContextMenuSignalOnAcquire(t *testing.T) {
6+ t.Run("true for prewarmed view that already has other signals", func(t *testing.T) {
7+ wv := &WebView{signalIDs: []uintptr{1, 2, 3}}
8+ if !needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) {
9+ t.Fatal("expected prewarmed webview to require explicit context-menu signal connection")
10+ }
11+ })
12+
13+ t.Run("false when pipeline missing", func(t *testing.T) {
14+ wv := &WebView{signalIDs: []uintptr{1}}
15+ if needsExplicitContextMenuSignalOnAcquire(wv, nil) {
16+ t.Fatal("expected missing pipeline to skip explicit context-menu signal connection")
17+ }
18+ })
19+
20+ t.Run("false for reused view with disconnected signals", func(t *testing.T) {
21+ wv := &WebView{}
22+ if needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) {
23+ t.Fatal("expected reused webview to rely on full signal reconnection")
24+ }
25+ })
26+}
@@ -0,0 +1,26 @@
1+package webkit
2+
3+import "testing"
4+
5+func TestNeedsExplicitContextMenuSignalOnAcquire(t *testing.T) {
6+ t.Run("true for prewarmed view that already has other signals", func(t *testing.T) {
7+ wv := &WebView{signalIDs: []uintptr{1, 2, 3}}
8+ if !needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) {
9+ t.Fatal("expected prewarmed webview to require explicit context-menu signal connection")
10+ }
11+ })
12+
13+ t.Run("false when pipeline missing", func(t *testing.T) {
14+ wv := &WebView{signalIDs: []uintptr{1}}
15+ if needsExplicitContextMenuSignalOnAcquire(wv, nil) {
16+ t.Fatal("expected missing pipeline to skip explicit context-menu signal connection")
17+ }
18+ })
19+
20+ t.Run("false for reused view with disconnected signals", func(t *testing.T) {
21+ wv := &WebView{}
22+ if needsExplicitContextMenuSignalOnAcquire(wv, &contextMenuPipeline{}) {
23+ t.Fatal("expected reused webview to rely on full signal reconnection")
24+ }
25+ })
26+}
M internal/infrastructure/webkit/renderer.go +89 -4
@@ -83,6 +83,8 @@ func showMenu(
8383
8484 selected := false
8585 var popover *gtk.Popover
86+ parent := choosePopoverParent(anchor, anchor.GetParent())
87+ popoverHost, overlay := createPopoverHost(parent, x, y)
8688
8789 for _, item := range items {
8890 if item.separator {
@@ -111,21 +113,104 @@ func showMenu(
111113
112114 popover = gtk.NewPopover()
113115 popover.SetChild(&box.Widget)
114- popover.SetParent(anchor)
116+ attachPopover(popover, popoverHost, parent)
115117 popover.SetHasArrow(false)
116118 popover.SetAutohide(true)
117119
118- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}
119- popover.SetPointingTo(rect)
120+ if popoverHost == nil {
121+ rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122+ if anchor == nil || parent == nil || anchor == parent {
123+ return srcX, srcY, false
124+ }
125+ var destX, destY float64
126+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127+ return srcX, srcY, false
128+ }
129+ return destX, destY, true
130+ })
131+ popover.SetPointingTo(rect)
132+ }
120133
121134 closedCb := func(_ gtk.Popover) {
122135 if !selected && onClose != nil {
123136 onClose()
124137 }
125- popover.Unparent()
138+ cleanupPopover(popover, popoverHost, overlay)
126139 }
127140 popover.ConnectClosed(&closedCb)
128141
142+ popupPopover(popover, popoverHost)
143+}
144+
145+func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146+ if parent != nil {
147+ return parent
148+ }
149+ return anchor
150+}
151+
152+func popoverPointingRect(
153+ x, y int32,
154+ translate func(srcX, srcY float64) (float64, float64, bool),
155+) *gdk.Rectangle {
156+ destX, destY := float64(x), float64(y)
157+ if translate != nil {
158+ if tx, ty, ok := translate(destX, destY); ok {
159+ destX, destY = tx, ty
160+ }
161+ }
162+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163+}
164+
165+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166+ overlay := overlayFromWidget(parent)
167+ if overlay == nil {
168+ return nil, nil
169+ }
170+ host := gtk.NewMenuButton()
171+ host.SetCanFocus(false)
172+ host.SetHalign(gtk.AlignStartValue)
173+ host.SetValign(gtk.AlignStartValue)
174+ host.SetMarginStart(int(x))
175+ host.SetMarginTop(int(y))
176+ host.SetOpacity(0)
177+ host.SetSizeRequest(1, 1)
178+ host.SetVisible(true)
179+ overlay.AddOverlay(&host.Widget)
180+ overlay.SetClipOverlay(&host.Widget, false)
181+ overlay.SetMeasureOverlay(&host.Widget, false)
182+ return host, overlay
183+}
184+
185+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186+ if widget == nil || widget.GoPointer() == 0 {
187+ return nil
188+ }
189+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190+}
191+
192+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193+ if host != nil {
194+ host.SetPopover(popover)
195+ return
196+ }
197+ popover.SetParent(parent)
198+}
199+
200+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201+ if host != nil && overlay != nil {
202+ host.SetPopover(nil)
203+ overlay.RemoveOverlay(&host.Widget)
204+ return
205+ }
206+ popover.Unparent()
207+}
208+
209+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210+ if host != nil {
211+ host.Popup()
212+ return
213+ }
129214 popover.Popup()
130215 }
131216
@@ -83,6 +83,8 @@ func showMenu(
83 83
84 selected := false84 selected := false
85 var popover *gtk.Popover85 var popover *gtk.Popover
86+ parent := choosePopoverParent(anchor, anchor.GetParent())
87+ popoverHost, overlay := createPopoverHost(parent, x, y)
86 88
87 for _, item := range items {89 for _, item := range items {
88 if item.separator {90 if item.separator {
@@ -111,21 +113,104 @@ func showMenu(
111 113
112 popover = gtk.NewPopover()114 popover = gtk.NewPopover()
113 popover.SetChild(&box.Widget)115 popover.SetChild(&box.Widget)
114- popover.SetParent(anchor)116+ attachPopover(popover, popoverHost, parent)
115 popover.SetHasArrow(false)117 popover.SetHasArrow(false)
116 popover.SetAutohide(true)118 popover.SetAutohide(true)
117 119
118- rect := &gdk.Rectangle{X: int(x), Y: int(y), Width: 1, Height: 1}120+ if popoverHost == nil {
119- popover.SetPointingTo(rect)121+ rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122+ if anchor == nil || parent == nil || anchor == parent {
123+ return srcX, srcY, false
124+ }
125+ var destX, destY float64
126+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127+ return srcX, srcY, false
128+ }
129+ return destX, destY, true
130+ })
131+ popover.SetPointingTo(rect)
132+ }
120 133
121 closedCb := func(_ gtk.Popover) {134 closedCb := func(_ gtk.Popover) {
122 if !selected && onClose != nil {135 if !selected && onClose != nil {
123 onClose()136 onClose()
124 }137 }
125- popover.Unparent()138+ cleanupPopover(popover, popoverHost, overlay)
126 }139 }
127 popover.ConnectClosed(&closedCb)140 popover.ConnectClosed(&closedCb)
128 141
142+ popupPopover(popover, popoverHost)
143+}
144+
145+func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146+ if parent != nil {
147+ return parent
148+ }
149+ return anchor
150+}
151+
152+func popoverPointingRect(
153+ x, y int32,
154+ translate func(srcX, srcY float64) (float64, float64, bool),
155+) *gdk.Rectangle {
156+ destX, destY := float64(x), float64(y)
157+ if translate != nil {
158+ if tx, ty, ok := translate(destX, destY); ok {
159+ destX, destY = tx, ty
160+ }
161+ }
162+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163+}
164+
165+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166+ overlay := overlayFromWidget(parent)
167+ if overlay == nil {
168+ return nil, nil
169+ }
170+ host := gtk.NewMenuButton()
171+ host.SetCanFocus(false)
172+ host.SetHalign(gtk.AlignStartValue)
173+ host.SetValign(gtk.AlignStartValue)
174+ host.SetMarginStart(int(x))
175+ host.SetMarginTop(int(y))
176+ host.SetOpacity(0)
177+ host.SetSizeRequest(1, 1)
178+ host.SetVisible(true)
179+ overlay.AddOverlay(&host.Widget)
180+ overlay.SetClipOverlay(&host.Widget, false)
181+ overlay.SetMeasureOverlay(&host.Widget, false)
182+ return host, overlay
183+}
184+
185+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186+ if widget == nil || widget.GoPointer() == 0 {
187+ return nil
188+ }
189+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190+}
191+
192+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193+ if host != nil {
194+ host.SetPopover(popover)
195+ return
196+ }
197+ popover.SetParent(parent)
198+}
199+
200+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201+ if host != nil && overlay != nil {
202+ host.SetPopover(nil)
203+ overlay.RemoveOverlay(&host.Widget)
204+ return
205+ }
206+ popover.Unparent()
207+}
208+
209+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210+ if host != nil {
211+ host.Popup()
212+ return
213+ }
129 popover.Popup()214 popover.Popup()
130 }215 }
131 216
M internal/infrastructure/webkit/renderer_test.go +33 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/puregotk/v4/gtk"
78 "github.com/stretchr/testify/require"
89 )
910
@@ -29,3 +30,35 @@ func TestBuildButtons(t *testing.T) {
2930 require.Equal(t, "Inspect Element", renderItems[3].item.Label)
3031 require.False(t, renderItems[3].separator)
3132 }
33+
34+func TestChoosePopoverParent(t *testing.T) {
35+ anchor := &gtk.Widget{}
36+ parent := &gtk.Widget{}
37+
38+ require.Same(t, parent, choosePopoverParent(anchor, parent))
39+ require.Same(t, anchor, choosePopoverParent(anchor, nil))
40+}
41+
42+func TestPopoverPointingRect(t *testing.T) {
43+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
44+ rect := popoverPointingRect(50, 80, func(srcX, srcY float64) (float64, float64, bool) {
45+ require.InDelta(t, 50.0, srcX, 0.0001)
46+ require.InDelta(t, 80.0, srcY, 0.0001)
47+ return 20, 30, true
48+ })
49+
50+ require.Equal(t, 20, rect.X)
51+ require.Equal(t, 30, rect.Y)
52+ require.Equal(t, 1, rect.Width)
53+ require.Equal(t, 1, rect.Height)
54+ })
55+
56+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
57+ rect := popoverPointingRect(12, 34, func(srcX, srcY float64) (float64, float64, bool) {
58+ return srcX, srcY, false
59+ })
60+
61+ require.Equal(t, 12, rect.X)
62+ require.Equal(t, 34, rect.Y)
63+ })
64+}
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/puregotk/v4/gtk"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
@@ -29,3 +30,35 @@ func TestBuildButtons(t *testing.T) {
29 require.Equal(t, "Inspect Element", renderItems[3].item.Label)30 require.Equal(t, "Inspect Element", renderItems[3].item.Label)
30 require.False(t, renderItems[3].separator)31 require.False(t, renderItems[3].separator)
31 }32 }
33+
34+func TestChoosePopoverParent(t *testing.T) {
35+ anchor := &gtk.Widget{}
36+ parent := &gtk.Widget{}
37+
38+ require.Same(t, parent, choosePopoverParent(anchor, parent))
39+ require.Same(t, anchor, choosePopoverParent(anchor, nil))
40+}
41+
42+func TestPopoverPointingRect(t *testing.T) {
43+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
44+ rect := popoverPointingRect(50, 80, func(srcX, srcY float64) (float64, float64, bool) {
45+ require.InDelta(t, 50.0, srcX, 0.0001)
46+ require.InDelta(t, 80.0, srcY, 0.0001)
47+ return 20, 30, true
48+ })
49+
50+ require.Equal(t, 20, rect.X)
51+ require.Equal(t, 30, rect.Y)
52+ require.Equal(t, 1, rect.Width)
53+ require.Equal(t, 1, rect.Height)
54+ })
55+
56+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
57+ rect := popoverPointingRect(12, 34, func(srcX, srcY float64) (float64, float64, bool) {
58+ return srcX, srcY, false
59+ })
60+
61+ require.Equal(t, 12, rect.X)
62+ require.Equal(t, 34, rect.Y)
63+ })
64+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close