detached 8f62984

fix(cef): address popup surface review findings

commit
8f62984be35e709fdca7dd17e044005920388c91
author
brice · · 2026-05-21 13:53 UTC
committer
brice · · 2026-05-21 14:19 UTC
parent
68ec9b2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +517 -11
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 +155 -0
@@ -0,0 +1,155 @@
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.SetFocusOnClick(true)
65+ root.SetFocusChild(mainWidget)
66+ return &popupBridgeSurface{
67+ ctx: ctx,
68+ root: root,
69+ mainWidget: mainWidget,
70+ bridge: bridge,
71+ popupWidget: popupWidget,
72+ }
73+}
74+
75+func (s *popupBridgeSurface) RootWidget() *gtk.Widget {
76+ if s == nil {
77+ return nil
78+ }
79+ return s.root
80+}
81+
82+func (s *popupBridgeSurface) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
83+ if s == nil || s.bridge == nil {
84+ return nil
85+ }
86+ return s.bridge.RenderHandler(hooks)
87+}
88+
89+func (s *popupBridgeSurface) PrepareOnGTKThread() error {
90+ if s == nil || s.bridge == nil {
91+ return nil
92+ }
93+ if !s.prepared.CompareAndSwap(0, 1) {
94+ return nil
95+ }
96+ if err := s.bridge.PrepareOnGTKThread(); err != nil {
97+ s.prepared.Store(0)
98+ return err
99+ }
100+ return nil
101+}
102+
103+func (s *popupBridgeSurface) SetPopupVisible(visible bool) {
104+ if s == nil || s.popupWidget == nil {
105+ return
106+ }
107+ if visible {
108+ if err := s.PrepareOnGTKThread(); err != nil {
109+ logging.FromContext(popupSurfaceLogContext(s.ctx)).Error().
110+ Err(err).
111+ Msg("cef: popupBridgeSurface.SetPopupVisible failed to PrepareOnGTKThread")
112+ return
113+ }
114+ }
115+ s.popupWidget.SetVisible(visible)
116+ if s.root != nil {
117+ s.root.QueueAllocate()
118+ }
119+}
120+
121+func (s *popupBridgeSurface) SetPopupRect(rect purecef.Rect) {
122+ if s == nil || s.popupWidget == nil {
123+ return
124+ }
125+ if rect.Width <= 0 || rect.Height <= 0 {
126+ s.popupWidget.SetVisible(false)
127+ return
128+ }
129+ s.popupWidget.SetMarginStart(int(rect.X))
130+ s.popupWidget.SetMarginTop(int(rect.Y))
131+ s.popupWidget.SetSizeRequest(int(rect.Width), int(rect.Height))
132+ s.popupWidget.QueueResize()
133+ if s.root != nil {
134+ s.root.QueueResize()
135+ }
136+}
137+
138+func popupSurfaceLogContext(ctx context.Context) context.Context {
139+ if ctx != nil {
140+ return ctx
141+ }
142+ return context.Background()
143+}
144+
145+func (s *popupBridgeSurface) DestroyOnGTKThread() {
146+ if s == nil || s.bridge == nil {
147+ return
148+ }
149+ _ = s.bridge.Destroy()
150+ s.bridge = nil
151+ s.popupWidget = nil
152+ s.mainWidget = nil
153+ s.root = nil
154+ s.prepared.Store(0)
155+}
@@ -0,0 +1,155 @@
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.SetFocusOnClick(true)
65+ root.SetFocusChild(mainWidget)
66+ return &popupBridgeSurface{
67+ ctx: ctx,
68+ root: root,
69+ mainWidget: mainWidget,
70+ bridge: bridge,
71+ popupWidget: popupWidget,
72+ }
73+}
74+
75+func (s *popupBridgeSurface) RootWidget() *gtk.Widget {
76+ if s == nil {
77+ return nil
78+ }
79+ return s.root
80+}
81+
82+func (s *popupBridgeSurface) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
83+ if s == nil || s.bridge == nil {
84+ return nil
85+ }
86+ return s.bridge.RenderHandler(hooks)
87+}
88+
89+func (s *popupBridgeSurface) PrepareOnGTKThread() error {
90+ if s == nil || s.bridge == nil {
91+ return nil
92+ }
93+ if !s.prepared.CompareAndSwap(0, 1) {
94+ return nil
95+ }
96+ if err := s.bridge.PrepareOnGTKThread(); err != nil {
97+ s.prepared.Store(0)
98+ return err
99+ }
100+ return nil
101+}
102+
103+func (s *popupBridgeSurface) SetPopupVisible(visible bool) {
104+ if s == nil || s.popupWidget == nil {
105+ return
106+ }
107+ if visible {
108+ if err := s.PrepareOnGTKThread(); err != nil {
109+ logging.FromContext(popupSurfaceLogContext(s.ctx)).Error().
110+ Err(err).
111+ Msg("cef: popupBridgeSurface.SetPopupVisible failed to PrepareOnGTKThread")
112+ return
113+ }
114+ }
115+ s.popupWidget.SetVisible(visible)
116+ if s.root != nil {
117+ s.root.QueueAllocate()
118+ }
119+}
120+
121+func (s *popupBridgeSurface) SetPopupRect(rect purecef.Rect) {
122+ if s == nil || s.popupWidget == nil {
123+ return
124+ }
125+ if rect.Width <= 0 || rect.Height <= 0 {
126+ s.popupWidget.SetVisible(false)
127+ return
128+ }
129+ s.popupWidget.SetMarginStart(int(rect.X))
130+ s.popupWidget.SetMarginTop(int(rect.Y))
131+ s.popupWidget.SetSizeRequest(int(rect.Width), int(rect.Height))
132+ s.popupWidget.QueueResize()
133+ if s.root != nil {
134+ s.root.QueueResize()
135+ }
136+}
137+
138+func popupSurfaceLogContext(ctx context.Context) context.Context {
139+ if ctx != nil {
140+ return ctx
141+ }
142+ return context.Background()
143+}
144+
145+func (s *popupBridgeSurface) DestroyOnGTKThread() {
146+ if s == nil || s.bridge == nil {
147+ return
148+ }
149+ _ = s.bridge.Destroy()
150+ s.bridge = nil
151+ s.popupWidget = nil
152+ s.mainWidget = nil
153+ s.root = nil
154+ s.prepared.Store(0)
155+}
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 +12 -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 })
@@ -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 })
M internal/ui/layout/gtk_widgets.go +31 -7
@@ -27,13 +27,26 @@ type gtkWidget struct {
2727 inner *gtk.Widget
2828 }
2929
30-func (w *gtkWidget) Show() { w.inner.Show() }
31-func (w *gtkWidget) Hide() { w.inner.Hide() }
32-func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }
33-func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }
34-func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }
35-func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
36-func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
30+func (w *gtkWidget) Show() { w.inner.Show() }
31+func (w *gtkWidget) Hide() { w.inner.Hide() }
32+func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }
33+func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }
34+func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }
35+func (w *gtkWidget) GrabFocus() bool {
36+ if focusChild := gtkWidgetFocusChild(w.inner); focusChild != nil && focusChild.GrabFocus() {
37+ return true
38+ }
39+ return w.inner.GrabFocus()
40+}
41+func (w *gtkWidget) HasFocus() bool {
42+ if w.inner.HasFocus() {
43+ return true
44+ }
45+ if focusChild := gtkWidgetFocusChild(w.inner); focusChild != nil {
46+ return focusChild.HasFocus()
47+ }
48+ return false
49+}
3750 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
3851 func (w *gtkWidget) SetFocusable(focusable bool) { w.inner.SetFocusable(focusable) }
3952 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }
@@ -79,6 +92,17 @@ func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) {
7992 return float64(outPoint.X), float64(outPoint.Y), true
8093 }
8194
95+func gtkWidgetFocusChild(widget *gtk.Widget) *gtk.Widget {
96+ if widget == nil {
97+ return nil
98+ }
99+ focusChild := widget.GetFocusChild()
100+ if focusChild == nil || focusChild.GoPointer() == widget.GoPointer() {
101+ return nil
102+ }
103+ return focusChild
104+}
105+
82106 // gtkPaned wraps gtk.Paned to implement PanedWidget.
83107 type gtkPaned struct {
84108 inner *gtk.Paned
@@ -27,13 +27,26 @@ type gtkWidget struct {
27 inner *gtk.Widget27 inner *gtk.Widget
28 }28 }
29 29
30-func (w *gtkWidget) Show() { w.inner.Show() }30+func (w *gtkWidget) Show() { w.inner.Show() }
31-func (w *gtkWidget) Hide() { w.inner.Hide() }31+func (w *gtkWidget) Hide() { w.inner.Hide() }
32-func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }32+func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }
33-func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }33+func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }
34-func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }34+func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }
35-func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }35+func (w *gtkWidget) GrabFocus() bool {
36-func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }36+ if focusChild := gtkWidgetFocusChild(w.inner); focusChild != nil && focusChild.GrabFocus() {
37+ return true
38+ }
39+ return w.inner.GrabFocus()
40+}
41+func (w *gtkWidget) HasFocus() bool {
42+ if w.inner.HasFocus() {
43+ return true
44+ }
45+ if focusChild := gtkWidgetFocusChild(w.inner); focusChild != nil {
46+ return focusChild.HasFocus()
47+ }
48+ return false
49+}
37 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }50 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
38 func (w *gtkWidget) SetFocusable(focusable bool) { w.inner.SetFocusable(focusable) }51 func (w *gtkWidget) SetFocusable(focusable bool) { w.inner.SetFocusable(focusable) }
39 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }52 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }
@@ -79,6 +92,17 @@ func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) {
79 return float64(outPoint.X), float64(outPoint.Y), true92 return float64(outPoint.X), float64(outPoint.Y), true
80 }93 }
81 94
95+func gtkWidgetFocusChild(widget *gtk.Widget) *gtk.Widget {
96+ if widget == nil {
97+ return nil
98+ }
99+ focusChild := widget.GetFocusChild()
100+ if focusChild == nil || focusChild.GoPointer() == widget.GoPointer() {
101+ return nil
102+ }
103+ return focusChild
104+}
105+
82 // gtkPaned wraps gtk.Paned to implement PanedWidget.106 // gtkPaned wraps gtk.Paned to implement PanedWidget.
83 type gtkPaned struct {107 type gtkPaned struct {
84 inner *gtk.Paned108 inner *gtk.Paned

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