detached 5b526c7

refactor(context-menu): extract shared GTK renderer

Move the duplicated GTK popover menu rendering logic into one shared infrastructure package so CEF and WebKit keep using the same tested menu behavior.
commit
5b526c7383bef86b76c28dda432834d1f7e4f513
author
brice · · 2026-04-14 05:19 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
0545eb9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +266 -510
M internal/infrastructure/cef/renderer.go +3 -218
@@ -1,224 +1,9 @@
11 package cef
22
3-import (
4- "github.com/bnema/dumber/internal/application/port"
5- "github.com/bnema/puregotk/v4/gdk"
6- "github.com/bnema/puregotk/v4/gtk"
7-)
3+import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
84
9-type renderItem struct {
10- item port.MenuItem
11- separator bool
12-}
13-
14-// Renderer shows GTK context menus on the provided anchor widget.
15-type Renderer struct {
16- dispatch func(func())
17-}
5+type Renderer = gtkmenu.Renderer
186
19-// NewRenderer creates a new Renderer.
207 func NewRenderer(dispatch func(func())) *Renderer {
21- return &Renderer{dispatch: dispatch}
22-}
23-
24-func buildButtons(items []port.MenuItem) []renderItem {
25- renderItems := make([]renderItem, 0, len(items))
26- for _, item := range items {
27- if isSeparator(item) {
28- renderItems = append(renderItems, renderItem{separator: true})
29- continue
30- }
31- renderItems = append(renderItems, renderItem{item: item})
32- }
33- return renderItems
34-}
35-
36-func isSeparator(item port.MenuItem) bool {
37- return item.Action == "" && item.Label == ""
38-}
39-
40-// Show renders a popover menu on the GTK thread.
41-func (r *Renderer) Show(
42- items []port.MenuItem,
43- anchor *gtk.Widget,
44- x, y int32,
45- onSelect func(port.MenuItem),
46- onClose func(),
47-) {
48- if r == nil {
49- return
50- }
51- run := func() {
52- showMenu(anchor, buildButtons(items), x, y, onSelect, onClose)
53- }
54- if r.dispatch == nil {
55- run()
56- return
57- }
58- r.dispatch(run)
59-}
60-
61-func showMenu(
62- anchor *gtk.Widget,
63- items []renderItem,
64- x, y int32,
65- onSelect func(port.MenuItem),
66- onClose func(),
67-) {
68- if anchor == nil {
69- if onClose != nil {
70- onClose()
71- }
72- return
73- }
74- if !hasRenderableItems(items) {
75- if onClose != nil {
76- onClose()
77- }
78- return
79- }
80-
81- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82- box.AddCssClass("context-menu")
83-
84- selected := false
85- var popover *gtk.Popover
86- parent := choosePopoverParent(anchor, anchor.GetParent())
87- popoverHost, overlay := createPopoverHost(parent, x, y)
88-
89- for _, item := range items {
90- if item.separator {
91- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
92- box.Append(&sep.Widget)
93- continue
94- }
95-
96- btn := gtk.NewButton()
97- btn.SetLabel(item.item.Label)
98- btn.AddCssClass("flat")
99-
100- menuItem := item.item
101- clickCb := func(_ gtk.Button) {
102- selected = true
103- if onSelect != nil {
104- onSelect(menuItem)
105- }
106- if popover != nil {
107- popover.Popdown()
108- }
109- }
110- btn.ConnectClicked(&clickCb)
111- box.Append(&btn.Widget)
112- }
113-
114- popover = gtk.NewPopover()
115- popover.SetChild(&box.Widget)
116- attachPopover(popover, popoverHost, parent)
117- popover.SetHasArrow(false)
118- popover.SetAutohide(true)
119-
120- if popoverHost == nil {
121- rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122- if anchor == nil || parent == nil || anchor == parent {
123- return srcX, srcY, false
124- }
125- var destX, destY float64
126- if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127- return srcX, srcY, false
128- }
129- return destX, destY, true
130- })
131- popover.SetPointingTo(rect)
132- }
133-
134- closedCb := func(_ gtk.Popover) {
135- if !selected && onClose != nil {
136- onClose()
137- }
138- cleanupPopover(popover, popoverHost, overlay)
139- }
140- popover.ConnectClosed(&closedCb)
141-
142- popupPopover(popover, popoverHost)
143-}
144-
145-func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146- if parent != nil {
147- return parent
148- }
149- return anchor
150-}
151-
152-func popoverPointingRect(
153- x, y int32,
154- translate func(srcX, srcY float64) (float64, float64, bool),
155-) *gdk.Rectangle {
156- destX, destY := float64(x), float64(y)
157- if translate != nil {
158- if tx, ty, ok := translate(destX, destY); ok {
159- destX, destY = tx, ty
160- }
161- }
162- return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163-}
164-
165-func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166- overlay := overlayFromWidget(parent)
167- if overlay == nil {
168- return nil, nil
169- }
170- host := gtk.NewMenuButton()
171- host.SetCanFocus(false)
172- host.SetHalign(gtk.AlignStartValue)
173- host.SetValign(gtk.AlignStartValue)
174- host.SetMarginStart(int(x))
175- host.SetMarginTop(int(y))
176- host.SetOpacity(0)
177- host.SetSizeRequest(1, 1)
178- host.SetVisible(true)
179- overlay.AddOverlay(&host.Widget)
180- overlay.SetClipOverlay(&host.Widget, false)
181- overlay.SetMeasureOverlay(&host.Widget, false)
182- return host, overlay
183-}
184-
185-func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186- if widget == nil || widget.GoPointer() == 0 {
187- return nil
188- }
189- return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190-}
191-
192-func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193- if host != nil {
194- host.SetPopover(popover)
195- return
196- }
197- popover.SetParent(parent)
198-}
199-
200-func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201- if host != nil && overlay != nil {
202- host.SetPopover(nil)
203- overlay.RemoveOverlay(&host.Widget)
204- return
205- }
206- popover.Unparent()
207-}
208-
209-func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210- if host != nil {
211- host.Popup()
212- return
213- }
214- popover.Popup()
215-}
216-
217-func hasRenderableItems(items []renderItem) bool {
218- for _, item := range items {
219- if !item.separator {
220- return true
221- }
222- }
223- return false
8+ return gtkmenu.NewRenderer(dispatch)
2249 }
@@ -1,224 +1,9 @@
1 package cef1 package cef
2 2
3-import (3+import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
4- "github.com/bnema/dumber/internal/application/port"
5- "github.com/bnema/puregotk/v4/gdk"
6- "github.com/bnema/puregotk/v4/gtk"
7-)
8 4
9-type renderItem struct {5+type Renderer = gtkmenu.Renderer
10- item port.MenuItem
11- separator bool
12-}
13-
14-// Renderer shows GTK context menus on the provided anchor widget.
15-type Renderer struct {
16- dispatch func(func())
17-}
18 6
19-// NewRenderer creates a new Renderer.
20 func NewRenderer(dispatch func(func())) *Renderer {7 func NewRenderer(dispatch func(func())) *Renderer {
21- return &Renderer{dispatch: dispatch}8+ return gtkmenu.NewRenderer(dispatch)
22-}
23-
24-func buildButtons(items []port.MenuItem) []renderItem {
25- renderItems := make([]renderItem, 0, len(items))
26- for _, item := range items {
27- if isSeparator(item) {
28- renderItems = append(renderItems, renderItem{separator: true})
29- continue
30- }
31- renderItems = append(renderItems, renderItem{item: item})
32- }
33- return renderItems
34-}
35-
36-func isSeparator(item port.MenuItem) bool {
37- return item.Action == "" && item.Label == ""
38-}
39-
40-// Show renders a popover menu on the GTK thread.
41-func (r *Renderer) Show(
42- items []port.MenuItem,
43- anchor *gtk.Widget,
44- x, y int32,
45- onSelect func(port.MenuItem),
46- onClose func(),
47-) {
48- if r == nil {
49- return
50- }
51- run := func() {
52- showMenu(anchor, buildButtons(items), x, y, onSelect, onClose)
53- }
54- if r.dispatch == nil {
55- run()
56- return
57- }
58- r.dispatch(run)
59-}
60-
61-func showMenu(
62- anchor *gtk.Widget,
63- items []renderItem,
64- x, y int32,
65- onSelect func(port.MenuItem),
66- onClose func(),
67-) {
68- if anchor == nil {
69- if onClose != nil {
70- onClose()
71- }
72- return
73- }
74- if !hasRenderableItems(items) {
75- if onClose != nil {
76- onClose()
77- }
78- return
79- }
80-
81- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82- box.AddCssClass("context-menu")
83-
84- selected := false
85- var popover *gtk.Popover
86- parent := choosePopoverParent(anchor, anchor.GetParent())
87- popoverHost, overlay := createPopoverHost(parent, x, y)
88-
89- for _, item := range items {
90- if item.separator {
91- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
92- box.Append(&sep.Widget)
93- continue
94- }
95-
96- btn := gtk.NewButton()
97- btn.SetLabel(item.item.Label)
98- btn.AddCssClass("flat")
99-
100- menuItem := item.item
101- clickCb := func(_ gtk.Button) {
102- selected = true
103- if onSelect != nil {
104- onSelect(menuItem)
105- }
106- if popover != nil {
107- popover.Popdown()
108- }
109- }
110- btn.ConnectClicked(&clickCb)
111- box.Append(&btn.Widget)
112- }
113-
114- popover = gtk.NewPopover()
115- popover.SetChild(&box.Widget)
116- attachPopover(popover, popoverHost, parent)
117- popover.SetHasArrow(false)
118- popover.SetAutohide(true)
119-
120- if popoverHost == nil {
121- rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122- if anchor == nil || parent == nil || anchor == parent {
123- return srcX, srcY, false
124- }
125- var destX, destY float64
126- if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127- return srcX, srcY, false
128- }
129- return destX, destY, true
130- })
131- popover.SetPointingTo(rect)
132- }
133-
134- closedCb := func(_ gtk.Popover) {
135- if !selected && onClose != nil {
136- onClose()
137- }
138- cleanupPopover(popover, popoverHost, overlay)
139- }
140- popover.ConnectClosed(&closedCb)
141-
142- popupPopover(popover, popoverHost)
143-}
144-
145-func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146- if parent != nil {
147- return parent
148- }
149- return anchor
150-}
151-
152-func popoverPointingRect(
153- x, y int32,
154- translate func(srcX, srcY float64) (float64, float64, bool),
155-) *gdk.Rectangle {
156- destX, destY := float64(x), float64(y)
157- if translate != nil {
158- if tx, ty, ok := translate(destX, destY); ok {
159- destX, destY = tx, ty
160- }
161- }
162- return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163-}
164-
165-func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166- overlay := overlayFromWidget(parent)
167- if overlay == nil {
168- return nil, nil
169- }
170- host := gtk.NewMenuButton()
171- host.SetCanFocus(false)
172- host.SetHalign(gtk.AlignStartValue)
173- host.SetValign(gtk.AlignStartValue)
174- host.SetMarginStart(int(x))
175- host.SetMarginTop(int(y))
176- host.SetOpacity(0)
177- host.SetSizeRequest(1, 1)
178- host.SetVisible(true)
179- overlay.AddOverlay(&host.Widget)
180- overlay.SetClipOverlay(&host.Widget, false)
181- overlay.SetMeasureOverlay(&host.Widget, false)
182- return host, overlay
183-}
184-
185-func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186- if widget == nil || widget.GoPointer() == 0 {
187- return nil
188- }
189- return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190-}
191-
192-func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193- if host != nil {
194- host.SetPopover(popover)
195- return
196- }
197- popover.SetParent(parent)
198-}
199-
200-func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201- if host != nil && overlay != nil {
202- host.SetPopover(nil)
203- overlay.RemoveOverlay(&host.Widget)
204- return
205- }
206- popover.Unparent()
207-}
208-
209-func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210- if host != nil {
211- host.Popup()
212- return
213- }
214- popover.Popup()
215-}
216-
217-func hasRenderableItems(items []renderItem) bool {
218- for _, item := range items {
219- if !item.separator {
220- return true
221- }
222- }
223- return false
224 }9 }
D internal/infrastructure/cef/renderer_test.go +0 -40
@@ -1,40 +0,0 @@
1-package cef
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/puregotk/v4/gtk"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestChoosePopoverParent(t *testing.T) {
11- anchor := &gtk.Widget{}
12- parent := &gtk.Widget{}
13-
14- require.Same(t, parent, choosePopoverParent(anchor, parent))
15- require.Same(t, anchor, choosePopoverParent(anchor, nil))
16-}
17-
18-func TestPopoverPointingRect(t *testing.T) {
19- t.Run("uses translated parent coordinates when available", func(t *testing.T) {
20- rect := popoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
21- require.InDelta(t, 64.0, srcX, 0.0001)
22- require.InDelta(t, 96.0, srcY, 0.0001)
23- return 24, 48, true
24- })
25-
26- require.Equal(t, 24, rect.X)
27- require.Equal(t, 48, rect.Y)
28- require.Equal(t, 1, rect.Width)
29- require.Equal(t, 1, rect.Height)
30- })
31-
32- t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
33- rect := popoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
34- return srcX, srcY, false
35- })
36-
37- require.Equal(t, 10, rect.X)
38- require.Equal(t, 20, rect.Y)
39- })
40-}
@@ -1,40 +0,0 @@
1-package cef
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/puregotk/v4/gtk"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestChoosePopoverParent(t *testing.T) {
11- anchor := &gtk.Widget{}
12- parent := &gtk.Widget{}
13-
14- require.Same(t, parent, choosePopoverParent(anchor, parent))
15- require.Same(t, anchor, choosePopoverParent(anchor, nil))
16-}
17-
18-func TestPopoverPointingRect(t *testing.T) {
19- t.Run("uses translated parent coordinates when available", func(t *testing.T) {
20- rect := popoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
21- require.InDelta(t, 64.0, srcX, 0.0001)
22- require.InDelta(t, 96.0, srcY, 0.0001)
23- return 24, 48, true
24- })
25-
26- require.Equal(t, 24, rect.X)
27- require.Equal(t, 48, rect.Y)
28- require.Equal(t, 1, rect.Width)
29- require.Equal(t, 1, rect.Height)
30- })
31-
32- t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
33- rect := popoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
34- return srcX, srcY, false
35- })
36-
37- require.Equal(t, 10, rect.X)
38- require.Equal(t, 20, rect.Y)
39- })
40-}
A internal/infrastructure/gtkmenu/renderer.go +226 -0
@@ -0,0 +1,226 @@
1+package gtkmenu
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/port"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+type renderItem struct {
10+ item port.MenuItem
11+ separator bool
12+}
13+
14+// Renderer shows GTK context menus on the provided anchor widget.
15+type Renderer struct {
16+ dispatch func(func())
17+}
18+
19+// NewRenderer creates a new Renderer.
20+func NewRenderer(dispatch func(func())) *Renderer {
21+ return &Renderer{dispatch: dispatch}
22+}
23+
24+func BuildButtons(items []port.MenuItem) []renderItem {
25+ renderItems := make([]renderItem, 0, len(items))
26+ for _, item := range items {
27+ if isSeparator(item) {
28+ renderItems = append(renderItems, renderItem{separator: true})
29+ continue
30+ }
31+ renderItems = append(renderItems, renderItem{item: item})
32+ }
33+ return renderItems
34+}
35+
36+func isSeparator(item port.MenuItem) bool {
37+ return item.Action == "" && item.Label == ""
38+}
39+
40+// Show renders a popover menu on the GTK thread.
41+func (r *Renderer) Show(
42+ items []port.MenuItem,
43+ anchor *gtk.Widget,
44+ x, y int32,
45+ onSelect func(port.MenuItem),
46+ onClose func(),
47+) {
48+ if r == nil {
49+ return
50+ }
51+ run := func() {
52+ showMenu(anchor, BuildButtons(items), x, y, onSelect, onClose)
53+ }
54+ if r.dispatch == nil {
55+ run()
56+ return
57+ }
58+ r.dispatch(run)
59+}
60+
61+func showMenu(
62+ anchor *gtk.Widget,
63+ items []renderItem,
64+ x, y int32,
65+ onSelect func(port.MenuItem),
66+ onClose func(),
67+) {
68+ if anchor == nil {
69+ if onClose != nil {
70+ onClose()
71+ }
72+ return
73+ }
74+ if !hasRenderableItems(items) {
75+ if onClose != nil {
76+ onClose()
77+ }
78+ return
79+ }
80+
81+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82+ box.AddCssClass("context-menu")
83+
84+ selected := false
85+ var popover *gtk.Popover
86+ parent := ChoosePopoverParent(anchor, anchor.GetParent())
87+ popoverHost, overlay := createPopoverHost(parent, x, y)
88+
89+ for _, item := range items {
90+ if item.separator {
91+ sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
92+ box.Append(&sep.Widget)
93+ continue
94+ }
95+
96+ btn := gtk.NewButton()
97+ btn.SetLabel(item.item.Label)
98+ btn.AddCssClass("flat")
99+
100+ menuItem := item.item
101+ clickCb := func(_ gtk.Button) {
102+ selected = true
103+ if onSelect != nil {
104+ onSelect(menuItem)
105+ }
106+ if popover != nil {
107+ popover.Popdown()
108+ }
109+ }
110+ btn.ConnectClicked(&clickCb)
111+ box.Append(&btn.Widget)
112+ }
113+
114+ popover = gtk.NewPopover()
115+ popover.SetChild(&box.Widget)
116+ attachPopover(popover, popoverHost, parent)
117+ popover.SetHasArrow(false)
118+ popover.SetAutohide(true)
119+
120+ if popoverHost == nil {
121+ rect := PopoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122+ if anchor == nil || parent == nil || anchor == parent {
123+ return srcX, srcY, false
124+ }
125+ var destX, destY float64
126+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127+ return srcX, srcY, false
128+ }
129+ return destX, destY, true
130+ })
131+ popover.SetPointingTo(rect)
132+ }
133+
134+ closedCb := func(_ gtk.Popover) {
135+ if !selected && onClose != nil {
136+ onClose()
137+ }
138+ cleanupPopover(popover, popoverHost, overlay)
139+ }
140+ popover.ConnectClosed(&closedCb)
141+
142+ popupPopover(popover, popoverHost)
143+}
144+
145+// ChoosePopoverParent picks the best parent for a GTK popover.
146+func ChoosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
147+ if parent != nil {
148+ return parent
149+ }
150+ return anchor
151+}
152+
153+// PopoverPointingRect builds the popover pointing rectangle.
154+func PopoverPointingRect(
155+ x, y int32,
156+ translate func(srcX, srcY float64) (float64, float64, bool),
157+) *gdk.Rectangle {
158+ destX, destY := float64(x), float64(y)
159+ if translate != nil {
160+ if tx, ty, ok := translate(destX, destY); ok {
161+ destX, destY = tx, ty
162+ }
163+ }
164+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
165+}
166+
167+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
168+ overlay := overlayFromWidget(parent)
169+ if overlay == nil {
170+ return nil, nil
171+ }
172+ host := gtk.NewMenuButton()
173+ host.SetCanFocus(false)
174+ host.SetHalign(gtk.AlignStartValue)
175+ host.SetValign(gtk.AlignStartValue)
176+ host.SetMarginStart(int(x))
177+ host.SetMarginTop(int(y))
178+ host.SetOpacity(0)
179+ host.SetSizeRequest(1, 1)
180+ host.SetVisible(true)
181+ overlay.AddOverlay(&host.Widget)
182+ overlay.SetClipOverlay(&host.Widget, false)
183+ overlay.SetMeasureOverlay(&host.Widget, false)
184+ return host, overlay
185+}
186+
187+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
188+ if widget == nil || widget.GoPointer() == 0 {
189+ return nil
190+ }
191+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
192+}
193+
194+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
195+ if host != nil {
196+ host.SetPopover(popover)
197+ return
198+ }
199+ popover.SetParent(parent)
200+}
201+
202+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
203+ if host != nil && overlay != nil {
204+ host.SetPopover(nil)
205+ overlay.RemoveOverlay(&host.Widget)
206+ return
207+ }
208+ popover.Unparent()
209+}
210+
211+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
212+ if host != nil {
213+ host.Popup()
214+ return
215+ }
216+ popover.Popup()
217+}
218+
219+func hasRenderableItems(items []renderItem) bool {
220+ for _, item := range items {
221+ if !item.separator {
222+ return true
223+ }
224+ }
225+ return false
226+}
@@ -0,0 +1,226 @@
1+package gtkmenu
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/port"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+type renderItem struct {
10+ item port.MenuItem
11+ separator bool
12+}
13+
14+// Renderer shows GTK context menus on the provided anchor widget.
15+type Renderer struct {
16+ dispatch func(func())
17+}
18+
19+// NewRenderer creates a new Renderer.
20+func NewRenderer(dispatch func(func())) *Renderer {
21+ return &Renderer{dispatch: dispatch}
22+}
23+
24+func BuildButtons(items []port.MenuItem) []renderItem {
25+ renderItems := make([]renderItem, 0, len(items))
26+ for _, item := range items {
27+ if isSeparator(item) {
28+ renderItems = append(renderItems, renderItem{separator: true})
29+ continue
30+ }
31+ renderItems = append(renderItems, renderItem{item: item})
32+ }
33+ return renderItems
34+}
35+
36+func isSeparator(item port.MenuItem) bool {
37+ return item.Action == "" && item.Label == ""
38+}
39+
40+// Show renders a popover menu on the GTK thread.
41+func (r *Renderer) Show(
42+ items []port.MenuItem,
43+ anchor *gtk.Widget,
44+ x, y int32,
45+ onSelect func(port.MenuItem),
46+ onClose func(),
47+) {
48+ if r == nil {
49+ return
50+ }
51+ run := func() {
52+ showMenu(anchor, BuildButtons(items), x, y, onSelect, onClose)
53+ }
54+ if r.dispatch == nil {
55+ run()
56+ return
57+ }
58+ r.dispatch(run)
59+}
60+
61+func showMenu(
62+ anchor *gtk.Widget,
63+ items []renderItem,
64+ x, y int32,
65+ onSelect func(port.MenuItem),
66+ onClose func(),
67+) {
68+ if anchor == nil {
69+ if onClose != nil {
70+ onClose()
71+ }
72+ return
73+ }
74+ if !hasRenderableItems(items) {
75+ if onClose != nil {
76+ onClose()
77+ }
78+ return
79+ }
80+
81+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82+ box.AddCssClass("context-menu")
83+
84+ selected := false
85+ var popover *gtk.Popover
86+ parent := ChoosePopoverParent(anchor, anchor.GetParent())
87+ popoverHost, overlay := createPopoverHost(parent, x, y)
88+
89+ for _, item := range items {
90+ if item.separator {
91+ sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
92+ box.Append(&sep.Widget)
93+ continue
94+ }
95+
96+ btn := gtk.NewButton()
97+ btn.SetLabel(item.item.Label)
98+ btn.AddCssClass("flat")
99+
100+ menuItem := item.item
101+ clickCb := func(_ gtk.Button) {
102+ selected = true
103+ if onSelect != nil {
104+ onSelect(menuItem)
105+ }
106+ if popover != nil {
107+ popover.Popdown()
108+ }
109+ }
110+ btn.ConnectClicked(&clickCb)
111+ box.Append(&btn.Widget)
112+ }
113+
114+ popover = gtk.NewPopover()
115+ popover.SetChild(&box.Widget)
116+ attachPopover(popover, popoverHost, parent)
117+ popover.SetHasArrow(false)
118+ popover.SetAutohide(true)
119+
120+ if popoverHost == nil {
121+ rect := PopoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122+ if anchor == nil || parent == nil || anchor == parent {
123+ return srcX, srcY, false
124+ }
125+ var destX, destY float64
126+ if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127+ return srcX, srcY, false
128+ }
129+ return destX, destY, true
130+ })
131+ popover.SetPointingTo(rect)
132+ }
133+
134+ closedCb := func(_ gtk.Popover) {
135+ if !selected && onClose != nil {
136+ onClose()
137+ }
138+ cleanupPopover(popover, popoverHost, overlay)
139+ }
140+ popover.ConnectClosed(&closedCb)
141+
142+ popupPopover(popover, popoverHost)
143+}
144+
145+// ChoosePopoverParent picks the best parent for a GTK popover.
146+func ChoosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
147+ if parent != nil {
148+ return parent
149+ }
150+ return anchor
151+}
152+
153+// PopoverPointingRect builds the popover pointing rectangle.
154+func PopoverPointingRect(
155+ x, y int32,
156+ translate func(srcX, srcY float64) (float64, float64, bool),
157+) *gdk.Rectangle {
158+ destX, destY := float64(x), float64(y)
159+ if translate != nil {
160+ if tx, ty, ok := translate(destX, destY); ok {
161+ destX, destY = tx, ty
162+ }
163+ }
164+ return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
165+}
166+
167+func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
168+ overlay := overlayFromWidget(parent)
169+ if overlay == nil {
170+ return nil, nil
171+ }
172+ host := gtk.NewMenuButton()
173+ host.SetCanFocus(false)
174+ host.SetHalign(gtk.AlignStartValue)
175+ host.SetValign(gtk.AlignStartValue)
176+ host.SetMarginStart(int(x))
177+ host.SetMarginTop(int(y))
178+ host.SetOpacity(0)
179+ host.SetSizeRequest(1, 1)
180+ host.SetVisible(true)
181+ overlay.AddOverlay(&host.Widget)
182+ overlay.SetClipOverlay(&host.Widget, false)
183+ overlay.SetMeasureOverlay(&host.Widget, false)
184+ return host, overlay
185+}
186+
187+func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
188+ if widget == nil || widget.GoPointer() == 0 {
189+ return nil
190+ }
191+ return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
192+}
193+
194+func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
195+ if host != nil {
196+ host.SetPopover(popover)
197+ return
198+ }
199+ popover.SetParent(parent)
200+}
201+
202+func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
203+ if host != nil && overlay != nil {
204+ host.SetPopover(nil)
205+ overlay.RemoveOverlay(&host.Widget)
206+ return
207+ }
208+ popover.Unparent()
209+}
210+
211+func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
212+ if host != nil {
213+ host.Popup()
214+ return
215+ }
216+ popover.Popup()
217+}
218+
219+func hasRenderableItems(items []renderItem) bool {
220+ for _, item := range items {
221+ if !item.separator {
222+ return true
223+ }
224+ }
225+ return false
226+}
R internal/infrastructure/webkit/renderer_test.go → internal/infrastructure/gtkmenu/renderer_test.go +34 -34 renamed, 70% similar
@@ -1,4 +1,4 @@
1-package webkit
1+package gtkmenu
22
33 import (
44 "testing"
@@ -8,6 +8,38 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11+func TestChoosePopoverParent(t *testing.T) {
12+ anchor := &gtk.Widget{}
13+ parent := &gtk.Widget{}
14+
15+ require.Same(t, parent, ChoosePopoverParent(anchor, parent))
16+ require.Same(t, anchor, ChoosePopoverParent(anchor, nil))
17+}
18+
19+func TestPopoverPointingRect(t *testing.T) {
20+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
21+ rect := PopoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
22+ require.InDelta(t, 64.0, srcX, 0.0001)
23+ require.InDelta(t, 96.0, srcY, 0.0001)
24+ return 24, 48, true
25+ })
26+
27+ require.Equal(t, 24, rect.X)
28+ require.Equal(t, 48, rect.Y)
29+ require.Equal(t, 1, rect.Width)
30+ require.Equal(t, 1, rect.Height)
31+ })
32+
33+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
34+ rect := PopoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
35+ return srcX, srcY, false
36+ })
37+
38+ require.Equal(t, 10, rect.X)
39+ require.Equal(t, 20, rect.Y)
40+ })
41+}
42+
1143 func TestBuildButtons(t *testing.T) {
1244 items := []port.MenuItem{
1345 {Action: port.MenuActionBack, Label: "Back"},
@@ -16,7 +48,7 @@ func TestBuildButtons(t *testing.T) {
1648 {Action: port.MenuActionInspectElement, Label: "Inspect Element"},
1749 }
1850
19- renderItems := buildButtons(items)
51+ renderItems := BuildButtons(items)
2052 require.Len(t, renderItems, 4)
2153
2254 require.Equal(t, "Back", renderItems[0].item.Label)
@@ -30,35 +62,3 @@ func TestBuildButtons(t *testing.T) {
3062 require.Equal(t, "Inspect Element", renderItems[3].item.Label)
3163 require.False(t, renderItems[3].separator)
3264 }
33-
34-func TestChoosePopoverParent(t *testing.T) {
35- anchor := &gtk.Widget{}
36- parent := &gtk.Widget{}
37-
38- require.Same(t, parent, choosePopoverParent(anchor, parent))
39- require.Same(t, anchor, choosePopoverParent(anchor, nil))
40-}
41-
42-func TestPopoverPointingRect(t *testing.T) {
43- t.Run("uses translated parent coordinates when available", func(t *testing.T) {
44- rect := popoverPointingRect(50, 80, func(srcX, srcY float64) (float64, float64, bool) {
45- require.InDelta(t, 50.0, srcX, 0.0001)
46- require.InDelta(t, 80.0, srcY, 0.0001)
47- return 20, 30, true
48- })
49-
50- require.Equal(t, 20, rect.X)
51- require.Equal(t, 30, rect.Y)
52- require.Equal(t, 1, rect.Width)
53- require.Equal(t, 1, rect.Height)
54- })
55-
56- t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
57- rect := popoverPointingRect(12, 34, func(srcX, srcY float64) (float64, float64, bool) {
58- return srcX, srcY, false
59- })
60-
61- require.Equal(t, 12, rect.X)
62- require.Equal(t, 34, rect.Y)
63- })
64-}
@@ -1,4 +1,4 @@
1-package webkit1+package gtkmenu
2 2
3 import (3 import (
4 "testing"4 "testing"
@@ -8,6 +8,38 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11+func TestChoosePopoverParent(t *testing.T) {
12+ anchor := &gtk.Widget{}
13+ parent := &gtk.Widget{}
14+
15+ require.Same(t, parent, ChoosePopoverParent(anchor, parent))
16+ require.Same(t, anchor, ChoosePopoverParent(anchor, nil))
17+}
18+
19+func TestPopoverPointingRect(t *testing.T) {
20+ t.Run("uses translated parent coordinates when available", func(t *testing.T) {
21+ rect := PopoverPointingRect(64, 96, func(srcX, srcY float64) (float64, float64, bool) {
22+ require.InDelta(t, 64.0, srcX, 0.0001)
23+ require.InDelta(t, 96.0, srcY, 0.0001)
24+ return 24, 48, true
25+ })
26+
27+ require.Equal(t, 24, rect.X)
28+ require.Equal(t, 48, rect.Y)
29+ require.Equal(t, 1, rect.Width)
30+ require.Equal(t, 1, rect.Height)
31+ })
32+
33+ t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
34+ rect := PopoverPointingRect(10, 20, func(srcX, srcY float64) (float64, float64, bool) {
35+ return srcX, srcY, false
36+ })
37+
38+ require.Equal(t, 10, rect.X)
39+ require.Equal(t, 20, rect.Y)
40+ })
41+}
42+
11 func TestBuildButtons(t *testing.T) {43 func TestBuildButtons(t *testing.T) {
12 items := []port.MenuItem{44 items := []port.MenuItem{
13 {Action: port.MenuActionBack, Label: "Back"},45 {Action: port.MenuActionBack, Label: "Back"},
@@ -16,7 +48,7 @@ func TestBuildButtons(t *testing.T) {
16 {Action: port.MenuActionInspectElement, Label: "Inspect Element"},48 {Action: port.MenuActionInspectElement, Label: "Inspect Element"},
17 }49 }
18 50
19- renderItems := buildButtons(items)51+ renderItems := BuildButtons(items)
20 require.Len(t, renderItems, 4)52 require.Len(t, renderItems, 4)
21 53
22 require.Equal(t, "Back", renderItems[0].item.Label)54 require.Equal(t, "Back", renderItems[0].item.Label)
@@ -30,35 +62,3 @@ func TestBuildButtons(t *testing.T) {
30 require.Equal(t, "Inspect Element", renderItems[3].item.Label)62 require.Equal(t, "Inspect Element", renderItems[3].item.Label)
31 require.False(t, renderItems[3].separator)63 require.False(t, renderItems[3].separator)
32 }64 }
33-
34-func TestChoosePopoverParent(t *testing.T) {
35- anchor := &gtk.Widget{}
36- parent := &gtk.Widget{}
37-
38- require.Same(t, parent, choosePopoverParent(anchor, parent))
39- require.Same(t, anchor, choosePopoverParent(anchor, nil))
40-}
41-
42-func TestPopoverPointingRect(t *testing.T) {
43- t.Run("uses translated parent coordinates when available", func(t *testing.T) {
44- rect := popoverPointingRect(50, 80, func(srcX, srcY float64) (float64, float64, bool) {
45- require.InDelta(t, 50.0, srcX, 0.0001)
46- require.InDelta(t, 80.0, srcY, 0.0001)
47- return 20, 30, true
48- })
49-
50- require.Equal(t, 20, rect.X)
51- require.Equal(t, 30, rect.Y)
52- require.Equal(t, 1, rect.Width)
53- require.Equal(t, 1, rect.Height)
54- })
55-
56- t.Run("falls back to raw coordinates when translation fails", func(t *testing.T) {
57- rect := popoverPointingRect(12, 34, func(srcX, srcY float64) (float64, float64, bool) {
58- return srcX, srcY, false
59- })
60-
61- require.Equal(t, 12, rect.X)
62- require.Equal(t, 34, rect.Y)
63- })
64-}
M internal/infrastructure/webkit/renderer.go +3 -218
@@ -1,224 +1,9 @@
11 package webkit
22
3-import (
4- "github.com/bnema/dumber/internal/application/port"
5- "github.com/bnema/puregotk/v4/gdk"
6- "github.com/bnema/puregotk/v4/gtk"
7-)
3+import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
84
9-type renderItem struct {
10- item port.MenuItem
11- separator bool
12-}
13-
14-// Renderer shows GTK context menus on the provided anchor widget.
15-type Renderer struct {
16- dispatch func(func())
17-}
5+type Renderer = gtkmenu.Renderer
186
19-// NewRenderer creates a new Renderer.
207 func NewRenderer(dispatch func(func())) *Renderer {
21- return &Renderer{dispatch: dispatch}
22-}
23-
24-func buildButtons(items []port.MenuItem) []renderItem {
25- renderItems := make([]renderItem, 0, len(items))
26- for _, item := range items {
27- if isSeparator(item) {
28- renderItems = append(renderItems, renderItem{separator: true})
29- continue
30- }
31- renderItems = append(renderItems, renderItem{item: item})
32- }
33- return renderItems
34-}
35-
36-func isSeparator(item port.MenuItem) bool {
37- return item.Action == "" && item.Label == ""
38-}
39-
40-// Show renders a popover menu on the GTK thread.
41-func (r *Renderer) Show(
42- items []port.MenuItem,
43- anchor *gtk.Widget,
44- x, y int32,
45- onSelect func(port.MenuItem),
46- onClose func(),
47-) {
48- if r == nil {
49- return
50- }
51- run := func() {
52- showMenu(anchor, buildButtons(items), x, y, onSelect, onClose)
53- }
54- if r.dispatch == nil {
55- run()
56- return
57- }
58- r.dispatch(run)
59-}
60-
61-func showMenu(
62- anchor *gtk.Widget,
63- items []renderItem,
64- x, y int32,
65- onSelect func(port.MenuItem),
66- onClose func(),
67-) {
68- if anchor == nil {
69- if onClose != nil {
70- onClose()
71- }
72- return
73- }
74- if !hasRenderableItems(items) {
75- if onClose != nil {
76- onClose()
77- }
78- return
79- }
80-
81- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82- box.AddCssClass("context-menu")
83-
84- selected := false
85- var popover *gtk.Popover
86- parent := choosePopoverParent(anchor, anchor.GetParent())
87- popoverHost, overlay := createPopoverHost(parent, x, y)
88-
89- for _, item := range items {
90- if item.separator {
91- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
92- box.Append(&sep.Widget)
93- continue
94- }
95-
96- btn := gtk.NewButton()
97- btn.SetLabel(item.item.Label)
98- btn.AddCssClass("flat")
99-
100- menuItem := item.item
101- clickCb := func(_ gtk.Button) {
102- selected = true
103- if onSelect != nil {
104- onSelect(menuItem)
105- }
106- if popover != nil {
107- popover.Popdown()
108- }
109- }
110- btn.ConnectClicked(&clickCb)
111- box.Append(&btn.Widget)
112- }
113-
114- popover = gtk.NewPopover()
115- popover.SetChild(&box.Widget)
116- attachPopover(popover, popoverHost, parent)
117- popover.SetHasArrow(false)
118- popover.SetAutohide(true)
119-
120- if popoverHost == nil {
121- rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122- if anchor == nil || parent == nil || anchor == parent {
123- return srcX, srcY, false
124- }
125- var destX, destY float64
126- if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127- return srcX, srcY, false
128- }
129- return destX, destY, true
130- })
131- popover.SetPointingTo(rect)
132- }
133-
134- closedCb := func(_ gtk.Popover) {
135- if !selected && onClose != nil {
136- onClose()
137- }
138- cleanupPopover(popover, popoverHost, overlay)
139- }
140- popover.ConnectClosed(&closedCb)
141-
142- popupPopover(popover, popoverHost)
143-}
144-
145-func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146- if parent != nil {
147- return parent
148- }
149- return anchor
150-}
151-
152-func popoverPointingRect(
153- x, y int32,
154- translate func(srcX, srcY float64) (float64, float64, bool),
155-) *gdk.Rectangle {
156- destX, destY := float64(x), float64(y)
157- if translate != nil {
158- if tx, ty, ok := translate(destX, destY); ok {
159- destX, destY = tx, ty
160- }
161- }
162- return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163-}
164-
165-func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166- overlay := overlayFromWidget(parent)
167- if overlay == nil {
168- return nil, nil
169- }
170- host := gtk.NewMenuButton()
171- host.SetCanFocus(false)
172- host.SetHalign(gtk.AlignStartValue)
173- host.SetValign(gtk.AlignStartValue)
174- host.SetMarginStart(int(x))
175- host.SetMarginTop(int(y))
176- host.SetOpacity(0)
177- host.SetSizeRequest(1, 1)
178- host.SetVisible(true)
179- overlay.AddOverlay(&host.Widget)
180- overlay.SetClipOverlay(&host.Widget, false)
181- overlay.SetMeasureOverlay(&host.Widget, false)
182- return host, overlay
183-}
184-
185-func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186- if widget == nil || widget.GoPointer() == 0 {
187- return nil
188- }
189- return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190-}
191-
192-func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193- if host != nil {
194- host.SetPopover(popover)
195- return
196- }
197- popover.SetParent(parent)
198-}
199-
200-func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201- if host != nil && overlay != nil {
202- host.SetPopover(nil)
203- overlay.RemoveOverlay(&host.Widget)
204- return
205- }
206- popover.Unparent()
207-}
208-
209-func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210- if host != nil {
211- host.Popup()
212- return
213- }
214- popover.Popup()
215-}
216-
217-func hasRenderableItems(items []renderItem) bool {
218- for _, item := range items {
219- if !item.separator {
220- return true
221- }
222- }
223- return false
8+ return gtkmenu.NewRenderer(dispatch)
2249 }
@@ -1,224 +1,9 @@
1 package webkit1 package webkit
2 2
3-import (3+import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
4- "github.com/bnema/dumber/internal/application/port"
5- "github.com/bnema/puregotk/v4/gdk"
6- "github.com/bnema/puregotk/v4/gtk"
7-)
8 4
9-type renderItem struct {5+type Renderer = gtkmenu.Renderer
10- item port.MenuItem
11- separator bool
12-}
13-
14-// Renderer shows GTK context menus on the provided anchor widget.
15-type Renderer struct {
16- dispatch func(func())
17-}
18 6
19-// NewRenderer creates a new Renderer.
20 func NewRenderer(dispatch func(func())) *Renderer {7 func NewRenderer(dispatch func(func())) *Renderer {
21- return &Renderer{dispatch: dispatch}8+ return gtkmenu.NewRenderer(dispatch)
22-}
23-
24-func buildButtons(items []port.MenuItem) []renderItem {
25- renderItems := make([]renderItem, 0, len(items))
26- for _, item := range items {
27- if isSeparator(item) {
28- renderItems = append(renderItems, renderItem{separator: true})
29- continue
30- }
31- renderItems = append(renderItems, renderItem{item: item})
32- }
33- return renderItems
34-}
35-
36-func isSeparator(item port.MenuItem) bool {
37- return item.Action == "" && item.Label == ""
38-}
39-
40-// Show renders a popover menu on the GTK thread.
41-func (r *Renderer) Show(
42- items []port.MenuItem,
43- anchor *gtk.Widget,
44- x, y int32,
45- onSelect func(port.MenuItem),
46- onClose func(),
47-) {
48- if r == nil {
49- return
50- }
51- run := func() {
52- showMenu(anchor, buildButtons(items), x, y, onSelect, onClose)
53- }
54- if r.dispatch == nil {
55- run()
56- return
57- }
58- r.dispatch(run)
59-}
60-
61-func showMenu(
62- anchor *gtk.Widget,
63- items []renderItem,
64- x, y int32,
65- onSelect func(port.MenuItem),
66- onClose func(),
67-) {
68- if anchor == nil {
69- if onClose != nil {
70- onClose()
71- }
72- return
73- }
74- if !hasRenderableItems(items) {
75- if onClose != nil {
76- onClose()
77- }
78- return
79- }
80-
81- box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
82- box.AddCssClass("context-menu")
83-
84- selected := false
85- var popover *gtk.Popover
86- parent := choosePopoverParent(anchor, anchor.GetParent())
87- popoverHost, overlay := createPopoverHost(parent, x, y)
88-
89- for _, item := range items {
90- if item.separator {
91- sep := gtk.NewSeparator(gtk.OrientationHorizontalValue)
92- box.Append(&sep.Widget)
93- continue
94- }
95-
96- btn := gtk.NewButton()
97- btn.SetLabel(item.item.Label)
98- btn.AddCssClass("flat")
99-
100- menuItem := item.item
101- clickCb := func(_ gtk.Button) {
102- selected = true
103- if onSelect != nil {
104- onSelect(menuItem)
105- }
106- if popover != nil {
107- popover.Popdown()
108- }
109- }
110- btn.ConnectClicked(&clickCb)
111- box.Append(&btn.Widget)
112- }
113-
114- popover = gtk.NewPopover()
115- popover.SetChild(&box.Widget)
116- attachPopover(popover, popoverHost, parent)
117- popover.SetHasArrow(false)
118- popover.SetAutohide(true)
119-
120- if popoverHost == nil {
121- rect := popoverPointingRect(x, y, func(srcX, srcY float64) (float64, float64, bool) {
122- if anchor == nil || parent == nil || anchor == parent {
123- return srcX, srcY, false
124- }
125- var destX, destY float64
126- if !anchor.TranslateCoordinates(parent, srcX, srcY, &destX, &destY) {
127- return srcX, srcY, false
128- }
129- return destX, destY, true
130- })
131- popover.SetPointingTo(rect)
132- }
133-
134- closedCb := func(_ gtk.Popover) {
135- if !selected && onClose != nil {
136- onClose()
137- }
138- cleanupPopover(popover, popoverHost, overlay)
139- }
140- popover.ConnectClosed(&closedCb)
141-
142- popupPopover(popover, popoverHost)
143-}
144-
145-func choosePopoverParent(anchor, parent *gtk.Widget) *gtk.Widget {
146- if parent != nil {
147- return parent
148- }
149- return anchor
150-}
151-
152-func popoverPointingRect(
153- x, y int32,
154- translate func(srcX, srcY float64) (float64, float64, bool),
155-) *gdk.Rectangle {
156- destX, destY := float64(x), float64(y)
157- if translate != nil {
158- if tx, ty, ok := translate(destX, destY); ok {
159- destX, destY = tx, ty
160- }
161- }
162- return &gdk.Rectangle{X: int(destX), Y: int(destY), Width: 1, Height: 1}
163-}
164-
165-func createPopoverHost(parent *gtk.Widget, x, y int32) (*gtk.MenuButton, *gtk.Overlay) {
166- overlay := overlayFromWidget(parent)
167- if overlay == nil {
168- return nil, nil
169- }
170- host := gtk.NewMenuButton()
171- host.SetCanFocus(false)
172- host.SetHalign(gtk.AlignStartValue)
173- host.SetValign(gtk.AlignStartValue)
174- host.SetMarginStart(int(x))
175- host.SetMarginTop(int(y))
176- host.SetOpacity(0)
177- host.SetSizeRequest(1, 1)
178- host.SetVisible(true)
179- overlay.AddOverlay(&host.Widget)
180- overlay.SetClipOverlay(&host.Widget, false)
181- overlay.SetMeasureOverlay(&host.Widget, false)
182- return host, overlay
183-}
184-
185-func overlayFromWidget(widget *gtk.Widget) *gtk.Overlay {
186- if widget == nil || widget.GoPointer() == 0 {
187- return nil
188- }
189- return gtk.OverlayNewFromInternalPtr(widget.GoPointer())
190-}
191-
192-func attachPopover(popover *gtk.Popover, host *gtk.MenuButton, parent *gtk.Widget) {
193- if host != nil {
194- host.SetPopover(popover)
195- return
196- }
197- popover.SetParent(parent)
198-}
199-
200-func cleanupPopover(popover *gtk.Popover, host *gtk.MenuButton, overlay *gtk.Overlay) {
201- if host != nil && overlay != nil {
202- host.SetPopover(nil)
203- overlay.RemoveOverlay(&host.Widget)
204- return
205- }
206- popover.Unparent()
207-}
208-
209-func popupPopover(popover *gtk.Popover, host *gtk.MenuButton) {
210- if host != nil {
211- host.Popup()
212- return
213- }
214- popover.Popup()
215-}
216-
217-func hasRenderableItems(items []renderItem) bool {
218- for _, item := range items {
219- if !item.separator {
220- return true
221- }
222- }
223- return false
224 }9 }

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