detached 8c6c289

Merge pull request #259 from bnema/fix/cef-native-select-popup

fix(cef): render native popup surfaces without focus regressions
commit
8c6c28995795ddc3482411323727bbe7778e8511
author
bnema · · 2026-05-21 20:53 UTC
committer
GitHub · · 2026-05-21 20:53 UTC
merge of
68ec9b2 + ba10f7d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +575 -33
M go.mod +1 -2
@@ -7,7 +7,7 @@ require (
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
99 github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.5.1
10+ github.com/bnema/purego-cef2gtk v0.5.2
1111 github.com/bnema/purego-pipewire v0.1.3
1212 github.com/bnema/purego-sqlite v0.1.2
1313 github.com/bnema/puregotk v0.5.2
@@ -83,4 +83,3 @@ require (
8383 )
8484
8585 tool github.com/a-h/templ/cmd/templ
86-
@@ -7,7 +7,7 @@ require (
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9 github.com/bnema/purego-cef v0.13.09 github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.5.110+ github.com/bnema/purego-cef2gtk v0.5.2
11 github.com/bnema/purego-pipewire v0.1.311 github.com/bnema/purego-pipewire v0.1.3
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
13 github.com/bnema/puregotk v0.5.213 github.com/bnema/puregotk v0.5.2
@@ -83,4 +83,3 @@ require (
83 )83 )
84 84
85 tool github.com/a-h/templ/cmd/templ85 tool github.com/a-h/templ/cmd/templ
86-
M go.sum +2 -2 generated file: collapsed
M internal/infrastructure/cef/cef2gtk_adapter.go +45 -12
@@ -25,10 +25,11 @@ var ErrAdapterDestroyed = errors.New("cef2gtk_adapter: adapter is destroyed")
2525 // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input
2626 // controller implementation — those belong to purego-cef2gtk.
2727 type Cef2gtkAdapter struct {
28- viewMu sync.RWMutex
29- view *cef2gtk.View
30- destroyed atomic.Bool
31- destroyCnt atomic.Uint64
28+ viewMu sync.RWMutex
29+ view *cef2gtk.View
30+ inputTargetWidget *gtk.Widget
31+ destroyed atomic.Bool
32+ destroyCnt atomic.Uint64
3233 }
3334
3435 // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin
@@ -137,8 +138,8 @@ func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {
137138 return a.view.AddSizeObserver(fn)
138139 }
139140
140-// HasFocus reports whether the bridge widget currently has GTK focus. Must be
141-// called on the GTK main thread.
141+// HasFocus reports whether the effective GTK input target currently has focus.
142+// Must be called on the GTK main thread.
142143 func (a *Cef2gtkAdapter) HasFocus() bool {
143144 if a == nil || a.destroyed.Load() {
144145 return false
@@ -148,6 +149,9 @@ func (a *Cef2gtkAdapter) HasFocus() bool {
148149 if a.view == nil {
149150 return false
150151 }
152+ if a.inputTargetWidget != nil {
153+ return a.inputTargetWidget.HasFocus()
154+ }
151155 return a.view.HasFocus()
152156 }
153157
@@ -243,15 +247,41 @@ func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() {
243247 //
244248 // opts.Scale configures HiDPI scale for pointer coordinate translation.
245249 func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error {
250+ return a.AttachInputToWidget(host, nil, opts)
251+}
252+
253+// AttachInputToWidget attaches GTK event controllers to the specified target
254+// widget and forwards input to the given CEF browser host. When targetWidget is
255+// nil, it falls back to the bridge render widget. Must be called on the GTK
256+// main thread.
257+func (a *Cef2gtkAdapter) AttachInputToWidget(host purecef.BrowserHost, targetWidget *gtk.Widget, opts cef2gtk.InputOptions) error {
246258 if a == nil || a.destroyed.Load() {
247259 return ErrAdapterDestroyed
248260 }
249- a.viewMu.RLock()
250- defer a.viewMu.RUnlock()
261+ a.viewMu.Lock()
262+ defer a.viewMu.Unlock()
251263 if a.view == nil {
252264 return ErrAdapterDestroyed
253265 }
254- return a.view.AttachInput(host, opts)
266+ effectiveTarget := targetWidget
267+ if effectiveTarget == nil {
268+ effectiveTarget = a.view.Widget()
269+ }
270+ type inputWidgetAttacher interface {
271+ AttachInputToWidget(purecef.BrowserHost, *gtk.Widget, cef2gtk.InputOptions) error
272+ }
273+ var err error
274+ if attacher, ok := any(a.view).(inputWidgetAttacher); ok {
275+ err = attacher.AttachInputToWidget(host, effectiveTarget, opts)
276+ } else {
277+ err = a.view.AttachInput(host, opts)
278+ }
279+ if err != nil {
280+ a.inputTargetWidget = nil
281+ return err
282+ }
283+ a.inputTargetWidget = effectiveTarget
284+ return nil
255285 }
256286
257287 // SetInputHost updates the CEF browser host used by the attached input bridge.
@@ -275,12 +305,14 @@ func (a *Cef2gtkAdapter) DetachInput() error {
275305 if a == nil || a.destroyed.Load() {
276306 return ErrAdapterDestroyed
277307 }
278- a.viewMu.RLock()
279- defer a.viewMu.RUnlock()
308+ a.viewMu.Lock()
309+ defer a.viewMu.Unlock()
280310 if a.view == nil {
281311 return ErrAdapterDestroyed
282312 }
283- return a.view.DetachInput()
313+ err := a.view.DetachInput()
314+ a.inputTargetWidget = nil
315+ return err
284316 }
285317
286318 // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics
@@ -311,6 +343,7 @@ func (a *Cef2gtkAdapter) Destroy() error {
311343 }
312344 err := a.view.Destroy()
313345 a.view = nil
346+ a.inputTargetWidget = nil
314347 a.destroyCnt.Add(1)
315348 return err
316349 }
@@ -25,10 +25,11 @@ var ErrAdapterDestroyed = errors.New("cef2gtk_adapter: adapter is destroyed")
25 // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input25 // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input
26 // controller implementation — those belong to purego-cef2gtk.26 // controller implementation — those belong to purego-cef2gtk.
27 type Cef2gtkAdapter struct {27 type Cef2gtkAdapter struct {
28- viewMu sync.RWMutex28+ viewMu sync.RWMutex
29- view *cef2gtk.View29+ view *cef2gtk.View
30- destroyed atomic.Bool30+ inputTargetWidget *gtk.Widget
31- destroyCnt atomic.Uint6431+ destroyed atomic.Bool
32+ destroyCnt atomic.Uint64
32 }33 }
33 34
34 // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin35 // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin
@@ -137,8 +138,8 @@ func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {
137 return a.view.AddSizeObserver(fn)138 return a.view.AddSizeObserver(fn)
138 }139 }
139 140
140-// HasFocus reports whether the bridge widget currently has GTK focus. Must be141+// HasFocus reports whether the effective GTK input target currently has focus.
141-// called on the GTK main thread.142+// Must be called on the GTK main thread.
142 func (a *Cef2gtkAdapter) HasFocus() bool {143 func (a *Cef2gtkAdapter) HasFocus() bool {
143 if a == nil || a.destroyed.Load() {144 if a == nil || a.destroyed.Load() {
144 return false145 return false
@@ -148,6 +149,9 @@ func (a *Cef2gtkAdapter) HasFocus() bool {
148 if a.view == nil {149 if a.view == nil {
149 return false150 return false
150 }151 }
152+ if a.inputTargetWidget != nil {
153+ return a.inputTargetWidget.HasFocus()
154+ }
151 return a.view.HasFocus()155 return a.view.HasFocus()
152 }156 }
153 157
@@ -243,15 +247,41 @@ func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() {
243 //247 //
244 // opts.Scale configures HiDPI scale for pointer coordinate translation.248 // opts.Scale configures HiDPI scale for pointer coordinate translation.
245 func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error {249 func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error {
250+ return a.AttachInputToWidget(host, nil, opts)
251+}
252+
253+// AttachInputToWidget attaches GTK event controllers to the specified target
254+// widget and forwards input to the given CEF browser host. When targetWidget is
255+// nil, it falls back to the bridge render widget. Must be called on the GTK
256+// main thread.
257+func (a *Cef2gtkAdapter) AttachInputToWidget(host purecef.BrowserHost, targetWidget *gtk.Widget, opts cef2gtk.InputOptions) error {
246 if a == nil || a.destroyed.Load() {258 if a == nil || a.destroyed.Load() {
247 return ErrAdapterDestroyed259 return ErrAdapterDestroyed
248 }260 }
249- a.viewMu.RLock()261+ a.viewMu.Lock()
250- defer a.viewMu.RUnlock()262+ defer a.viewMu.Unlock()
251 if a.view == nil {263 if a.view == nil {
252 return ErrAdapterDestroyed264 return ErrAdapterDestroyed
253 }265 }
254- return a.view.AttachInput(host, opts)266+ effectiveTarget := targetWidget
267+ if effectiveTarget == nil {
268+ effectiveTarget = a.view.Widget()
269+ }
270+ type inputWidgetAttacher interface {
271+ AttachInputToWidget(purecef.BrowserHost, *gtk.Widget, cef2gtk.InputOptions) error
272+ }
273+ var err error
274+ if attacher, ok := any(a.view).(inputWidgetAttacher); ok {
275+ err = attacher.AttachInputToWidget(host, effectiveTarget, opts)
276+ } else {
277+ err = a.view.AttachInput(host, opts)
278+ }
279+ if err != nil {
280+ a.inputTargetWidget = nil
281+ return err
282+ }
283+ a.inputTargetWidget = effectiveTarget
284+ return nil
255 }285 }
256 286
257 // SetInputHost updates the CEF browser host used by the attached input bridge.287 // SetInputHost updates the CEF browser host used by the attached input bridge.
@@ -275,12 +305,14 @@ func (a *Cef2gtkAdapter) DetachInput() error {
275 if a == nil || a.destroyed.Load() {305 if a == nil || a.destroyed.Load() {
276 return ErrAdapterDestroyed306 return ErrAdapterDestroyed
277 }307 }
278- a.viewMu.RLock()308+ a.viewMu.Lock()
279- defer a.viewMu.RUnlock()309+ defer a.viewMu.Unlock()
280 if a.view == nil {310 if a.view == nil {
281 return ErrAdapterDestroyed311 return ErrAdapterDestroyed
282 }312 }
283- return a.view.DetachInput()313+ err := a.view.DetachInput()
314+ a.inputTargetWidget = nil
315+ return err
284 }316 }
285 317
286 // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics318 // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics
@@ -311,6 +343,7 @@ func (a *Cef2gtkAdapter) Destroy() error {
311 }343 }
312 err := a.view.Destroy()344 err := a.view.Destroy()
313 a.view = nil345 a.view = nil
346+ a.inputTargetWidget = nil
314 a.destroyCnt.Add(1)347 a.destroyCnt.Add(1)
315 return err348 return err
316 }349 }
M internal/infrastructure/cef/factory.go +11 -0
@@ -123,6 +123,17 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
123123 windowlessFrameRateMax: f.windowlessFrameRateMax,
124124 backgroundColor: f.bgColor.Load(),
125125 }
126+ wv.runOnGTKSync(func() {
127+ nativeWidget := viewBridge.Widget()
128+ popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan)
129+ wv.nativeWidget = nativeWidget
130+ if popupSurface != nil {
131+ wv.popupSurface = popupSurface
132+ if popupRoot := popupSurface.RootWidget(); popupRoot != nil {
133+ wv.nativeWidget = popupRoot
134+ }
135+ }
136+ })
126137
127138 handlers := &handlerSet{wv: wv}
128139 wv.handlers = handlers
@@ -123,6 +123,17 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
123 windowlessFrameRateMax: f.windowlessFrameRateMax,123 windowlessFrameRateMax: f.windowlessFrameRateMax,
124 backgroundColor: f.bgColor.Load(),124 backgroundColor: f.bgColor.Load(),
125 }125 }
126+ wv.runOnGTKSync(func() {
127+ nativeWidget := viewBridge.Widget()
128+ popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan)
129+ wv.nativeWidget = nativeWidget
130+ if popupSurface != nil {
131+ wv.popupSurface = popupSurface
132+ if popupRoot := popupSurface.RootWidget(); popupRoot != nil {
133+ wv.nativeWidget = popupRoot
134+ }
135+ }
136+ })
126 137
127 handlers := &handlerSet{wv: wv}138 handlers := &handlerSet{wv: wv}
128 wv.handlers = handlers139 wv.handlers = handlers
A internal/infrastructure/cef/popup_surface.go +156 -0
@@ -0,0 +1,156 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cef2gtk "github.com/bnema/purego-cef2gtk"
9+ "github.com/bnema/puregotk/v4/glib"
10+ "github.com/bnema/puregotk/v4/gtk"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type popupBridgeSurface struct {
16+ ctx context.Context
17+ root *gtk.Widget
18+ mainWidget *gtk.Widget
19+ bridge *Cef2gtkAdapter
20+ popupWidget *gtk.Widget
21+ prepared atomic.Uint32
22+}
23+
24+// newPopupBridgeSurface must be called on the GTK thread because it creates
25+// and packs GTK widgets around the primary CEF widget.
26+func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef2gtk.RenderStackPlan) *popupBridgeSurface {
27+ mainContext := glib.MainContextDefault()
28+ if mainContext == nil || !mainContext.IsOwner() {
29+ logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")
30+ return nil
31+ }
32+ if mainWidget == nil {
33+ return nil
34+ }
35+ bridge := NewCef2gtkAdapter(plan)
36+ if bridge == nil {
37+ return nil
38+ }
39+ popupWidget := bridge.Widget()
40+ if popupWidget == nil {
41+ _ = bridge.Destroy()
42+ return nil
43+ }
44+ overlay := gtk.NewOverlay()
45+ if overlay == nil {
46+ _ = bridge.Destroy()
47+ return nil
48+ }
49+ overlay.SetChild(mainWidget)
50+ popupWidget.SetVisible(false)
51+ popupWidget.SetHalign(gtk.AlignStartValue)
52+ popupWidget.SetValign(gtk.AlignStartValue)
53+ popupWidget.SetHexpand(false)
54+ popupWidget.SetVexpand(false)
55+ popupWidget.SetCanTarget(false)
56+ popupWidget.SetCanFocus(false)
57+ popupWidget.SetFocusable(false)
58+ overlay.AddOverlay(popupWidget)
59+ overlay.SetClipOverlay(popupWidget, true)
60+ overlay.SetMeasureOverlay(popupWidget, false)
61+ root := &overlay.Widget
62+ root.SetHexpand(true)
63+ root.SetVexpand(true)
64+ root.SetCanFocus(true)
65+ root.SetFocusable(true)
66+ root.SetFocusOnClick(true)
67+ return &popupBridgeSurface{
68+ ctx: ctx,
69+ root: root,
70+ mainWidget: mainWidget,
71+ bridge: bridge,
72+ popupWidget: popupWidget,
73+ }
74+}
75+
76+func (s *popupBridgeSurface) RootWidget() *gtk.Widget {
77+ if s == nil {
78+ return nil
79+ }
80+ return s.root
81+}
82+
83+func (s *popupBridgeSurface) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
84+ if s == nil || s.bridge == nil {
85+ return nil
86+ }
87+ return s.bridge.RenderHandler(hooks)
88+}
89+
90+func (s *popupBridgeSurface) PrepareOnGTKThread() error {
91+ if s == nil || s.bridge == nil {
92+ return nil
93+ }
94+ if !s.prepared.CompareAndSwap(0, 1) {
95+ return nil
96+ }
97+ if err := s.bridge.PrepareOnGTKThread(); err != nil {
98+ s.prepared.Store(0)
99+ return err
100+ }
101+ return nil
102+}
103+
104+func (s *popupBridgeSurface) SetPopupVisible(visible bool) {
105+ if s == nil || s.popupWidget == nil {
106+ return
107+ }
108+ if visible {
109+ if err := s.PrepareOnGTKThread(); err != nil {
110+ logging.FromContext(popupSurfaceLogContext(s.ctx)).Error().
111+ Err(err).
112+ Msg("cef: popupBridgeSurface.SetPopupVisible failed to PrepareOnGTKThread")
113+ return
114+ }
115+ }
116+ s.popupWidget.SetVisible(visible)
117+ if s.root != nil {
118+ s.root.QueueAllocate()
119+ }
120+}
121+
122+func (s *popupBridgeSurface) SetPopupRect(rect purecef.Rect) {
123+ if s == nil || s.popupWidget == nil {
124+ return
125+ }
126+ if rect.Width <= 0 || rect.Height <= 0 {
127+ s.popupWidget.SetVisible(false)
128+ return
129+ }
130+ s.popupWidget.SetMarginStart(int(rect.X))
131+ s.popupWidget.SetMarginTop(int(rect.Y))
132+ s.popupWidget.SetSizeRequest(int(rect.Width), int(rect.Height))
133+ s.popupWidget.QueueResize()
134+ if s.root != nil {
135+ s.root.QueueResize()
136+ }
137+}
138+
139+func popupSurfaceLogContext(ctx context.Context) context.Context {
140+ if ctx != nil {
141+ return ctx
142+ }
143+ return context.Background()
144+}
145+
146+func (s *popupBridgeSurface) DestroyOnGTKThread() {
147+ if s == nil || s.bridge == nil {
148+ return
149+ }
150+ _ = s.bridge.Destroy()
151+ s.bridge = nil
152+ s.popupWidget = nil
153+ s.mainWidget = nil
154+ s.root = nil
155+ s.prepared.Store(0)
156+}
@@ -0,0 +1,156 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cef2gtk "github.com/bnema/purego-cef2gtk"
9+ "github.com/bnema/puregotk/v4/glib"
10+ "github.com/bnema/puregotk/v4/gtk"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type popupBridgeSurface struct {
16+ ctx context.Context
17+ root *gtk.Widget
18+ mainWidget *gtk.Widget
19+ bridge *Cef2gtkAdapter
20+ popupWidget *gtk.Widget
21+ prepared atomic.Uint32
22+}
23+
24+// newPopupBridgeSurface must be called on the GTK thread because it creates
25+// and packs GTK widgets around the primary CEF widget.
26+func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef2gtk.RenderStackPlan) *popupBridgeSurface {
27+ mainContext := glib.MainContextDefault()
28+ if mainContext == nil || !mainContext.IsOwner() {
29+ logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")
30+ return nil
31+ }
32+ if mainWidget == nil {
33+ return nil
34+ }
35+ bridge := NewCef2gtkAdapter(plan)
36+ if bridge == nil {
37+ return nil
38+ }
39+ popupWidget := bridge.Widget()
40+ if popupWidget == nil {
41+ _ = bridge.Destroy()
42+ return nil
43+ }
44+ overlay := gtk.NewOverlay()
45+ if overlay == nil {
46+ _ = bridge.Destroy()
47+ return nil
48+ }
49+ overlay.SetChild(mainWidget)
50+ popupWidget.SetVisible(false)
51+ popupWidget.SetHalign(gtk.AlignStartValue)
52+ popupWidget.SetValign(gtk.AlignStartValue)
53+ popupWidget.SetHexpand(false)
54+ popupWidget.SetVexpand(false)
55+ popupWidget.SetCanTarget(false)
56+ popupWidget.SetCanFocus(false)
57+ popupWidget.SetFocusable(false)
58+ overlay.AddOverlay(popupWidget)
59+ overlay.SetClipOverlay(popupWidget, true)
60+ overlay.SetMeasureOverlay(popupWidget, false)
61+ root := &overlay.Widget
62+ root.SetHexpand(true)
63+ root.SetVexpand(true)
64+ root.SetCanFocus(true)
65+ root.SetFocusable(true)
66+ root.SetFocusOnClick(true)
67+ return &popupBridgeSurface{
68+ ctx: ctx,
69+ root: root,
70+ mainWidget: mainWidget,
71+ bridge: bridge,
72+ popupWidget: popupWidget,
73+ }
74+}
75+
76+func (s *popupBridgeSurface) RootWidget() *gtk.Widget {
77+ if s == nil {
78+ return nil
79+ }
80+ return s.root
81+}
82+
83+func (s *popupBridgeSurface) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
84+ if s == nil || s.bridge == nil {
85+ return nil
86+ }
87+ return s.bridge.RenderHandler(hooks)
88+}
89+
90+func (s *popupBridgeSurface) PrepareOnGTKThread() error {
91+ if s == nil || s.bridge == nil {
92+ return nil
93+ }
94+ if !s.prepared.CompareAndSwap(0, 1) {
95+ return nil
96+ }
97+ if err := s.bridge.PrepareOnGTKThread(); err != nil {
98+ s.prepared.Store(0)
99+ return err
100+ }
101+ return nil
102+}
103+
104+func (s *popupBridgeSurface) SetPopupVisible(visible bool) {
105+ if s == nil || s.popupWidget == nil {
106+ return
107+ }
108+ if visible {
109+ if err := s.PrepareOnGTKThread(); err != nil {
110+ logging.FromContext(popupSurfaceLogContext(s.ctx)).Error().
111+ Err(err).
112+ Msg("cef: popupBridgeSurface.SetPopupVisible failed to PrepareOnGTKThread")
113+ return
114+ }
115+ }
116+ s.popupWidget.SetVisible(visible)
117+ if s.root != nil {
118+ s.root.QueueAllocate()
119+ }
120+}
121+
122+func (s *popupBridgeSurface) SetPopupRect(rect purecef.Rect) {
123+ if s == nil || s.popupWidget == nil {
124+ return
125+ }
126+ if rect.Width <= 0 || rect.Height <= 0 {
127+ s.popupWidget.SetVisible(false)
128+ return
129+ }
130+ s.popupWidget.SetMarginStart(int(rect.X))
131+ s.popupWidget.SetMarginTop(int(rect.Y))
132+ s.popupWidget.SetSizeRequest(int(rect.Width), int(rect.Height))
133+ s.popupWidget.QueueResize()
134+ if s.root != nil {
135+ s.root.QueueResize()
136+ }
137+}
138+
139+func popupSurfaceLogContext(ctx context.Context) context.Context {
140+ if ctx != nil {
141+ return ctx
142+ }
143+ return context.Background()
144+}
145+
146+func (s *popupBridgeSurface) DestroyOnGTKThread() {
147+ if s == nil || s.bridge == nil {
148+ return
149+ }
150+ _ = s.bridge.Destroy()
151+ s.bridge = nil
152+ s.popupWidget = nil
153+ s.mainWidget = nil
154+ s.root = nil
155+ s.prepared.Store(0)
156+}
M internal/infrastructure/cef/render_handler_adapter.go +226 -2
@@ -9,12 +9,28 @@ import (
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
12+type popupSurface interface {
13+ SetPopupVisible(bool)
14+ SetPopupRect(purecef.Rect)
15+}
16+
17+type dumberRenderHandler struct {
18+ wv *WebView
19+ main purecef.RenderHandler
20+ popup purecef.RenderHandler
21+ popupSurface popupSurface
22+}
23+
24+var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
25+
1226 func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
1327 if wv == nil || wv.viewBridge == nil {
1428 return nil
1529 }
30+ h := &dumberRenderHandler{wv: wv}
31+
1632 var unsupportedPaintOnce sync.Once
17- hooks := cef2gtk.Hooks{
33+ h.main = wv.viewBridge.RenderHandler(cef2gtk.Hooks{
1834 OnUnsupportedPaint: func() {
1935 unsupportedPaintOnce.Do(func() {
2036 if wv.ctx != nil {
@@ -30,8 +46,216 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
3046 OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) {
3147 handleRenderTextSelectionChanged(wv, selectedText)
3248 },
49+ })
50+
51+ if wv.popupSurface != nil {
52+ var popupUnsupportedPaintOnce sync.Once
53+ h.popupSurface = wv.popupSurface
54+ h.popup = wv.popupSurface.RenderHandler(cef2gtk.Hooks{
55+ OnUnsupportedPaint: func() {
56+ popupUnsupportedPaintOnce.Do(func() {
57+ if wv.ctx != nil {
58+ logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from popup surface")
59+ }
60+ })
61+ },
62+ OnError: func(err error) {
63+ if wv.ctx != nil {
64+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: popup surface render error")
65+ }
66+ },
67+ })
68+ }
69+
70+ return h
71+}
72+
73+func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElementType) purecef.RenderHandler {
74+ if h == nil {
75+ return nil
76+ }
77+ if elementType == purecef.PaintElementTypePetPopup && h.popup != nil {
78+ return h.popup
79+ }
80+ return h.main
81+}
82+
83+func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {
84+ if h == nil || h.main == nil {
85+ return nil
86+ }
87+ return h.main.GetAccessibilityHandler()
88+}
89+
90+func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {
91+ if h == nil || h.main == nil {
92+ return 0
93+ }
94+ return h.main.GetRootScreenRect(browser, rect)
95+}
96+
97+func (h *dumberRenderHandler) GetViewRect(browser purecef.Browser, rect *purecef.Rect) {
98+ if h == nil || h.main == nil {
99+ if rect != nil {
100+ rect.X, rect.Y, rect.Width, rect.Height = 0, 0, 1, 1
101+ }
102+ return
103+ }
104+ h.main.GetViewRect(browser, rect)
105+}
106+
107+func (h *dumberRenderHandler) GetScreenPoint(browser purecef.Browser, viewX, viewY int32, screenX, screenY *int32) int32 {
108+ if h == nil || h.main == nil {
109+ return 0
110+ }
111+ return h.main.GetScreenPoint(browser, viewX, viewY, screenX, screenY)
112+}
113+
114+func (h *dumberRenderHandler) GetScreenInfo(browser purecef.Browser, info *purecef.ScreenInfo) int32 {
115+ if h == nil || h.main == nil {
116+ return 0
117+ }
118+ return h.main.GetScreenInfo(browser, info)
119+}
120+
121+func (h *dumberRenderHandler) OnPopupShow(browser purecef.Browser, show int32) {
122+ if h != nil && h.popupSurface != nil {
123+ visible := show != 0
124+ if h.wv != nil {
125+ h.wv.runOnGTKSync(func() {
126+ h.popupSurface.SetPopupVisible(visible)
127+ })
128+ } else {
129+ h.popupSurface.SetPopupVisible(visible)
130+ }
131+ }
132+ if h != nil && h.popup != nil {
133+ h.popup.OnPopupShow(browser, show)
134+ }
135+}
136+
137+func (h *dumberRenderHandler) OnPopupSize(browser purecef.Browser, rect *purecef.Rect) {
138+ if h != nil && h.popupSurface != nil && rect != nil {
139+ popupRect := *rect
140+ if h.wv != nil {
141+ popupRect = popupRectForWidget(h.wv, popupRect)
142+ h.wv.runOnGTKSync(func() {
143+ h.popupSurface.SetPopupRect(popupRect)
144+ })
145+ } else {
146+ h.popupSurface.SetPopupRect(popupRect)
147+ }
148+ }
149+ if h != nil && h.popup != nil {
150+ h.popup.OnPopupSize(browser, rect)
151+ }
152+}
153+
154+func popupRectForWidget(wv *WebView, rect purecef.Rect) purecef.Rect {
155+ if wv == nil {
156+ return rect
157+ }
158+ // Only convert when the OSR backing-scale compatibility path is active.
159+ // A monitor scale above 1 is not enough by itself because CEF already reports
160+ // logical popup coordinates when backing-scale mode is off.
161+ if wv.osrBackingScaleFactor() <= 1 {
162+ return rect
163+ }
164+ scale := wv.viewBridgeScale()
165+ return purecef.Rect{
166+ X: deviceToLogicalCoord(rect.X, scale),
167+ Y: deviceToLogicalCoord(rect.Y, scale),
168+ Width: deviceToLogicalSize(rect.Width, scale),
169+ Height: deviceToLogicalSize(rect.Height, scale),
170+ }
171+}
172+
173+func (h *dumberRenderHandler) OnPaint(
174+ browser purecef.Browser,
175+ elementType purecef.PaintElementType,
176+ dirtyRects []purecef.Rect,
177+ buffer []byte,
178+ width, height int32,
179+) {
180+ if delegate := h.renderTargetForPaint(elementType); delegate != nil {
181+ delegate.OnPaint(browser, elementType, dirtyRects, buffer, width, height)
182+ }
183+}
184+
185+func (h *dumberRenderHandler) OnAcceleratedPaint(
186+ browser purecef.Browser,
187+ elementType purecef.PaintElementType,
188+ dirtyRects []purecef.Rect,
189+ info *purecef.AcceleratedPaintInfo,
190+) {
191+ if delegate := h.renderTargetForPaint(elementType); delegate != nil {
192+ delegate.OnAcceleratedPaint(browser, elementType, dirtyRects, info)
193+ }
194+}
195+
196+func (h *dumberRenderHandler) GetTouchHandleSize(browser purecef.Browser, orientation purecef.HorizontalAlignment, size *purecef.Size) {
197+ if h == nil || h.main == nil {
198+ return
199+ }
200+ h.main.GetTouchHandleSize(browser, orientation, size)
201+}
202+
203+func (h *dumberRenderHandler) OnTouchHandleStateChanged(browser purecef.Browser, state *purecef.TouchHandleState) {
204+ if h == nil || h.main == nil {
205+ return
206+ }
207+ h.main.OnTouchHandleStateChanged(browser, state)
208+}
209+
210+func (h *dumberRenderHandler) StartDragging(
211+ browser purecef.Browser,
212+ dragData purecef.DragData,
213+ allowedOps purecef.DragOperationsMask,
214+ x, y int32,
215+) int32 {
216+ if h == nil || h.main == nil {
217+ return 0
218+ }
219+ return h.main.StartDragging(browser, dragData, allowedOps, x, y)
220+}
221+
222+func (h *dumberRenderHandler) UpdateDragCursor(browser purecef.Browser, operation purecef.DragOperationsMask) {
223+ if h == nil || h.main == nil {
224+ return
225+ }
226+ h.main.UpdateDragCursor(browser, operation)
227+}
228+
229+func (h *dumberRenderHandler) OnScrollOffsetChanged(browser purecef.Browser, x, y float64) {
230+ if h == nil || h.main == nil {
231+ return
232+ }
233+ h.main.OnScrollOffsetChanged(browser, x, y)
234+}
235+
236+func (h *dumberRenderHandler) OnImeCompositionRangeChanged(
237+ browser purecef.Browser,
238+ selectedRange *purecef.Range,
239+ characterBounds []purecef.Rect,
240+) {
241+ if h == nil || h.main == nil {
242+ return
243+ }
244+ h.main.OnImeCompositionRangeChanged(browser, selectedRange, characterBounds)
245+}
246+
247+func (h *dumberRenderHandler) OnTextSelectionChanged(browser purecef.Browser, selectedText string, selectedRange *purecef.Range) {
248+ if h == nil || h.main == nil {
249+ return
250+ }
251+ h.main.OnTextSelectionChanged(browser, selectedText, selectedRange)
252+}
253+
254+func (h *dumberRenderHandler) OnVirtualKeyboardRequested(browser purecef.Browser, mode purecef.TextInputMode) {
255+ if h == nil || h.main == nil {
256+ return
33257 }
34- return wv.viewBridge.RenderHandler(hooks)
258+ h.main.OnVirtualKeyboardRequested(browser, mode)
35259 }
36260
37261 func handleRenderTextSelectionChanged(wv *WebView, selectedText string) {
@@ -9,12 +9,28 @@ import (
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 )10 )
11 11
12+type popupSurface interface {
13+ SetPopupVisible(bool)
14+ SetPopupRect(purecef.Rect)
15+}
16+
17+type dumberRenderHandler struct {
18+ wv *WebView
19+ main purecef.RenderHandler
20+ popup purecef.RenderHandler
21+ popupSurface popupSurface
22+}
23+
24+var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
25+
12 func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {26 func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
13 if wv == nil || wv.viewBridge == nil {27 if wv == nil || wv.viewBridge == nil {
14 return nil28 return nil
15 }29 }
30+ h := &dumberRenderHandler{wv: wv}
31+
16 var unsupportedPaintOnce sync.Once32 var unsupportedPaintOnce sync.Once
17- hooks := cef2gtk.Hooks{33+ h.main = wv.viewBridge.RenderHandler(cef2gtk.Hooks{
18 OnUnsupportedPaint: func() {34 OnUnsupportedPaint: func() {
19 unsupportedPaintOnce.Do(func() {35 unsupportedPaintOnce.Do(func() {
20 if wv.ctx != nil {36 if wv.ctx != nil {
@@ -30,8 +46,216 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
30 OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) {46 OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) {
31 handleRenderTextSelectionChanged(wv, selectedText)47 handleRenderTextSelectionChanged(wv, selectedText)
32 },48 },
49+ })
50+
51+ if wv.popupSurface != nil {
52+ var popupUnsupportedPaintOnce sync.Once
53+ h.popupSurface = wv.popupSurface
54+ h.popup = wv.popupSurface.RenderHandler(cef2gtk.Hooks{
55+ OnUnsupportedPaint: func() {
56+ popupUnsupportedPaintOnce.Do(func() {
57+ if wv.ctx != nil {
58+ logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from popup surface")
59+ }
60+ })
61+ },
62+ OnError: func(err error) {
63+ if wv.ctx != nil {
64+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: popup surface render error")
65+ }
66+ },
67+ })
68+ }
69+
70+ return h
71+}
72+
73+func (h *dumberRenderHandler) renderTargetForPaint(elementType purecef.PaintElementType) purecef.RenderHandler {
74+ if h == nil {
75+ return nil
76+ }
77+ if elementType == purecef.PaintElementTypePetPopup && h.popup != nil {
78+ return h.popup
79+ }
80+ return h.main
81+}
82+
83+func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {
84+ if h == nil || h.main == nil {
85+ return nil
86+ }
87+ return h.main.GetAccessibilityHandler()
88+}
89+
90+func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {
91+ if h == nil || h.main == nil {
92+ return 0
93+ }
94+ return h.main.GetRootScreenRect(browser, rect)
95+}
96+
97+func (h *dumberRenderHandler) GetViewRect(browser purecef.Browser, rect *purecef.Rect) {
98+ if h == nil || h.main == nil {
99+ if rect != nil {
100+ rect.X, rect.Y, rect.Width, rect.Height = 0, 0, 1, 1
101+ }
102+ return
103+ }
104+ h.main.GetViewRect(browser, rect)
105+}
106+
107+func (h *dumberRenderHandler) GetScreenPoint(browser purecef.Browser, viewX, viewY int32, screenX, screenY *int32) int32 {
108+ if h == nil || h.main == nil {
109+ return 0
110+ }
111+ return h.main.GetScreenPoint(browser, viewX, viewY, screenX, screenY)
112+}
113+
114+func (h *dumberRenderHandler) GetScreenInfo(browser purecef.Browser, info *purecef.ScreenInfo) int32 {
115+ if h == nil || h.main == nil {
116+ return 0
117+ }
118+ return h.main.GetScreenInfo(browser, info)
119+}
120+
121+func (h *dumberRenderHandler) OnPopupShow(browser purecef.Browser, show int32) {
122+ if h != nil && h.popupSurface != nil {
123+ visible := show != 0
124+ if h.wv != nil {
125+ h.wv.runOnGTKSync(func() {
126+ h.popupSurface.SetPopupVisible(visible)
127+ })
128+ } else {
129+ h.popupSurface.SetPopupVisible(visible)
130+ }
131+ }
132+ if h != nil && h.popup != nil {
133+ h.popup.OnPopupShow(browser, show)
134+ }
135+}
136+
137+func (h *dumberRenderHandler) OnPopupSize(browser purecef.Browser, rect *purecef.Rect) {
138+ if h != nil && h.popupSurface != nil && rect != nil {
139+ popupRect := *rect
140+ if h.wv != nil {
141+ popupRect = popupRectForWidget(h.wv, popupRect)
142+ h.wv.runOnGTKSync(func() {
143+ h.popupSurface.SetPopupRect(popupRect)
144+ })
145+ } else {
146+ h.popupSurface.SetPopupRect(popupRect)
147+ }
148+ }
149+ if h != nil && h.popup != nil {
150+ h.popup.OnPopupSize(browser, rect)
151+ }
152+}
153+
154+func popupRectForWidget(wv *WebView, rect purecef.Rect) purecef.Rect {
155+ if wv == nil {
156+ return rect
157+ }
158+ // Only convert when the OSR backing-scale compatibility path is active.
159+ // A monitor scale above 1 is not enough by itself because CEF already reports
160+ // logical popup coordinates when backing-scale mode is off.
161+ if wv.osrBackingScaleFactor() <= 1 {
162+ return rect
163+ }
164+ scale := wv.viewBridgeScale()
165+ return purecef.Rect{
166+ X: deviceToLogicalCoord(rect.X, scale),
167+ Y: deviceToLogicalCoord(rect.Y, scale),
168+ Width: deviceToLogicalSize(rect.Width, scale),
169+ Height: deviceToLogicalSize(rect.Height, scale),
170+ }
171+}
172+
173+func (h *dumberRenderHandler) OnPaint(
174+ browser purecef.Browser,
175+ elementType purecef.PaintElementType,
176+ dirtyRects []purecef.Rect,
177+ buffer []byte,
178+ width, height int32,
179+) {
180+ if delegate := h.renderTargetForPaint(elementType); delegate != nil {
181+ delegate.OnPaint(browser, elementType, dirtyRects, buffer, width, height)
182+ }
183+}
184+
185+func (h *dumberRenderHandler) OnAcceleratedPaint(
186+ browser purecef.Browser,
187+ elementType purecef.PaintElementType,
188+ dirtyRects []purecef.Rect,
189+ info *purecef.AcceleratedPaintInfo,
190+) {
191+ if delegate := h.renderTargetForPaint(elementType); delegate != nil {
192+ delegate.OnAcceleratedPaint(browser, elementType, dirtyRects, info)
193+ }
194+}
195+
196+func (h *dumberRenderHandler) GetTouchHandleSize(browser purecef.Browser, orientation purecef.HorizontalAlignment, size *purecef.Size) {
197+ if h == nil || h.main == nil {
198+ return
199+ }
200+ h.main.GetTouchHandleSize(browser, orientation, size)
201+}
202+
203+func (h *dumberRenderHandler) OnTouchHandleStateChanged(browser purecef.Browser, state *purecef.TouchHandleState) {
204+ if h == nil || h.main == nil {
205+ return
206+ }
207+ h.main.OnTouchHandleStateChanged(browser, state)
208+}
209+
210+func (h *dumberRenderHandler) StartDragging(
211+ browser purecef.Browser,
212+ dragData purecef.DragData,
213+ allowedOps purecef.DragOperationsMask,
214+ x, y int32,
215+) int32 {
216+ if h == nil || h.main == nil {
217+ return 0
218+ }
219+ return h.main.StartDragging(browser, dragData, allowedOps, x, y)
220+}
221+
222+func (h *dumberRenderHandler) UpdateDragCursor(browser purecef.Browser, operation purecef.DragOperationsMask) {
223+ if h == nil || h.main == nil {
224+ return
225+ }
226+ h.main.UpdateDragCursor(browser, operation)
227+}
228+
229+func (h *dumberRenderHandler) OnScrollOffsetChanged(browser purecef.Browser, x, y float64) {
230+ if h == nil || h.main == nil {
231+ return
232+ }
233+ h.main.OnScrollOffsetChanged(browser, x, y)
234+}
235+
236+func (h *dumberRenderHandler) OnImeCompositionRangeChanged(
237+ browser purecef.Browser,
238+ selectedRange *purecef.Range,
239+ characterBounds []purecef.Rect,
240+) {
241+ if h == nil || h.main == nil {
242+ return
243+ }
244+ h.main.OnImeCompositionRangeChanged(browser, selectedRange, characterBounds)
245+}
246+
247+func (h *dumberRenderHandler) OnTextSelectionChanged(browser purecef.Browser, selectedText string, selectedRange *purecef.Range) {
248+ if h == nil || h.main == nil {
249+ return
250+ }
251+ h.main.OnTextSelectionChanged(browser, selectedText, selectedRange)
252+}
253+
254+func (h *dumberRenderHandler) OnVirtualKeyboardRequested(browser purecef.Browser, mode purecef.TextInputMode) {
255+ if h == nil || h.main == nil {
256+ return
33 }257 }
34- return wv.viewBridge.RenderHandler(hooks)258+ h.main.OnVirtualKeyboardRequested(browser, mode)
35 }259 }
36 260
37 func handleRenderTextSelectionChanged(wv *WebView, selectedText string) {261 func handleRenderTextSelectionChanged(wv *WebView, selectedText string) {
A internal/infrastructure/cef/render_handler_popup_test.go +71 -0
@@ -0,0 +1,71 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type popupSurfaceRecorder struct {
12+ visible bool
13+ rect purecef.Rect
14+ calls []string
15+}
16+
17+func (r *popupSurfaceRecorder) SetPopupVisible(visible bool) {
18+ r.visible = visible
19+ r.calls = append(r.calls, "visible")
20+}
21+
22+func (r *popupSurfaceRecorder) SetPopupRect(rect purecef.Rect) {
23+ r.rect = rect
24+ r.calls = append(r.calls, "rect")
25+}
26+
27+func TestDumberRenderHandler_RoutesPopupAcceleratedPaintToPopupSurface(t *testing.T) {
28+ mainHandler := cefmocks.NewMockRenderHandler(t)
29+ popupHandler := cefmocks.NewMockRenderHandler(t)
30+ h := &dumberRenderHandler{
31+ main: mainHandler,
32+ popup: popupHandler,
33+ }
34+
35+ info := &purecef.AcceleratedPaintInfo{}
36+ popupHandler.EXPECT().OnAcceleratedPaint(nil, purecef.PaintElementTypePetPopup, []purecef.Rect(nil), info).Return().Once()
37+
38+ h.OnAcceleratedPaint(nil, purecef.PaintElementTypePetPopup, []purecef.Rect(nil), info)
39+
40+ mainHandler.AssertNotCalled(t, "OnAcceleratedPaint", nil, purecef.PaintElementTypePetPopup, []purecef.Rect(nil), info)
41+}
42+
43+func TestDumberRenderHandler_TracksPopupLifecycle(t *testing.T) {
44+ recorder := &popupSurfaceRecorder{}
45+ // With a nil engine, WebView.runOnGTK/webview.go executes inline so popup
46+ // lifecycle updates stay synchronous in this unit test.
47+ wv := &WebView{}
48+ wv.engine = nil
49+ h := &dumberRenderHandler{
50+ wv: wv,
51+ popupSurface: recorder,
52+ }
53+
54+ popupRect := &purecef.Rect{X: 12, Y: 34, Width: 180, Height: 240}
55+ h.OnPopupShow(nil, 1)
56+ h.OnPopupSize(nil, popupRect)
57+ h.OnPopupShow(nil, 0)
58+
59+ assert.Equal(t, []string{"visible", "rect", "visible"}, recorder.calls)
60+ assert.Equal(t, purecef.Rect{X: 12, Y: 34, Width: 180, Height: 240}, recorder.rect)
61+ assert.False(t, recorder.visible)
62+}
63+
64+func TestDeviceToLogicalSize_RoundsUp(t *testing.T) {
65+ assert.Equal(t, int32(4), deviceToLogicalSize(5, 1.25))
66+ assert.Equal(t, int32(1), deviceToLogicalSize(1, 1.5))
67+ assert.Equal(t, int32(0), deviceToLogicalSize(0, 2.0))
68+ assert.Equal(t, int32(0), deviceToLogicalSize(-1, 2.0))
69+ assert.Equal(t, int32(10), deviceToLogicalSize(10, 1.0))
70+ assert.Equal(t, int32(1), deviceToLogicalSize(1, 100.0))
71+}
@@ -0,0 +1,71 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type popupSurfaceRecorder struct {
12+ visible bool
13+ rect purecef.Rect
14+ calls []string
15+}
16+
17+func (r *popupSurfaceRecorder) SetPopupVisible(visible bool) {
18+ r.visible = visible
19+ r.calls = append(r.calls, "visible")
20+}
21+
22+func (r *popupSurfaceRecorder) SetPopupRect(rect purecef.Rect) {
23+ r.rect = rect
24+ r.calls = append(r.calls, "rect")
25+}
26+
27+func TestDumberRenderHandler_RoutesPopupAcceleratedPaintToPopupSurface(t *testing.T) {
28+ mainHandler := cefmocks.NewMockRenderHandler(t)
29+ popupHandler := cefmocks.NewMockRenderHandler(t)
30+ h := &dumberRenderHandler{
31+ main: mainHandler,
32+ popup: popupHandler,
33+ }
34+
35+ info := &purecef.AcceleratedPaintInfo{}
36+ popupHandler.EXPECT().OnAcceleratedPaint(nil, purecef.PaintElementTypePetPopup, []purecef.Rect(nil), info).Return().Once()
37+
38+ h.OnAcceleratedPaint(nil, purecef.PaintElementTypePetPopup, []purecef.Rect(nil), info)
39+
40+ mainHandler.AssertNotCalled(t, "OnAcceleratedPaint", nil, purecef.PaintElementTypePetPopup, []purecef.Rect(nil), info)
41+}
42+
43+func TestDumberRenderHandler_TracksPopupLifecycle(t *testing.T) {
44+ recorder := &popupSurfaceRecorder{}
45+ // With a nil engine, WebView.runOnGTK/webview.go executes inline so popup
46+ // lifecycle updates stay synchronous in this unit test.
47+ wv := &WebView{}
48+ wv.engine = nil
49+ h := &dumberRenderHandler{
50+ wv: wv,
51+ popupSurface: recorder,
52+ }
53+
54+ popupRect := &purecef.Rect{X: 12, Y: 34, Width: 180, Height: 240}
55+ h.OnPopupShow(nil, 1)
56+ h.OnPopupSize(nil, popupRect)
57+ h.OnPopupShow(nil, 0)
58+
59+ assert.Equal(t, []string{"visible", "rect", "visible"}, recorder.calls)
60+ assert.Equal(t, purecef.Rect{X: 12, Y: 34, Width: 180, Height: 240}, recorder.rect)
61+ assert.False(t, recorder.visible)
62+}
63+
64+func TestDeviceToLogicalSize_RoundsUp(t *testing.T) {
65+ assert.Equal(t, int32(4), deviceToLogicalSize(5, 1.25))
66+ assert.Equal(t, int32(1), deviceToLogicalSize(1, 1.5))
67+ assert.Equal(t, int32(0), deviceToLogicalSize(0, 2.0))
68+ assert.Equal(t, int32(0), deviceToLogicalSize(-1, 2.0))
69+ assert.Equal(t, int32(10), deviceToLogicalSize(10, 1.0))
70+ assert.Equal(t, int32(1), deviceToLogicalSize(1, 100.0))
71+}
M internal/infrastructure/cef/scale.go +11 -0
@@ -12,3 +12,14 @@ func normalizeScale(scale float64) float64 {
1212 func deviceToLogicalCoord(value int32, scale float64) int32 {
1313 return int32(math.Floor(float64(value) / normalizeScale(scale)))
1414 }
15+
16+func deviceToLogicalSize(value int32, scale float64) int32 {
17+ if value <= 0 {
18+ return 0
19+ }
20+ scaled := int32(math.Ceil(float64(value) / normalizeScale(scale)))
21+ if scaled < 1 {
22+ return 1
23+ }
24+ return scaled
25+}
@@ -12,3 +12,14 @@ func normalizeScale(scale float64) float64 {
12 func deviceToLogicalCoord(value int32, scale float64) int32 {12 func deviceToLogicalCoord(value int32, scale float64) int32 {
13 return int32(math.Floor(float64(value) / normalizeScale(scale)))13 return int32(math.Floor(float64(value) / normalizeScale(scale)))
14 }14 }
15+
16+func deviceToLogicalSize(value int32, scale float64) int32 {
17+ if value <= 0 {
18+ return 0
19+ }
20+ scaled := int32(math.Ceil(float64(value) / normalizeScale(scale)))
21+ if scaled < 1 {
22+ return 1
23+ }
24+ return scaled
25+}
M internal/infrastructure/cef/webview.go +30 -2
@@ -79,6 +79,8 @@ type WebView struct {
7979 host purecef.BrowserHost
8080 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
8181 viewBridge *Cef2gtkAdapter
82+ popupSurface *popupBridgeSurface
83+ nativeWidget *gtk.Widget
8284 handlers *handlerSet
8385 findCtrl *cefFindController
8486 profileCleanup func()
@@ -1156,9 +1158,14 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
11561158 wv.removeSizeObserver = nil
11571159 }
11581160 wv.disconnectViewportSyncHooksOnGTKThread()
1161+ if wv.popupSurface != nil {
1162+ wv.popupSurface.DestroyOnGTKThread()
1163+ wv.popupSurface = nil
1164+ }
11591165 _ = wv.viewBridge.DetachInput()
11601166 _ = wv.viewBridge.Destroy()
11611167 wv.viewBridge = nil
1168+ wv.nativeWidget = nil
11621169 }
11631170
11641171 // ---------------------------------------------------------------------------
@@ -1167,12 +1174,15 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
11671174
11681175 // NativeWidget returns the uintptr for embedding the bridge GTK widget.
11691176 func (wv *WebView) NativeWidget() uintptr {
1170- if wv.viewBridge == nil {
1177+ if wv == nil {
11711178 return 0
11721179 }
11731180 var ptr uintptr
11741181 wv.runOnGTKSync(func() {
1175- if wv.viewBridge != nil {
1182+ switch {
1183+ case wv.nativeWidget != nil:
1184+ ptr = wv.nativeWidget.GoPointer()
1185+ case wv.viewBridge != nil:
11761186 ptr = wv.viewBridge.NativeWidget()
11771187 }
11781188 })
@@ -1403,6 +1413,24 @@ func (wv *WebView) selectedTextSnapshot() string {
14031413 return wv.selectedText
14041414 }
14051415
1416+func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1417+ if wv == nil {
1418+ return cef2gtk.InputOptions{}
1419+ }
1420+ return cef2gtk.InputOptions{
1421+ Scale: 0,
1422+ OnMiddleClick: func(_, _ float64) bool {
1423+ return wv.handleMiddleClickFromBridge()
1424+ },
1425+ SelectionText: wv.selectedTextSnapshot,
1426+ OnClipboardShortcut: func(action, text string) {
1427+ if wv.engine != nil {
1428+ wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
1429+ }
1430+ },
1431+ }
1432+}
1433+
14061434 func (wv *WebView) viewBridgeScale() float64 {
14071435 if wv == nil || wv.viewBridge == nil {
14081436 return 1
@@ -79,6 +79,8 @@ type WebView struct {
79 host purecef.BrowserHost79 host purecef.BrowserHost
80 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it80 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
81 viewBridge *Cef2gtkAdapter81 viewBridge *Cef2gtkAdapter
82+ popupSurface *popupBridgeSurface
83+ nativeWidget *gtk.Widget
82 handlers *handlerSet84 handlers *handlerSet
83 findCtrl *cefFindController85 findCtrl *cefFindController
84 profileCleanup func()86 profileCleanup func()
@@ -1156,9 +1158,14 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
1156 wv.removeSizeObserver = nil1158 wv.removeSizeObserver = nil
1157 }1159 }
1158 wv.disconnectViewportSyncHooksOnGTKThread()1160 wv.disconnectViewportSyncHooksOnGTKThread()
1161+ if wv.popupSurface != nil {
1162+ wv.popupSurface.DestroyOnGTKThread()
1163+ wv.popupSurface = nil
1164+ }
1159 _ = wv.viewBridge.DetachInput()1165 _ = wv.viewBridge.DetachInput()
1160 _ = wv.viewBridge.Destroy()1166 _ = wv.viewBridge.Destroy()
1161 wv.viewBridge = nil1167 wv.viewBridge = nil
1168+ wv.nativeWidget = nil
1162 }1169 }
1163 1170
1164 // ---------------------------------------------------------------------------1171 // ---------------------------------------------------------------------------
@@ -1167,12 +1174,15 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
1167 1174
1168 // NativeWidget returns the uintptr for embedding the bridge GTK widget.1175 // NativeWidget returns the uintptr for embedding the bridge GTK widget.
1169 func (wv *WebView) NativeWidget() uintptr {1176 func (wv *WebView) NativeWidget() uintptr {
1170- if wv.viewBridge == nil {1177+ if wv == nil {
1171 return 01178 return 0
1172 }1179 }
1173 var ptr uintptr1180 var ptr uintptr
1174 wv.runOnGTKSync(func() {1181 wv.runOnGTKSync(func() {
1175- if wv.viewBridge != nil {1182+ switch {
1183+ case wv.nativeWidget != nil:
1184+ ptr = wv.nativeWidget.GoPointer()
1185+ case wv.viewBridge != nil:
1176 ptr = wv.viewBridge.NativeWidget()1186 ptr = wv.viewBridge.NativeWidget()
1177 }1187 }
1178 })1188 })
@@ -1403,6 +1413,24 @@ func (wv *WebView) selectedTextSnapshot() string {
1403 return wv.selectedText1413 return wv.selectedText
1404 }1414 }
1405 1415
1416+func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1417+ if wv == nil {
1418+ return cef2gtk.InputOptions{}
1419+ }
1420+ return cef2gtk.InputOptions{
1421+ Scale: 0,
1422+ OnMiddleClick: func(_, _ float64) bool {
1423+ return wv.handleMiddleClickFromBridge()
1424+ },
1425+ SelectionText: wv.selectedTextSnapshot,
1426+ OnClipboardShortcut: func(action, text string) {
1427+ if wv.engine != nil {
1428+ wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
1429+ }
1430+ },
1431+ }
1432+}
1433+
1406 func (wv *WebView) viewBridgeScale() float64 {1434 func (wv *WebView) viewBridgeScale() float64 {
1407 if wv == nil || wv.viewBridge == nil {1435 if wv == nil || wv.viewBridge == nil {
1408 return 11436 return 1
M internal/infrastructure/cef/webview_handlers.go +1 -13
@@ -6,7 +6,6 @@ import (
66 "sync"
77
88 purecef "github.com/bnema/purego-cef/cef"
9- cef2gtk "github.com/bnema/purego-cef2gtk"
109
1110 "github.com/bnema/dumber/internal/application/dto"
1211 "github.com/bnema/dumber/internal/application/port"
@@ -640,18 +639,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
640639 wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
641640 return
642641 }
643- if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{
644- Scale: 0,
645- OnMiddleClick: func(_, _ float64) bool {
646- return wv.handleMiddleClickFromBridge()
647- },
648- SelectionText: wv.selectedTextSnapshot,
649- OnClipboardShortcut: func(action, text string) {
650- if wv.engine != nil {
651- wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
652- }
653- },
654- }); err != nil {
642+ if err := wv.viewBridge.AttachInputToWidget(host, wv.nativeWidget, wv.bridgeInputOptions()); err != nil {
655643 wv.handleInputAttachFailure(err, host)
656644 return
657645 }
@@ -6,7 +6,6 @@ import (
6 "sync"6 "sync"
7 7
8 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
9- cef2gtk "github.com/bnema/purego-cef2gtk"
10 9
11 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
@@ -640,18 +639,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
640 wv.handleInputAttachFailure(ErrAdapterDestroyed, host)639 wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
641 return640 return
642 }641 }
643- if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{642+ if err := wv.viewBridge.AttachInputToWidget(host, wv.nativeWidget, wv.bridgeInputOptions()); err != nil {
644- Scale: 0,
645- OnMiddleClick: func(_, _ float64) bool {
646- return wv.handleMiddleClickFromBridge()
647- },
648- SelectionText: wv.selectedTextSnapshot,
649- OnClipboardShortcut: func(action, text string) {
650- if wv.engine != nil {
651- wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
652- }
653- },
654- }); err != nil {
655 wv.handleInputAttachFailure(err, host)643 wv.handleInputAttachFailure(err, host)
656 return644 return
657 }645 }
A internal/infrastructure/cef/webview_input_test.go +21 -0
@@ -0,0 +1,21 @@
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 TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) {
11+ nativeWidget := &gtk.Widget{}
12+ wv := &WebView{nativeWidget: nativeWidget}
13+
14+ opts := wv.bridgeInputOptions()
15+
16+ require.Zero(t, opts.Scale)
17+ require.NotNil(t, opts.OnMiddleClick)
18+ require.NotNil(t, opts.SelectionText)
19+ require.NotNil(t, opts.OnClipboardShortcut)
20+ require.Same(t, nativeWidget, wv.nativeWidget)
21+}
@@ -0,0 +1,21 @@
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 TestWebViewBridgeInputOptions_LeavesTargetSelectionToAdapter(t *testing.T) {
11+ nativeWidget := &gtk.Widget{}
12+ wv := &WebView{nativeWidget: nativeWidget}
13+
14+ opts := wv.bridgeInputOptions()
15+
16+ require.Zero(t, opts.Scale)
17+ require.NotNil(t, opts.OnMiddleClick)
18+ require.NotNil(t, opts.SelectionText)
19+ require.NotNil(t, opts.OnClipboardShortcut)
20+ require.Same(t, nativeWidget, wv.nativeWidget)
21+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close