1package cef
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 purecef "github.com/bnema/purego-cef/cef"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gtk"
13)
14
15// ContextMenuRenderer renders normalized context menu items for the CEF adapter.
16// It is implemented by the GTK menu adapter and injected by bootstrap so this
17// adapter does not depend on a sibling infrastructure adapter.
18type ContextMenuRenderer interface {
19 Show(items []port.MenuItem, anchor *gtk.Widget, x, y int32, onSelect func(port.MenuItem), onClose func())
20}
21
22// ===========================================================================
23// ContextMenuHandler (implemented on handlerSet)
24// ===========================================================================
25
26func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {
27}
28
29//nolint:gocyclo // CEF callback coordinates menu extraction, rendering, and native fallback in one synchronous call.
30func (h *handlerSet) RunContextMenu(
31 _ purecef.Browser, _ purecef.Frame,
32 params purecef.ContextMenuParams, model purecef.MenuModel,
33 callback purecef.RunContextMenuCallback,
34) int32 {
35 if h == nil || callback == nil || model == nil {
36 if callback != nil {
37 callback.Cancel()
38 }
39 // Returning 1 tells CEF we handled the menu, so cancel suppresses the default menu.
40 return 1
41 }
42 if h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuBuilder == nil {
43 // Returning 0 lets CEF show its native menu when no custom builder is wired.
44 return 0
45 }
46
47 menuContext := buildMenuContext(h.wv, params)
48 items := h.wv.engine.ctxMenuBuilder.Build(context.Background(), menuContext)
49 if len(items) == 0 {
50 return 0
51 }
52
53 // Snapshot the command IDs from CEF — model/params pointers are only valid during this call.
54 count := model.GetCount()
55 if count == 0 {
56 callback.Cancel()
57 return 1
58 }
59 commandIDsByLabel := make(map[string]int32, count)
60 for i := range count {
61 t := model.GetTypeAt(i)
62 if t == purecef.MenuItemTypeMenuitemtypeSeparator {
63 continue
64 }
65 if t == purecef.MenuItemTypeMenuitemtypeCommand {
66 label := strings.ToLower(strings.TrimSpace(model.GetLabelAt(i)))
67 if label != "" {
68 commandIDsByLabel[label] = model.GetCommandIDAt(i)
69 }
70 }
71 }
72 commandIDByAction := make(map[port.MenuAction]int32, len(items))
73 for _, item := range items {
74 if cmdID, ok := lookupContextMenuCommandID(item.Action, item.Label, commandIDsByLabel); ok {
75 commandIDByAction[item.Action] = cmdID
76 }
77 }
78 if h.wv == nil || h.wv.viewBridge == nil {
79 return 0
80 }
81
82 x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor())
83 executor := h.contextMenuExecutor()
84 wv := h.wv
85 wv.runOnGTK(func() {
86 if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
87 callback.Cancel()
88 return
89 }
90 widget := wv.viewBridge.Widget()
91 logContextMenuPopupRequest(h, widget, params, x, y)
92 renderer := wv.engine.contextMenuRenderer()
93 if renderer == nil {
94 callback.Cancel()
95 return
96 }
97 renderer.Show(
98 items,
99 widget,
100 x,
101 y,
102 func(item port.MenuItem) {
103 var copiedCallback func(string)
104 if wv.engine != nil {
105 copiedCallback = wv.engine.notifyClipboardCopied
106 }
107 dispatchContextMenuSelection(wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
108 },
109 func() {
110 callback.Cancel()
111 },
112 )
113 })
114 return 1
115}
116
117func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
118 if h == nil || h.wv == nil || h.wv.engine == nil || h.wv.engine.ctxMenuExecutorFactory == nil {
119 return nil
120 }
121 return h.wv.engine.ctxMenuExecutorFactory.NewContextMenuActionExecutor(
122 h.wv.engine.clipboard,
123 h.wv.engine.resolver,
124 nil,
125 &cefMenuDelegator{wv: h.wv},
126 )
127}
128
129func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) {
130 if params == nil {
131 return 0, 0
132 }
133 if backingScale <= 1 {
134 return params.GetXcoord(), params.GetYcoord()
135 }
136 return deviceToLogicalCoord(params.GetXcoord(), scale), deviceToLogicalCoord(params.GetYcoord(), scale)
137}
138
139func logContextMenuPopupRequest(
140 h *handlerSet,
141 widget *gtk.Widget,
142 params purecef.ContextMenuParams,
143 x, y int32,
144) {
145 if h == nil || h.wv == nil || h.wv.ctx == nil || widget == nil {
146 return
147 }
148 rawX, rawY := contextMenuRawPosition(params)
149 parent := widget.GetParent()
150 logging.FromContext(h.wv.ctx).Debug().
151 Int32("raw_x", rawX).
152 Int32("raw_y", rawY).
153 Int32("popup_x", x).
154 Int32("popup_y", y).
155 Float64("scale", h.wv.viewBridgeScale()).
156 Int("anchor_width", widget.GetAllocatedWidth()).
157 Int("anchor_height", widget.GetAllocatedHeight()).
158 Int("parent_width", cefWidgetAllocatedWidth(parent)).
159 Int("parent_height", cefWidgetAllocatedHeight(parent)).
160 Msg("cef: context menu popup request")
161}
162
163func contextMenuRawPosition(params purecef.ContextMenuParams) (int32, int32) {
164 if params == nil {
165 return 0, 0
166 }
167 return params.GetXcoord(), params.GetYcoord()
168}
169
170func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
171 if widget == nil {
172 return 0
173 }
174 return widget.GetAllocatedWidth()
175}
176
177func cefWidgetAllocatedHeight(widget *gtk.Widget) int {
178 if widget == nil {
179 return 0
180 }
181 return widget.GetAllocatedHeight()
182}
183
184func dispatchContextMenuSelection(
185 ctx context.Context,
186 executor port.ContextMenuActionExecutor,
187 callback purecef.RunContextMenuCallback,
188 copiedCallback func(text string),
189 commandIDByAction map[port.MenuAction]int32,
190 item port.MenuItem,
191 menuContext port.MenuContext,
192) {
193 log := logging.FromContext(ctx)
194 if callback == nil {
195 return
196 }
197 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
198 if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" {
199 if cmdID, ok := commandIDByAction[item.Action]; ok {
200 log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
201 callback.Cont(cmdID, 0)
202 return
203 }
204 log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
205 callback.Cancel()
206 return
207 }
208 if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
209 if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
210 log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
211 callback.Cancel()
212 return
213 }
214 if copiedCallback != nil {
215 if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" {
216 copiedCallback(copiedText)
217 }
218 }
219 log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
220 callback.Cancel()
221 return
222 }
223 if cmdID, ok := commandIDByAction[item.Action]; ok {
224 log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
225 callback.Cont(cmdID, 0)
226 return
227 }
228 log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
229 callback.Cancel()
230}
231
232func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
233 switch action {
234 case port.MenuActionCopySelection:
235 return menuContext.SelectionText
236 case port.MenuActionCopyLink:
237 return menuContext.LinkURI
238 default:
239 return ""
240 }
241}
242
243func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
244 switch action {
245 case port.MenuActionBack,
246 port.MenuActionForward,
247 port.MenuActionReload,
248 port.MenuActionOpenLinkNewTab,
249 port.MenuActionCopyLink,
250 port.MenuActionCopyImage,
251 port.MenuActionInspectElement,
252 port.MenuActionCopySelection:
253 return true
254 default:
255 return false
256 }
257}
258
259type cefMenuDelegator struct {
260 wv *WebView
261}
262
263func (d *cefMenuDelegator) DelegateMenuAction(ctx context.Context, action port.MenuAction, menuContext port.MenuContext) error {
264 if d == nil || d.wv == nil {
265 return fmt.Errorf("cef menu delegator: webview not available")
266 }
267 switch action {
268 case port.MenuActionBack:
269 return d.wv.GoBack(ctx)
270 case port.MenuActionForward:
271 return d.wv.GoForward(ctx)
272 case port.MenuActionReload:
273 return d.wv.Reload(ctx)
274 case port.MenuActionOpenLinkNewTab:
275 if menuContext.LinkURI == "" {
276 return fmt.Errorf("open link in new tab: link URI not available")
277 }
278 d.wv.mu.RLock()
279 cb := d.wv.callbacks
280 d.wv.mu.RUnlock()
281 if cb == nil || cb.OnLinkMiddleClick == nil {
282 return fmt.Errorf("open link in new tab: middle-click handler not available")
283 }
284 cb.OnLinkMiddleClick(menuContext.LinkURI)
285 return nil
286 case port.MenuActionInspectElement:
287 d.wv.OpenDevTools()
288 return nil
289 case port.MenuActionCopySelection:
290 d.wv.RunJavaScript(ctx, "document.execCommand('copy');")
291 return nil
292 default:
293 return fmt.Errorf("cef menu delegator: unsupported action %s", action)
294 }
295}
296
297func lookupContextMenuCommandID(action port.MenuAction, label string, commandIDsByLabel map[string]int32) (int32, bool) {
298 for _, candidate := range contextMenuActionLabels(action, label) {
299 if cmdID, ok := commandIDsByLabel[strings.ToLower(candidate)]; ok {
300 return cmdID, true
301 }
302 }
303 return 0, false
304}
305
306func contextMenuActionLabels(action port.MenuAction, label string) []string {
307 labels := []string{label}
308 switch action {
309 case port.MenuActionBack:
310 labels = append(labels, "Back")
311 case port.MenuActionForward:
312 labels = append(labels, "Forward")
313 case port.MenuActionReload:
314 labels = append(labels, "Reload")
315 case port.MenuActionOpenLinkNewTab:
316 labels = append(labels, "Open Link in New Tab", "Open link in new tab")
317 case port.MenuActionCopyLink:
318 labels = append(labels, "Copy Link", "Copy link address")
319 case port.MenuActionCopyImage:
320 labels = append(labels, "Copy Image", "Copy image")
321 case port.MenuActionSaveImage:
322 labels = append(labels, "Save Image", "Save image as...")
323 case port.MenuActionInspectElement:
324 labels = append(labels, "Inspect Element", "Inspect")
325 case port.MenuActionCopySelection:
326 labels = append(labels, "Copy Selection", "Copy")
327 }
328 return labels
329}
330
331func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuContext {
332 menuContext := port.MenuContext{}
333 if wv != nil {
334 menuContext.CanGoBack = wv.CanGoBack()
335 menuContext.CanGoForward = wv.CanGoForward()
336 menuContext.PageURI = wv.URI()
337 }
338 if params == nil {
339 return menuContext
340 }
341
342 if pageURI := params.GetPageURL(); pageURI != "" {
343 menuContext.PageURI = pageURI
344 }
345 menuContext.LinkURI = params.GetLinkURL()
346 if params.HasImageContents() {
347 menuContext.ImageURI = params.GetSourceURL()
348 }
349 menuContext.SelectionText = params.GetSelectionText()
350 menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != ""
351 menuContext.IsEditable = params.IsEditable()
352 menuContext.X = int(params.GetXcoord())
353 menuContext.Y = int(params.GetYcoord())
354 return menuContext
355}
356
357func (h *handlerSet) OnContextMenuCommand(
358 _ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams,
359 _ int32, _ purecef.EventFlags,
360) int32 {
361 return 0
362}
363
364func (h *handlerSet) OnContextMenuDismissed(_ purecef.Browser, _ purecef.Frame) {}
365
366func (h *handlerSet) RunQuickMenu(
367 _ purecef.Browser, _ purecef.Frame, _ *purecef.Point, _ *purecef.Size,
368 _ purecef.QuickMenuEditStateFlags, _ purecef.RunQuickMenuCallback,
369) int32 {
370 return 0
371}
372
373func (h *handlerSet) OnQuickMenuCommand(_ purecef.Browser, _ purecef.Frame, _ int32, _ purecef.EventFlags) int32 {
374 return 0
375}
376
377func (h *handlerSet) OnQuickMenuDismissed(_ purecef.Browser, _ purecef.Frame) {}