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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +667 -47
M internal/bootstrap/engine.go +9 -5
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 10 | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 12 | + | clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 14 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| @@ -83,11 +84,14 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 83 | 84 | } | |
| 84 | 85 | } | |
| 85 | 86 | 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(), | |
| 91 | 95 | MediaClassifier: cef.MediaClassifier{ | |
| 92 | 96 | IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME, | |
| 93 | 97 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 9 | ||
| 9 | 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 | 72 | return 1 | |
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | - | x := params.GetXcoord() | |
| 73 | - | y := params.GetYcoord() | |
| 75 | + | x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale) | |
| 74 | 76 | glArea := h.wv.pipeline.glArea | |
| 77 | + | logContextMenuPopupRequest(h, glArea, params, x, y) | |
| 78 | + | executor := h.contextMenuExecutor() | |
| 75 | 79 | ||
| 76 | 80 | NewRenderer(h.wv.runOnGTK).Show( | |
| 77 | 81 | items, | |
| 78 | 82 | &glArea.Widget, | |
| 79 | 83 | x, | |
| 80 | 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 | 88 | func() { | |
| 83 | 89 | callback.Cancel() | |
| 84 | 90 | }, | |
| @@ -86,21 +92,150 @@ func (h *handlerSet) RunContextMenu( | |||
| 86 | 92 | 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 | 154 | func dispatchContextMenuSelection( | |
| 155 | + | ctx context.Context, | |
| 156 | + | executor port.ContextMenuActionExecutor, | |
| 90 | 157 | callback purecef.RunContextMenuCallback, | |
| 91 | 158 | commandIDByAction map[port.MenuAction]int32, | |
| 92 | 159 | item port.MenuItem, | |
| 160 | + | menuContext port.MenuContext, | |
| 93 | 161 | ) { | |
| 162 | + | log := logging.FromContext(ctx) | |
| 94 | 163 | if callback == nil { | |
| 95 | 164 | 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 | 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 | 178 | callback.Cont(cmdID, 0) | |
| 99 | 179 | return | |
| 100 | 180 | } | |
| 181 | + | log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command") | |
| 101 | 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 | 239 | func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) { | |
| 105 | 240 | for _, candidate := range contextMenuActionLabels(action, label) { | |
| 106 | 241 | 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 1 | 72 | 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.glArea | 76 | 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 1 | 92 | 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 | return | 164 | 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 | return | 179 | 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 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -77,6 +78,20 @@ type stubRunContextMenuCallback struct { | |||
| 77 | 78 | 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 | 95 | func (c *stubRunContextMenuCallback) Cont(commandID int32, _ purecef.EventFlags) { | |
| 81 | 96 | c.contCalls++ | |
| 82 | 97 | c.commandID = commandID | |
| @@ -89,9 +104,9 @@ func (c *stubRunContextMenuCallback) Cancel() { | |||
| 89 | 104 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |
| 90 | 105 | callback := &stubRunContextMenuCallback{} | |
| 91 | 106 | ||
| 92 | - | dispatchContextMenuSelection(callback, map[port.MenuAction]int32{ | |
| 107 | + | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | |
| 93 | 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 | 111 | require.Zero(t, callback.contCalls) | |
| 97 | 112 | require.Equal(t, 1, callback.cancelCalls) | |
| @@ -100,11 +115,60 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||
| 100 | 115 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |
| 101 | 116 | callback := &stubRunContextMenuCallback{} | |
| 102 | 117 | ||
| 103 | - | dispatchContextMenuSelection(callback, map[port.MenuAction]int32{ | |
| 118 | + | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | |
| 104 | 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 | 122 | require.Equal(t, 1, callback.contCalls) | |
| 108 | 123 | require.Zero(t, callback.cancelCalls) | |
| 109 | 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package cef | 1 | 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 int32 | 78 | 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 = commandID | 97 | 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 { | |||
| 32 | 32 | currentConfigPayload func() ([]byte, error) | |
| 33 | 33 | defaultConfigPayload func() ([]byte, error) | |
| 34 | 34 | ctxMenuBuilder port.ContextMenuBuilder | |
| 35 | + | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 36 | + | clipboard port.Clipboard | |
| 37 | + | resolver port.ImageDataResolver | |
| 35 | 38 | mediaClassifier MediaClassifier | |
| 36 | 39 | ||
| 37 | 40 | // 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.ContextMenuBuilder | 34 | ctxMenuBuilder port.ContextMenuBuilder | ||
| 35 | + | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |||
| 36 | + | clipboard port.Clipboard | |||
| 37 | + | resolver port.ImageDataResolver | |||
| 35 | mediaClassifier MediaClassifier | 38 | 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( | |||
| 55 | 55 | currentConfigPayload: deps.CurrentConfigPayload, | |
| 56 | 56 | defaultConfigPayload: deps.DefaultConfigPayload, | |
| 57 | 57 | ctxMenuBuilder: deps.ContextMenuBuilder, | |
| 58 | + | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | |
| 59 | + | clipboard: deps.Clipboard, | |
| 60 | + | resolver: deps.ImageDataResolver, | |
| 58 | 61 | mediaClassifier: deps.MediaClassifier, | |
| 59 | 62 | } | |
| 60 | 63 | ||
| @@ -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( | |||
| 83 | 83 | ||
| 84 | 84 | selected := false | |
| 85 | 85 | var popover *gtk.Popover | |
| 86 | + | parent := choosePopoverParent(anchor, anchor.GetParent()) | |
| 87 | + | popoverHost, overlay := createPopoverHost(parent, x, y) | |
| 86 | 88 | ||
| 87 | 89 | for _, item := range items { | |
| 88 | 90 | if item.separator { | |
| @@ -111,21 +113,104 @@ func showMenu( | |||
| 111 | 113 | ||
| 112 | 114 | popover = gtk.NewPopover() | |
| 113 | 115 | popover.SetChild(&box.Widget) | |
| 114 | - | popover.SetParent(anchor) | |
| 116 | + | attachPopover(popover, popoverHost, parent) | |
| 115 | 117 | popover.SetHasArrow(false) | |
| 116 | 118 | popover.SetAutohide(true) | |
| 117 | 119 | ||
| 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 | + | } | |
| 120 | 133 | ||
| 121 | 134 | closedCb := func(_ gtk.Popover) { | |
| 122 | 135 | if !selected && onClose != nil { | |
| 123 | 136 | onClose() | |
| 124 | 137 | } | |
| 125 | - | popover.Unparent() | |
| 138 | + | cleanupPopover(popover, popoverHost, overlay) | |
| 126 | 139 | } | |
| 127 | 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 | 214 | popover.Popup() | |
| 130 | 215 | } | |
| 131 | 216 | ||
| @@ -83,6 +83,8 @@ func showMenu( | |||||
| 83 | 83 | ||||
| 84 | selected := false | 84 | selected := false | ||
| 85 | var popover *gtk.Popover | 85 | 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 := >k.Widget{} | |
| 12 | + | parent := >k.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 := >k.Widget{} | |||
| 12 | + | parent := >k.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 { | |||
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | 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 | |
| 74 | 77 | } | |
| @@ -65,10 +65,13 @@ func (m MediaClassifier) normalize() MediaClassifier { | |||||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | type EngineDependencies struct { | 67 | type EngineDependencies struct { | ||
| 68 | - | RegisterHandlers HandlerRegistrar | 68 | + | RegisterHandlers HandlerRegistrar |
| 69 | - | RegisterAccentHandlers AccentHandlerRegistrar | 69 | + | 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.ContextMenuBuilder | 72 | + | ContextMenuBuilder port.ContextMenuBuilder |
| 73 | - | MediaClassifier MediaClassifier | 73 | + | 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 { | |||
| 30 | 30 | 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 | 43 | var ( | |
| 34 | 44 | lookPath = exec.LookPath | |
| 35 | 45 | commandContext = exec.CommandContext | |
| @@ -147,8 +157,8 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||
| 147 | 157 | } | |
| 148 | 158 | commandErr = err | |
| 149 | 159 | } | |
| 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) | |
| 152 | 162 | if err == nil { | |
| 153 | 163 | log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success") | |
| 154 | 164 | return nil | |
| @@ -175,20 +185,24 @@ func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error | |||
| 175 | 185 | log.Error().Err(err).Msg("clipboard image write failed") | |
| 176 | 186 | return err | |
| 177 | 187 | } | |
| 178 | - | if a.copyCmd != "" { | |
| 179 | - | err := a.writeImageWithCommand(ctx, image, log) | |
| 188 | + | if toolkit := a.ensureToolkitClipboard(); toolkit != nil { | |
| 189 | + | err := toolkit.WriteImage(ctx, image) | |
| 180 | 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 | 196 | return nil | |
| 182 | 197 | } | |
| 183 | - | commandErr = err | |
| 198 | + | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | |
| 184 | 199 | } | |
| 185 | - | if a.toolkit != nil { | |
| 186 | - | err := a.toolkit.WriteImage(ctx, image) | |
| 200 | + | if a.copyCmd != "" { | |
| 201 | + | err := a.writeImageWithCommand(ctx, image, log) | |
| 187 | 202 | if err == nil { | |
| 188 | - | log.Debug().Str("backend", "toolkit").Int("bytes", len(image.Bytes)).Msg("clipboard image write success") | |
| 189 | 203 | return nil | |
| 190 | 204 | } | |
| 191 | - | log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back") | |
| 205 | + | commandErr = err | |
| 192 | 206 | } | |
| 193 | 207 | if commandErr != nil { | |
| 194 | 208 | return commandErr | |
| @@ -30,6 +30,16 @@ type Adapter struct { | |||||
| 30 | pasteCmd string | 30 | 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.LookPath | 44 | lookPath = exec.LookPath | ||
| 35 | commandContext = exec.CommandContext | 45 | commandContext = exec.CommandContext | ||
| @@ -147,8 +157,8 @@ func (a *Adapter) WriteText(ctx context.Context, text string) error { | |||||
| 147 | } | 157 | } | ||
| 148 | commandErr = err | 158 | 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 nil | 164 | 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 err | 186 | 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 nil | 196 | return nil | ||
| 182 | } | 197 | } | ||
| 183 | - | commandErr = err | 198 | + | 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 nil | 203 | 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 commandErr | 208 | return commandErr | ||
M internal/infrastructure/clipboard/clipboard_test.go +28 -5
| @@ -56,20 +56,43 @@ func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) | |||
| 56 | 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 | 65 | dir := t.TempDir() | |
| 61 | 66 | scriptPath := filepath.Join(dir, "wl-copy") | |
| 62 | - | argsPath := filepath.Join(dir, "args.txt") | |
| 63 | 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 | 69 | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) | |
| 66 | 70 | ||
| 67 | 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 | 93 | image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"} | |
| 70 | 94 | ||
| 71 | 95 | require.NoError(t, adapter.WriteImage(context.Background(), image)) | |
| 72 | - | require.Zero(t, fake.writeImageCalls) | |
| 73 | 96 | ||
| 74 | 97 | stdin, err := os.ReadFile(stdinPath) | |
| 75 | 98 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "mime" | |
| @@ -15,6 +16,7 @@ import ( | |||
| 15 | 16 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 17 | 18 | "github.com/bnema/puregotk-webkit/webkit" | |
| 19 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | |
| @@ -42,6 +44,8 @@ func (r *contextMenuResolver) ResolveImageData(ctx context.Context, imageURI str | |||
| 42 | 44 | return entity.ImageData{}, fmt.Errorf("parse image URI: %w", parseErr) | |
| 43 | 45 | } | |
| 44 | 46 | switch strings.ToLower(parsed.Scheme) { | |
| 47 | + | case "data": | |
| 48 | + | return resolveDataURIImageData(imageURI) | |
| 45 | 49 | case "http", "https": | |
| 46 | 50 | default: | |
| 47 | 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 | 75 | if err != nil { | |
| 72 | 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 | 133 | if len(data) == 0 { | |
| 75 | 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 | 139 | rawDetectedType := strings.ToLower(http.DetectContentType(data)) | |
| 81 | 140 | detectedType := validatedImageMimeType(rawDetectedType) | |
| 82 | 141 | mimeType := detectedType | |
| 83 | - | headerType := resp.Header.Get("Content-Type") | |
| 84 | 142 | if mimeType == "" && rawDetectedType == "application/octet-stream" { | |
| 85 | 143 | mimeType = validatedImageMimeType(headerType) | |
| 86 | 144 | } | |
| 87 | 145 | if mimeType == "" { | |
| 88 | - | responseURL := parsed.String() | |
| 89 | - | if resp.Request != nil && resp.Request.URL != nil { | |
| 90 | - | responseURL = resp.Request.URL.String() | |
| 91 | - | } | |
| 92 | 146 | return entity.ImageData{}, fmt.Errorf( | |
| 93 | 147 | "read image body: content is not an image (detected=%q, header=%q, url=%q)", | |
| 94 | 148 | rawDetectedType, | |
| 95 | 149 | headerType, | |
| 96 | - | responseURL, | |
| 150 | + | source, | |
| 97 | 151 | ) | |
| 98 | 152 | } | |
| 99 | - | ||
| 100 | 153 | 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 | 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 | 411 | menuContext := buildMenuContextFromHitTest(wv, hit, x, y) | |
| 350 | 412 | ctx := logging.WithContext(context.Background(), wv.logger) | |
| @@ -400,6 +462,27 @@ func contextMenuPosition(menu *webkit.ContextMenu) (int, int) { | |||
| 400 | 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 | 486 | // executeContextMenuAction runs the selected menu action through the shared | |
| 404 | 487 | // executor. GTK-thread-sensitive actions run inline; image fetch/save work | |
| 405 | 488 | // 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 bytes | 22 | // 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 := detectedType | 141 | 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}, nil | 153 | 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 shared | 486 | // executeContextMenuAction runs the selected menu action through the shared | ||
| 404 | // executor. GTK-thread-sensitive actions run inline; image fetch/save work | 487 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "net" | |
| @@ -104,6 +105,17 @@ func TestResolveImageData(t *testing.T) { | |||
| 104 | 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 | 119 | t.Run("fetches image bytes from HTTP", func(t *testing.T) { | |
| 108 | 120 | imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} | |
| 109 | 121 | 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) { | |||
| 186 | 186 | p.ctxMenuMu.RUnlock() | |
| 187 | 187 | if ctxMenu != nil { | |
| 188 | 188 | wv.contextMenu = ctxMenu | |
| 189 | + | if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) { | |
| 190 | + | wv.connectContextMenuSignal(ctxMenu) | |
| 191 | + | } | |
| 189 | 192 | } | |
| 190 | 193 | wv.ReconnectSignals() | |
| 191 | 194 | p.acquireCount.Add(1) | |
| @@ -230,6 +233,10 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 230 | 233 | 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 | 240 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |
| 234 | 241 | log := logging.FromContext(ctx) | |
| 235 | 242 | ||
| @@ -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 = ctxMenu | 188 | 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, nil | 233 | 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( | |||
| 83 | 83 | ||
| 84 | 84 | selected := false | |
| 85 | 85 | var popover *gtk.Popover | |
| 86 | + | parent := choosePopoverParent(anchor, anchor.GetParent()) | |
| 87 | + | popoverHost, overlay := createPopoverHost(parent, x, y) | |
| 86 | 88 | ||
| 87 | 89 | for _, item := range items { | |
| 88 | 90 | if item.separator { | |
| @@ -111,21 +113,104 @@ func showMenu( | |||
| 111 | 113 | ||
| 112 | 114 | popover = gtk.NewPopover() | |
| 113 | 115 | popover.SetChild(&box.Widget) | |
| 114 | - | popover.SetParent(anchor) | |
| 116 | + | attachPopover(popover, popoverHost, parent) | |
| 115 | 117 | popover.SetHasArrow(false) | |
| 116 | 118 | popover.SetAutohide(true) | |
| 117 | 119 | ||
| 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 | + | } | |
| 120 | 133 | ||
| 121 | 134 | closedCb := func(_ gtk.Popover) { | |
| 122 | 135 | if !selected && onClose != nil { | |
| 123 | 136 | onClose() | |
| 124 | 137 | } | |
| 125 | - | popover.Unparent() | |
| 138 | + | cleanupPopover(popover, popoverHost, overlay) | |
| 126 | 139 | } | |
| 127 | 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 | 214 | popover.Popup() | |
| 130 | 215 | } | |
| 131 | 216 | ||
| @@ -83,6 +83,8 @@ func showMenu( | |||||
| 83 | 83 | ||||
| 84 | selected := false | 84 | selected := false | ||
| 85 | var popover *gtk.Popover | 85 | 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 ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -29,3 +30,35 @@ func TestBuildButtons(t *testing.T) { | |||
| 29 | 30 | require.Equal(t, "Inspect Element", renderItems[3].item.Label) | |
| 30 | 31 | require.False(t, renderItems[3].separator) | |
| 31 | 32 | } | |
| 33 | + | ||
| 34 | + | func TestChoosePopoverParent(t *testing.T) { | |
| 35 | + | anchor := >k.Widget{} | |
| 36 | + | parent := >k.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 := >k.Widget{} | |||
| 36 | + | parent := >k.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 | + | } | |||