diff --git a/internal/ui/app.go b/internal/ui/app.go index 3117ed2..d835b18 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -458,9 +458,9 @@ func (a *App) onAppActivate(ctx context.Context) { gutil.Connect(a.win.MainWindow, a.win.MainWindow.ConnectCloseRequest, func(gtk.Window) bool { win := a.win a.win = nil - // The window is destroyed after this returns; release the - // window's Go-side references once the main loop is idle. - gutil.IdleAdd(win.Destroy) + // The window is destroyed after this returns; drop ours once + // the main loop is idle. + gutil.IdleAdd(win.Widget().Unref) return false }) diff --git a/internal/ui/builder_test.go b/internal/ui/builder_test.go index 72f9a1f..5b7abd5 100644 --- a/internal/ui/builder_test.go +++ b/internal/ui/builder_test.go @@ -3,19 +3,20 @@ package ui import ( "testing" - "deedles.dev/trayscale/internal/gutil" + "codeberg.org/puregotk/puregotk/v4/adw" + "codeberg.org/puregotk/puregotk/v4/gio" "deedles.dev/trayscale/internal/tsutil" "tailscale.com/ipn" "tailscale.com/tailcfg" ) -// TestFillFromBuilder builds every .ui file into its Go struct so that -// a type missing from gutil's registry fails here instead of panicking -// at runtime. -func TestFillFromBuilder(t *testing.T) { +// TestTemplates builds every template class through its constructor, +// so a template child whose type doesn't match its field fails here +// instead of panicking at runtime. +func TestTemplates(t *testing.T) { requireGTK(t) - fill := func(t *testing.T, build func()) { + build := func(t *testing.T, build func()) { t.Helper() defer func() { if r := recover(); r != nil { @@ -25,29 +26,33 @@ func TestFillFromBuilder(t *testing.T) { build() } + running := &tsutil.IPNStatus{State: ipn.Running, Prefs: ipn.NewPrefs().View()} + t.Run("MainWindow", func(t *testing.T) { - var win MainWindow - fill(t, func() { gutil.FillFromUI(&win, menuXML, mainWindowXML); gutil.UnrefFields(&win) }) + app := &App{app: adw.NewApplication("dev.deedles.Trayscale.test.templates", gio.GApplicationNonUniqueValue)} + defer app.app.Unref() + build(t, func() { + win := NewMainWindow(app) + win.MainWindow.Destroy() + win.Widget().Unref() + }) }) t.Run("OfflinePage", func(t *testing.T) { - fill(t, func() { offlinePageClass.New().Destroy() }) + build(t, func() { offlinePageClass.New().Widget().Unref() }) }) t.Run("PeerPage", func(t *testing.T) { peer := layoutPeer(t, "peer", "peer", true, false, false) status := layoutStatus([]tailcfg.NodeView{peer}) status.Prefs = ipn.NewPrefs().View() - fill(t, func() { NewPeerPage(&App{}, status, peer).Destroy() }) + build(t, func() { NewPeerPage(&App{}, status, peer).Widget().Unref() }) }) t.Run("SelfPage", func(t *testing.T) { - status := &tsutil.IPNStatus{State: ipn.Running, Prefs: ipn.NewPrefs().View()} - fill(t, func() { NewSelfPage(&App{}, status).Destroy() }) + build(t, func() { NewSelfPage(&App{}, running).Widget().Unref() }) }) t.Run("MullvadPage", func(t *testing.T) { - status := &tsutil.IPNStatus{State: ipn.Running, Prefs: ipn.NewPrefs().View()} - fill(t, func() { NewMullvadPage(&App{}, status).Destroy() }) + build(t, func() { NewMullvadPage(&App{}, running).Widget().Unref() }) }) t.Run("PreferencesDialog", func(t *testing.T) { - var dialog PreferencesDialog - fill(t, func() { gutil.FillFromUI(&dialog, preferencesXML); gutil.UnrefFields(&dialog) }) + build(t, func() { NewPreferencesDialog().Widget().Unref() }) }) } diff --git a/internal/ui/leak_test.go b/internal/ui/leak_test.go index 7fb0644..09ada42 100644 --- a/internal/ui/leak_test.go +++ b/internal/ui/leak_test.go @@ -59,7 +59,7 @@ func TestPeerPageLeaks(t *testing.T) { } stack.Remove(page.Widget()) vp.Unref() - page.Destroy() + page.Widget().Unref() } const warmup, n, objs = 5, 200, 6 @@ -102,7 +102,7 @@ func TestSelfPageLeaks(t *testing.T) { } stack.Remove(page.Widget()) vp.Unref() - page.Destroy() + page.Widget().Unref() } const warmup, n, objs = 5, 200, 5 @@ -171,7 +171,7 @@ func TestMullvadPageLeaks(t *testing.T) { stack.Remove(page.Widget()) vp.Unref() - page.Destroy() + page.Widget().Unref() } const warmup, n = 5, 100 diff --git a/internal/ui/mainwindow.go b/internal/ui/mainwindow.go index 7cd5658..5f5ae21 100644 --- a/internal/ui/mainwindow.go +++ b/internal/ui/mainwindow.go @@ -15,6 +15,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gio" "codeberg.org/puregotk/puregotk/v4/glib" "codeberg.org/puregotk/puregotk/v4/gtk" + "deedles.dev/trayscale/internal/gobj" "deedles.dev/trayscale/internal/gutil" "deedles.dev/trayscale/internal/listmodels" "deedles.dev/trayscale/internal/locale" @@ -33,10 +34,20 @@ var ( menuXML string ) +// The main window's template carries menu.ui's menus, which its menu +// buttons refer to by id. +var mainWindowClass = gobj.Widget[MainWindow](gobj.WidgetSpec{ + Name: "TrayscaleMainWindow", + Parent: adw.ApplicationWindowGLibType, + Template: strings.Replace(mainWindowXML, "", menuXML[strings.Index(menuXML, "")]+"", 1), +}) + type MainWindow struct { + gobj.Base + app *App - MainWindow *adw.ApplicationWindow + MainWindow *adw.ApplicationWindow `gtk:"-"` ToastOverlay *adw.ToastOverlay SplitView *adw.NavigationSplitView StatusSwitch *gtk.Switch @@ -64,16 +75,15 @@ type MainWindow struct { } func NewMainWindow(app *App) *MainWindow { - win := MainWindow{ - app: app, - pages: make(map[string]Page), - stackPages: make(map[string]*adw.ViewStackPage), - showOffline: true, - } + win := mainWindowClass.New() + win.MainWindow = adw.ApplicationWindowNewFromInternalPtr(win.GoPointer()) + win.app = app + win.pages = make(map[string]Page) + win.stackPages = make(map[string]*adw.ViewStackPage) + win.showOffline = true if app.settings != nil { win.showOffline = app.settings.GetBoolean("show-offline-peers") } - gutil.FillFromUI(&win, menuXML, mainWindowXML) win.MainWindow.SetApplication(&app.app.Application) @@ -155,7 +165,7 @@ func NewMainWindow(app *App) *MainWindow { win.initPeerSearch() - return &win + return win } func (win *MainWindow) initPeerSearch() { @@ -229,7 +239,7 @@ func (win *MainWindow) removePage(name string, page Page) { vp.Unref() delete(win.stackPages, name) } - page.Destroy() + page.Widget().Unref() if !reselect { return @@ -241,11 +251,11 @@ func (win *MainWindow) removePage(name string, page Page) { win.PeersStack.SetVisibleChildName(remaining[0].name) } -// Destroy releases the window's pages and Go-side references after the -// window has been closed. -func (win *MainWindow) Destroy() { +// Dispose releases the window's pages and Go-side references when the +// window is destroyed. +func (win *MainWindow) Dispose() { for _, page := range win.pages { - page.Destroy() + page.Widget().Unref() } clear(win.pages) for _, vp := range win.stackPages { @@ -254,7 +264,6 @@ func (win *MainWindow) Destroy() { clear(win.stackPages) win.profileModel.Unref() win.profileSortModel.Unref() - gutil.UnrefFields(win) } func (win *MainWindow) Update(status tsutil.Status) { diff --git a/internal/ui/mainwindow.ui b/internal/ui/mainwindow.ui index 5dec5d5..cb2e23d 100644 --- a/internal/ui/mainwindow.ui +++ b/internal/ui/mainwindow.ui @@ -1,10 +1,10 @@ - + - + diff --git a/internal/ui/migration_repro_test.go b/internal/ui/migration_repro_test.go index 4e3b509..54d9f17 100644 --- a/internal/ui/migration_repro_test.go +++ b/internal/ui/migration_repro_test.go @@ -72,7 +72,6 @@ func (p *stackStub) Widget() *gtk.Widget { return p.w } func (p *stackStub) Actions() *gio.SimpleActionGroup { return nil } func (p *stackStub) Bind(vp *adw.ViewStackPage) { p.page = vp } func (p *stackStub) Update(tsutil.Status) bool { p.page.SetTitle("still-bound"); return true } -func (p *stackStub) Destroy() { p.w.Unref() } func TestRestackReleasesReplacedStackPage(t *testing.T) { requireGTK(t) @@ -107,7 +106,7 @@ func TestRestackReleasesReplacedStackPage(t *testing.T) { t.Fatalf("replaced stack page finalized %d times, want 1", replaced) } - win.Destroy() + win.Dispose() if kept != 1 { t.Fatalf("filtered stack page finalized %d times after Destroy, want 1", kept) } diff --git a/internal/ui/mullvadpage.go b/internal/ui/mullvadpage.go index 78858c5..b6096ff 100644 --- a/internal/ui/mullvadpage.go +++ b/internal/ui/mullvadpage.go @@ -111,10 +111,6 @@ func (page *MullvadPage) Actions() *gio.SimpleActionGroup { return nil } -func (page *MullvadPage) Destroy() { - page.Widget().Unref() -} - func (page *MullvadPage) Dispose() { for _, node := range page.exitNodes { node.row.Unref() diff --git a/internal/ui/offlinepage.go b/internal/ui/offlinepage.go index 48ee2c9..dff0979 100644 --- a/internal/ui/offlinepage.go +++ b/internal/ui/offlinepage.go @@ -34,10 +34,6 @@ func (page *OfflinePage) Bind(stackPage *adw.ViewStackPage) { stackPage.SetIconName(page.Page.GetIconName()) } -func (page *OfflinePage) Destroy() { - page.Widget().Unref() -} - func (page *OfflinePage) Update(status tsutil.Status) bool { if status, ok := status.(*tsutil.IPNStatus); ok { page.NeedsAuthGroup.SetVisible(status.NeedsAuth()) diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 4304405..0ab738e 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -181,10 +181,6 @@ func (page *PeerPage) Actions() *gio.SimpleActionGroup { return page.actions } -func (page *PeerPage) Destroy() { - page.Widget().Unref() -} - func (page *PeerPage) Dispose() { page.addrModel.Unref() page.routeModel.Unref() diff --git a/internal/ui/preferences.go b/internal/ui/preferences.go index 6a78967..5272dfb 100644 --- a/internal/ui/preferences.go +++ b/internal/ui/preferences.go @@ -5,14 +5,22 @@ import ( "codeberg.org/puregotk/puregotk/v4/adw" "codeberg.org/puregotk/puregotk/v4/gtk" - "deedles.dev/trayscale/internal/gutil" + "deedles.dev/trayscale/internal/gobj" ) //go:embed preferences.ui var preferencesXML string +var preferencesDialogClass = gobj.Widget[PreferencesDialog](gobj.WidgetSpec{ + Name: "TrayscalePreferencesDialog", + Parent: adw.PreferencesDialogGLibType, + Template: preferencesXML, +}) + type PreferencesDialog struct { - PreferencesDialog *adw.PreferencesDialog + gobj.Base + + PreferencesDialog *adw.PreferencesDialog `gtk:"-"` UseTrayIconRow *adw.SwitchRow PollingIntervalRow *adw.SpinRow PollingIntervalAdjustment *gtk.Adjustment @@ -21,7 +29,7 @@ type PreferencesDialog struct { } func NewPreferencesDialog() *PreferencesDialog { - var win PreferencesDialog - gutil.FillFromUI(&win, preferencesXML) - return &win + win := preferencesDialogClass.New() + win.PreferencesDialog = adw.PreferencesDialogNewFromInternalPtr(win.GoPointer()) + return win } diff --git a/internal/ui/preferences.ui b/internal/ui/preferences.ui index 9744972..490d275 100644 --- a/internal/ui/preferences.ui +++ b/internal/ui/preferences.ui @@ -1,10 +1,10 @@ - + - + diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index 4271011..5ef7e58 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -327,10 +327,6 @@ func (page *SelfPage) Actions() *gio.SimpleActionGroup { return page.actions } -func (page *SelfPage) Destroy() { - page.Widget().Unref() -} - func (page *SelfPage) Dispose() { page.setLatencies(nil) page.addrModel.Unref() diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 9552c7a..072f4a6 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -204,12 +204,8 @@ func (a *App) showPreferences() { }) }) - // The presented dialog keeps itself alive; drop the fields' - // references once it's closed. - gutil.Connect(dialog.PreferencesDialog, dialog.PreferencesDialog.ConnectClosed, func(adw.Dialog) { - gutil.IdleAdd(func() { gutil.UnrefFields(dialog) }) - }) dialog.PreferencesDialog.Present(a.parent()) + dialog.Widget().Unref() // Presented dialogs keep themselves alive. } // showAbout shows the app's about dialog. diff --git a/internal/ui/slots_test.go b/internal/ui/slots_test.go index f254e44..d1a4baf 100644 --- a/internal/ui/slots_test.go +++ b/internal/ui/slots_test.go @@ -46,11 +46,11 @@ func slotsInUse(t *testing.T, n, m int) (used int) { } for _, p := range peers { stack.Remove(p.Widget()) - p.Destroy() + p.Widget().Unref() } if mullvad != nil { stack.Remove(mullvad.Widget()) - mullvad.Destroy() + mullvad.Widget().Unref() } // Shared callbacks made on first use stay, so wait for the // main loop to go idle rather than for the count to return. @@ -146,7 +146,7 @@ func BenchmarkMullvadPage(b *testing.B) { } page.applySearch() stack.Remove(page.Widget()) - page.Destroy() + page.Widget().Unref() } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 78afa9f..4d1cc43 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -56,7 +56,8 @@ func optBoolIcon(v opt.Bool) *gio.ThemedIcon { // Page represents the UI for a single page of the app. This usually // corresponds to information about a specific peer in the tailnet. type Page interface { - // Widget returns the page's widget, borrowed from the page. + // Widget returns the page's widget, borrowed from the page. Whoever + // holds the page owns one reference to it. Widget() *gtk.Widget // Actions returns the page's action group, borrowed from the page, // or nil. @@ -68,8 +69,4 @@ type Page interface { // in the constructor, not here. Bind(*adw.ViewStackPage) Update(tsutil.Status) bool - - // Destroy releases the page's references. It is called once, after - // the page's widget has been removed from the stack. - Destroy() }