detached 28632c9

fix(ui): release mode toaster GTK widgets when the legend is rebuilt or destroyed

puregotk constructors return a reference the caller owns, and removing a
widget from its parent does not drop it. Unreference the legend groups, rows
and keycaps when the legend is replaced, the column boxes when the layout is
redistributed, and every toaster widget and retained callback on destroy.
Covered by a GTK test watching widget finalization.
commit
28632c9041acd60b070f4a3f51020a31bfec534b
author
brice · · 2026-10-14 07:40 UTC
parent
4576e5c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +108 -1
M internal/ui/mode_toaster_view.go +41 -1
@@ -82,6 +82,12 @@ type gtkModeToasterView struct {
8282 interactive bool
8383 animations bool
8484
85+ // legendWidgets are the widgets built for the current legend. puregotk
86+ // constructors hand back a reference we own, which GTK does not drop when
87+ // the widget is removed from its parent, so they are unreferenced once the
88+ // legend is replaced or the view destroyed.
89+ legendWidgets []*gtk.Widget
90+
8591 groups []legendGroup
8692 groupBoxes []*gtk.Box
8793 columns [][]*gtk.Box // group boxes per column box, as placed
@@ -441,6 +447,7 @@ func (v *gtkModeToasterView) setBodyLingering(lingering bool) {
441447 // setLegend replaces the legend content; nil empties it.
442448 func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
443449 v.clearColumns()
450+ v.releaseLegendWidgets()
444451 v.groupBoxes = nil
445452 v.rows = make(map[string][]*gtk.Widget)
446453 v.keycaps = make(map[string][]*gtk.Widget)
@@ -457,11 +464,13 @@ func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
457464
458465 func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
459466 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
467+ v.legendWidgets = append(v.legendWidgets, &box.Widget)
460468 box.SetCanFocus(false)
461469 box.SetCanTarget(false)
462470 box.AddCssClass("mode-legend-group")
463471 name := group.Name
464472 title := gtk.NewLabel(&name)
473+ v.legendWidgets = append(v.legendWidgets, &title.Widget)
465474 title.SetCanFocus(false)
466475 title.SetCanTarget(false)
467476 title.SetHalign(gtk.AlignStartValue)
@@ -475,6 +484,7 @@ func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
475484
476485 func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
477486 box := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
487+ v.legendWidgets = append(v.legendWidgets, &box.Widget)
478488 box.SetCanFocus(false)
479489 box.SetCanTarget(false)
480490 box.AddCssClass("mode-legend-row")
@@ -482,6 +492,7 @@ func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
482492 for _, key := range row.Keys {
483493 display := displayLegendKey(key)
484494 keycap := gtk.NewLabel(&display)
495+ v.legendWidgets = append(v.legendWidgets, &keycap.Widget)
485496 keycap.SetCanFocus(false)
486497 keycap.SetCanTarget(false)
487498 keycap.AddCssClass("mode-legend-keycap")
@@ -490,6 +501,7 @@ func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
490501 }
491502 desc := row.Desc
492503 text := gtk.NewLabel(&desc)
504+ v.legendWidgets = append(v.legendWidgets, &text.Widget)
493505 text.SetCanFocus(false)
494506 text.SetCanTarget(false)
495507 text.SetHalign(gtk.AlignStartValue)
@@ -507,10 +519,22 @@ func (v *gtkModeToasterView) clearColumns() {
507519 col.Remove(&group.Widget)
508520 }
509521 v.columnBox.Remove(&col.Widget)
522+ col.Unref() // drop the reference NewBox gave us
510523 }
511524 v.columnBoxes, v.columns = nil, nil
512525 }
513526
527+// releaseLegendWidgets drops our reference to every widget of the current
528+// legend. The group boxes must already be detached from the columns.
529+func (v *gtkModeToasterView) releaseLegendWidgets() {
530+ for _, widget := range v.legendWidgets {
531+ widget.Unref()
532+ }
533+ v.legendWidgets = nil
534+ v.groupBoxes, v.groups = nil, nil
535+ v.rows, v.keycaps = make(map[string][]*gtk.Widget), make(map[string][]*gtk.Widget)
536+}
537+
514538 // layoutColumns distributes the groups over count columns.
515539 func (v *gtkModeToasterView) layoutColumns(count int) {
516540 v.clearColumns()
@@ -702,10 +726,26 @@ func (v *gtkModeToasterView) destroy() {
702726 v.positionHandlerID = 0
703727 }
704728 v.placement = nil
729+ v.clearColumns()
730+ v.releaseLegendWidgets()
705731 v.overlay.RemoveOverlay(&v.pill.Widget)
706732 v.overlay.RemoveOverlay(&v.root.Widget)
733+
734+ // Widgets are handed to us with a reference that GTK does not release on
735+ // removal: drop it. (Controllers are owned by the widget they were added to,
736+ // which released them on removal.) The callbacks go too, now that nothing
737+ // can invoke them.
738+ for _, widget := range []*gtk.Widget{
739+ &v.pillLabel.Widget, &v.chevron.Widget, &v.hint.Widget, &v.columnBox.Widget,
740+ &v.scroller.Widget, &v.panel.Widget, &v.pill.Widget, &v.root.Widget,
741+ } {
742+ widget.Unref()
743+ }
744+ v.pillClickCb, v.pillScrollCb, v.motionCb, v.enterCb, v.leaveCb, v.keyCb, v.windowClickC = nil, nil, nil, nil, nil, nil, nil
707745 v.overlay, v.window = nil, nil
708- v.pill, v.root = nil, nil
746+ v.pill, v.pillLabel, v.chevron = nil, nil, nil
747+ v.root, v.panel, v.scroller, v.columnBox, v.hint = nil, nil, nil, nil, nil
748+ v.motion, v.keys, v.windowClick, v.pillClick, v.pillScroll = nil, nil, nil, nil, nil
709749 }
710750
711751 // newModeToaster builds the mode toaster of a window inside its content
@@ -82,6 +82,12 @@ type gtkModeToasterView struct {
82 interactive bool82 interactive bool
83 animations bool83 animations bool
84 84
85+ // legendWidgets are the widgets built for the current legend. puregotk
86+ // constructors hand back a reference we own, which GTK does not drop when
87+ // the widget is removed from its parent, so they are unreferenced once the
88+ // legend is replaced or the view destroyed.
89+ legendWidgets []*gtk.Widget
90+
85 groups []legendGroup91 groups []legendGroup
86 groupBoxes []*gtk.Box92 groupBoxes []*gtk.Box
87 columns [][]*gtk.Box // group boxes per column box, as placed93 columns [][]*gtk.Box // group boxes per column box, as placed
@@ -441,6 +447,7 @@ func (v *gtkModeToasterView) setBodyLingering(lingering bool) {
441 // setLegend replaces the legend content; nil empties it.447 // setLegend replaces the legend content; nil empties it.
442 func (v *gtkModeToasterView) setLegend(groups []legendGroup) {448 func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
443 v.clearColumns()449 v.clearColumns()
450+ v.releaseLegendWidgets()
444 v.groupBoxes = nil451 v.groupBoxes = nil
445 v.rows = make(map[string][]*gtk.Widget)452 v.rows = make(map[string][]*gtk.Widget)
446 v.keycaps = make(map[string][]*gtk.Widget)453 v.keycaps = make(map[string][]*gtk.Widget)
@@ -457,11 +464,13 @@ func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
457 464
458 func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {465 func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
459 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)466 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
467+ v.legendWidgets = append(v.legendWidgets, &box.Widget)
460 box.SetCanFocus(false)468 box.SetCanFocus(false)
461 box.SetCanTarget(false)469 box.SetCanTarget(false)
462 box.AddCssClass("mode-legend-group")470 box.AddCssClass("mode-legend-group")
463 name := group.Name471 name := group.Name
464 title := gtk.NewLabel(&name)472 title := gtk.NewLabel(&name)
473+ v.legendWidgets = append(v.legendWidgets, &title.Widget)
465 title.SetCanFocus(false)474 title.SetCanFocus(false)
466 title.SetCanTarget(false)475 title.SetCanTarget(false)
467 title.SetHalign(gtk.AlignStartValue)476 title.SetHalign(gtk.AlignStartValue)
@@ -475,6 +484,7 @@ func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
475 484
476 func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {485 func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
477 box := gtk.NewBox(gtk.OrientationHorizontalValue, 0)486 box := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
487+ v.legendWidgets = append(v.legendWidgets, &box.Widget)
478 box.SetCanFocus(false)488 box.SetCanFocus(false)
479 box.SetCanTarget(false)489 box.SetCanTarget(false)
480 box.AddCssClass("mode-legend-row")490 box.AddCssClass("mode-legend-row")
@@ -482,6 +492,7 @@ func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
482 for _, key := range row.Keys {492 for _, key := range row.Keys {
483 display := displayLegendKey(key)493 display := displayLegendKey(key)
484 keycap := gtk.NewLabel(&display)494 keycap := gtk.NewLabel(&display)
495+ v.legendWidgets = append(v.legendWidgets, &keycap.Widget)
485 keycap.SetCanFocus(false)496 keycap.SetCanFocus(false)
486 keycap.SetCanTarget(false)497 keycap.SetCanTarget(false)
487 keycap.AddCssClass("mode-legend-keycap")498 keycap.AddCssClass("mode-legend-keycap")
@@ -490,6 +501,7 @@ func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
490 }501 }
491 desc := row.Desc502 desc := row.Desc
492 text := gtk.NewLabel(&desc)503 text := gtk.NewLabel(&desc)
504+ v.legendWidgets = append(v.legendWidgets, &text.Widget)
493 text.SetCanFocus(false)505 text.SetCanFocus(false)
494 text.SetCanTarget(false)506 text.SetCanTarget(false)
495 text.SetHalign(gtk.AlignStartValue)507 text.SetHalign(gtk.AlignStartValue)
@@ -507,10 +519,22 @@ func (v *gtkModeToasterView) clearColumns() {
507 col.Remove(&group.Widget)519 col.Remove(&group.Widget)
508 }520 }
509 v.columnBox.Remove(&col.Widget)521 v.columnBox.Remove(&col.Widget)
522+ col.Unref() // drop the reference NewBox gave us
510 }523 }
511 v.columnBoxes, v.columns = nil, nil524 v.columnBoxes, v.columns = nil, nil
512 }525 }
513 526
527+// releaseLegendWidgets drops our reference to every widget of the current
528+// legend. The group boxes must already be detached from the columns.
529+func (v *gtkModeToasterView) releaseLegendWidgets() {
530+ for _, widget := range v.legendWidgets {
531+ widget.Unref()
532+ }
533+ v.legendWidgets = nil
534+ v.groupBoxes, v.groups = nil, nil
535+ v.rows, v.keycaps = make(map[string][]*gtk.Widget), make(map[string][]*gtk.Widget)
536+}
537+
514 // layoutColumns distributes the groups over count columns.538 // layoutColumns distributes the groups over count columns.
515 func (v *gtkModeToasterView) layoutColumns(count int) {539 func (v *gtkModeToasterView) layoutColumns(count int) {
516 v.clearColumns()540 v.clearColumns()
@@ -702,10 +726,26 @@ func (v *gtkModeToasterView) destroy() {
702 v.positionHandlerID = 0726 v.positionHandlerID = 0
703 }727 }
704 v.placement = nil728 v.placement = nil
729+ v.clearColumns()
730+ v.releaseLegendWidgets()
705 v.overlay.RemoveOverlay(&v.pill.Widget)731 v.overlay.RemoveOverlay(&v.pill.Widget)
706 v.overlay.RemoveOverlay(&v.root.Widget)732 v.overlay.RemoveOverlay(&v.root.Widget)
733+
734+ // Widgets are handed to us with a reference that GTK does not release on
735+ // removal: drop it. (Controllers are owned by the widget they were added to,
736+ // which released them on removal.) The callbacks go too, now that nothing
737+ // can invoke them.
738+ for _, widget := range []*gtk.Widget{
739+ &v.pillLabel.Widget, &v.chevron.Widget, &v.hint.Widget, &v.columnBox.Widget,
740+ &v.scroller.Widget, &v.panel.Widget, &v.pill.Widget, &v.root.Widget,
741+ } {
742+ widget.Unref()
743+ }
744+ v.pillClickCb, v.pillScrollCb, v.motionCb, v.enterCb, v.leaveCb, v.keyCb, v.windowClickC = nil, nil, nil, nil, nil, nil, nil
707 v.overlay, v.window = nil, nil745 v.overlay, v.window = nil, nil
708- v.pill, v.root = nil, nil746+ v.pill, v.pillLabel, v.chevron = nil, nil, nil
747+ v.root, v.panel, v.scroller, v.columnBox, v.hint = nil, nil, nil, nil, nil
748+ v.motion, v.keys, v.windowClick, v.pillClick, v.pillScroll = nil, nil, nil, nil, nil
709 }749 }
710 750
711 // newModeToaster builds the mode toaster of a window inside its content751 // newModeToaster builds the mode toaster of a window inside its content
A internal/ui/mode_toaster_view_gtk_test.go +67 -0
@@ -0,0 +1,67 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/glib"
7+ "github.com/bnema/puregotk/v4/gobject"
8+ "github.com/bnema/puregotk/v4/gtk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// watchFinalized reports, through the returned flag, when widget is finalized.
13+func watchFinalized(t *testing.T, widget *gtk.Widget) *bool {
14+ t.Helper()
15+ finalized := new(bool)
16+ notify := gobject.WeakNotify(func(_, _ uintptr) { *finalized = true })
17+ t.Cleanup(func() { _ = notify })
18+ widget.WeakRef(&notify, 0)
19+ return finalized
20+}
21+
22+func TestGTKModeToasterViewReleasesWidgets(t *testing.T) {
23+ gtkApp := requireGTKDisplayApp(t)
24+ defer gtkApp.Unref()
25+
26+ var view *gtkModeToasterView
27+ done := make(chan struct{})
28+ run := glib.SourceFunc(func(uintptr) bool {
29+ defer close(done)
30+ window := gtk.NewApplicationWindow(gtkApp)
31+ overlay := gtk.NewOverlay()
32+ window.SetChild(&overlay.Widget)
33+ view = newGTKModeToasterView(overlay, window, modeToasterEvents{})
34+ require.NotNil(t, view)
35+
36+ groups := []legendGroup{{Name: "SCROLL", Rows: []legendRow{{Action: "down", Keys: []string{"j"}, Desc: "Down"}}}}
37+ view.setLegend(groups)
38+ row := watchFinalized(t, view.rows["down"][0])
39+ keycap := watchFinalized(t, view.keycaps["down"][0])
40+ group := watchFinalized(t, &view.groupBoxes[0].Widget)
41+
42+ view.setLegend(nil)
43+ require.True(t, *row, "a replaced legend row is released")
44+ require.True(t, *keycap, "a replaced legend keycap is released")
45+ require.True(t, *group, "a replaced legend group is released")
46+
47+ view.setLegend(groups)
48+ view.layoutColumns(1)
49+ column := watchFinalized(t, &view.columnBoxes[0].Widget)
50+ view.layoutColumns(2)
51+ require.True(t, *column, "a column dropped by a relayout is released")
52+
53+ pill := watchFinalized(t, &view.pill.Widget)
54+ body := watchFinalized(t, &view.root.Widget)
55+ label := watchFinalized(t, &view.pillLabel.Widget)
56+ lastRow := watchFinalized(t, view.rows["down"][0])
57+ view.destroy()
58+ require.True(t, *pill, "destroy releases the pill")
59+ require.True(t, *body, "destroy releases the legend body")
60+ require.True(t, *label, "destroy releases the pill label")
61+ require.True(t, *lastRow, "destroy releases the legend rows")
62+ window.Destroy()
63+ return false
64+ })
65+ glib.MainContextDefault().Invoke(&run, 0)
66+ <-done
67+}
@@ -0,0 +1,67 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/glib"
7+ "github.com/bnema/puregotk/v4/gobject"
8+ "github.com/bnema/puregotk/v4/gtk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// watchFinalized reports, through the returned flag, when widget is finalized.
13+func watchFinalized(t *testing.T, widget *gtk.Widget) *bool {
14+ t.Helper()
15+ finalized := new(bool)
16+ notify := gobject.WeakNotify(func(_, _ uintptr) { *finalized = true })
17+ t.Cleanup(func() { _ = notify })
18+ widget.WeakRef(&notify, 0)
19+ return finalized
20+}
21+
22+func TestGTKModeToasterViewReleasesWidgets(t *testing.T) {
23+ gtkApp := requireGTKDisplayApp(t)
24+ defer gtkApp.Unref()
25+
26+ var view *gtkModeToasterView
27+ done := make(chan struct{})
28+ run := glib.SourceFunc(func(uintptr) bool {
29+ defer close(done)
30+ window := gtk.NewApplicationWindow(gtkApp)
31+ overlay := gtk.NewOverlay()
32+ window.SetChild(&overlay.Widget)
33+ view = newGTKModeToasterView(overlay, window, modeToasterEvents{})
34+ require.NotNil(t, view)
35+
36+ groups := []legendGroup{{Name: "SCROLL", Rows: []legendRow{{Action: "down", Keys: []string{"j"}, Desc: "Down"}}}}
37+ view.setLegend(groups)
38+ row := watchFinalized(t, view.rows["down"][0])
39+ keycap := watchFinalized(t, view.keycaps["down"][0])
40+ group := watchFinalized(t, &view.groupBoxes[0].Widget)
41+
42+ view.setLegend(nil)
43+ require.True(t, *row, "a replaced legend row is released")
44+ require.True(t, *keycap, "a replaced legend keycap is released")
45+ require.True(t, *group, "a replaced legend group is released")
46+
47+ view.setLegend(groups)
48+ view.layoutColumns(1)
49+ column := watchFinalized(t, &view.columnBoxes[0].Widget)
50+ view.layoutColumns(2)
51+ require.True(t, *column, "a column dropped by a relayout is released")
52+
53+ pill := watchFinalized(t, &view.pill.Widget)
54+ body := watchFinalized(t, &view.root.Widget)
55+ label := watchFinalized(t, &view.pillLabel.Widget)
56+ lastRow := watchFinalized(t, view.rows["down"][0])
57+ view.destroy()
58+ require.True(t, *pill, "destroy releases the pill")
59+ require.True(t, *body, "destroy releases the legend body")
60+ require.True(t, *label, "destroy releases the pill label")
61+ require.True(t, *lastRow, "destroy releases the legend rows")
62+ window.Destroy()
63+ return false
64+ })
65+ glib.MainContextDefault().Invoke(&run, 0)
66+ <-done
67+}

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