From 9d35b6fe0b08cd195f2a65a228db63f4570e243d Mon Sep 17 00:00:00 2001 From: Coder Date: Fri, 16 Oct 2026 16:48:10 -0400 Subject: [PATCH 1/3] internal/gobj: add a list update benchmark --- internal/gobj/list_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/gobj/list_test.go b/internal/gobj/list_test.go index 7643d2c..2330ef0 100644 --- a/internal/gobj/list_test.go +++ b/internal/gobj/list_test.go @@ -2,6 +2,7 @@ package gobj import ( "cmp" + "net/netip" "slices" "strconv" "testing" @@ -100,3 +101,25 @@ func TestListFreesItems(t *testing.T) { t.Errorf("%d items live after dropping the list", got) } } + +// BenchmarkListSet measures the update the UI performs on every status +// change, with the list already holding every value. +func BenchmarkListSet(b *testing.B) { + initGTK() + for _, n := range []int{100, 1000, 5478} { + b.Run(strconv.Itoa(n), func(b *testing.B) { + l := NewList(netip.Prefix.Compare) + defer l.Unref() + prefixes := make([]netip.Prefix, 0, n) + for i := range n { + addr := netip.AddrFrom4([4]byte{10, byte(i >> 16), byte(i >> 8), byte(i)}) + prefixes = append(prefixes, netip.PrefixFrom(addr, 32)) + } + l.Set(prefixes) + + for b.Loop() { + l.Set(prefixes) + } + }) + } +} From dbc20018cdb1001283b63c832e4b7e351f1f4f3f Mon Sep 17 00:00:00 2001 From: Coder Date: Fri, 16 Oct 2026 16:48:10 -0400 Subject: [PATCH 2/3] internal/ui: move address, route, and file lists to gobj.List and drop rowManager --- internal/ui/leak_test.go | 4 +- internal/ui/peerpage.go | 22 +++++----- internal/ui/rowmanager.go | 92 --------------------------------------- internal/ui/selfpage.go | 86 ++++++++++++++---------------------- 4 files changed, 45 insertions(+), 159 deletions(-) delete mode 100644 internal/ui/rowmanager.go diff --git a/internal/ui/leak_test.go b/internal/ui/leak_test.go index fb8a624..7fb0644 100644 --- a/internal/ui/leak_test.go +++ b/internal/ui/leak_test.go @@ -50,7 +50,7 @@ func TestPeerPageLeaks(t *testing.T) { peer := peerAt(i) page := NewPeerPage(&App{}, status(peer), peer) track(&finalized, page.Page, page.actions, page.sendFileAction, page.DropTarget, - page.addrModel.ListModel(), page.routeModel.ListModel()) + page.addrModel.Model(), page.routeModel.Model()) vp := stack.AddNamed(page.Widget(), "peer") page.Bind(vp) for j := range 10 { @@ -94,7 +94,7 @@ func TestSelfPageLeaks(t *testing.T) { cycle := func(i int) { page := NewSelfPage(&App{}, &tsutil.IPNStatus{State: ipn.Running, Prefs: ipn.NewPrefs().View()}) track(&finalized, page.Page, page.actions, - page.addrModel.ListModel(), page.routeModel.ListModel(), page.fileModel.ListModel()) + page.addrModel.Model(), page.routeModel.Model(), page.fileModel.Model()) vp := stack.AddNamed(page.Widget(), "self") page.Bind(vp) for j := range 10 { diff --git a/internal/ui/peerpage.go b/internal/ui/peerpage.go index 66e6f7c..cee5d10 100644 --- a/internal/ui/peerpage.go +++ b/internal/ui/peerpage.go @@ -5,6 +5,7 @@ import ( _ "embed" "log/slog" "net/netip" + "slices" "strconv" "strings" @@ -14,6 +15,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gobject" "codeberg.org/puregotk/puregotk/v4/gobject/types" "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" @@ -56,8 +58,8 @@ type PeerPage struct { sendFileAction *gio.SimpleAction - addrModel *listmodels.Model[netip.Addr] - routeModel *listmodels.Model[netip.Prefix] + addrModel *gobj.List[netip.Addr] + routeModel *gobj.List[netip.Prefix] } func NewPeerPage(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeView) *PeerPage { @@ -127,21 +129,17 @@ func (page *PeerPage) init(a *App, status *tsutil.IPNStatus, peer tailcfg.NodeVi return true }) - page.addrModel = listmodels.NewModel(netip.Addr.String) - addrs := page.addrModel.NewSortListModel(netip.Addr.Compare) - page.addrModel.BindListBox(page.IPList, addrs, func(addr netip.Addr) *gtk.Widget { + page.addrModel = gobj.NewList(netip.Addr.Compare) + gobj.BindListBox(page.IPList, page.addrModel, func(addr netip.Addr) *gtk.Widget { return a.copyRow(addr.String()) }) - addrs.Unref() // The list box holds it. setPlaceholder(page.IPList, locale.Get("No addresses.")) - page.routeModel = listmodels.NewModel(netip.Prefix.String) - routes := page.routeModel.NewSortListModel(netip.Prefix.Compare) - page.routeModel.BindListBox(page.AdvertisedRoutesList, routes, func(route netip.Prefix) *gtk.Widget { + page.routeModel = gobj.NewList(netip.Prefix.Compare) + gobj.BindListBox(page.AdvertisedRoutesList, page.routeModel, func(route netip.Prefix) *gtk.Widget { row := routeActionRow(route) return &row.Widget }) - routes.Unref() setPlaceholder(page.AdvertisedRoutesList, locale.Get("No advertised routes.")) sw := rowSwitch(page.ExitNodeRow) @@ -261,8 +259,8 @@ func (page *PeerPage) Update(s tsutil.Status) bool { } } - page.addrModel.Update(xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr)) - page.routeModel.Update(routes) + page.addrModel.Set(slices.Collect(xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr))) + page.routeModel.Set(slices.Collect(routes)) return true } diff --git a/internal/ui/rowmanager.go b/internal/ui/rowmanager.go deleted file mode 100644 index c97be05..0000000 --- a/internal/ui/rowmanager.go +++ /dev/null @@ -1,92 +0,0 @@ -package ui - -import ( - "iter" - "slices" - - "codeberg.org/puregotk/puregotk/v4/gtk" - "deedles.dev/xiter" -) - -// TODO: Move this into a different package and remove the need to keep -// track of the parent and other pieces of tight coupling to GTK. -type rowManager[Data any] struct { - Parent rowManagerParent - New func(Data) row[Data] - - rows []row[Data] -} - -func (m *rowManager[Data]) resize(size int) { - if size >= cap(m.rows) { - m.rows = slices.Grow(m.rows, size-cap(m.rows)) - return - } - - if size < len(m.rows) { - for _, r := range m.rows[size:] { - m.Parent.Remove(r.Widget()) - r.Widget().Unref() - } - clear(m.rows[size:]) - m.rows = m.rows[:size] - } -} - -func (m *rowManager[Data]) Update(data []Data) { - m.UpdateFromSeq(slices.Values(data), len(data)) -} - -func (m *rowManager[Data]) UpdateFromSeq(data iter.Seq[Data], size int) { - m.resize(size) - - edata := xiter.Enumerate(data) - for i, d := range edata { - if i < len(m.rows) { - m.rows[i].Update(d) - continue - } - - row := m.New(d) - m.Parent.Add(row.Widget()) - m.rows = append(m.rows, row) - } -} - -type rowManagerParent interface { - Add(*gtk.Widget) - Remove(*gtk.Widget) -} - -type rowAdder interface { - AddRow(*gtk.Widget) - Remove(*gtk.Widget) -} - -type rowAdderParent struct { - rowAdder -} - -func (r rowAdderParent) Add(w *gtk.Widget) { - r.AddRow(w) -} - -// row is one managed row. The row owns a reference to its widget, -// which the manager drops when the row is removed. -type row[Data any] interface { - Update(Data) - Widget() *gtk.Widget -} - -type simpleRow[Data any] struct { - W *gtk.Widget - U func(Data) -} - -func (row *simpleRow[Data]) Update(data Data) { - row.U(data) -} - -func (row *simpleRow[Data]) Widget() *gtk.Widget { - return row.W -} diff --git a/internal/ui/selfpage.go b/internal/ui/selfpage.go index ec7cc32..b3d90db 100644 --- a/internal/ui/selfpage.go +++ b/internal/ui/selfpage.go @@ -14,8 +14,8 @@ 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" "deedles.dev/trayscale/internal/tsutil" "deedles.dev/xiter" @@ -67,12 +67,12 @@ type SelfPage struct { DERPLatencies *adw.ExpanderRow FilesList *gtk.ListBox - addrModel *listmodels.Model[netip.Addr] - routeModel *listmodels.Model[netip.Prefix] - fileModel *listmodels.Model[apitype.WaitingFile] + addrModel *gobj.List[netip.Addr] + routeModel *gobj.List[netip.Prefix] + fileModel *gobj.List[apitype.WaitingFile] incomingFiles int - latencyRows rowManager[xiter.Pair[string, time.Duration]] + latencyRows []*adw.ActionRow } func NewSelfPage(a *App, status *tsutil.IPNStatus) *SelfPage { @@ -96,28 +96,18 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { a.win.Toast(locale.Get("Copied FQDN to clipboard")) }) - page.addrModel = listmodels.NewModel(netip.Addr.String) - addrs := page.addrModel.NewSortListModel(netip.Addr.Compare) - page.addrModel.BindListBox(page.IPList, addrs, func(addr netip.Addr) *gtk.Widget { + page.addrModel = gobj.NewList(netip.Addr.Compare) + gobj.BindListBox(page.IPList, page.addrModel, func(addr netip.Addr) *gtk.Widget { return a.copyRow(addr.String()) }) - addrs.Unref() // The list box holds it. setPlaceholder(page.IPList, locale.Get("No addresses.")) - page.routeModel = listmodels.NewModel(netip.Prefix.String) - routes := page.routeModel.NewSortListModel(netip.Prefix.Compare) - page.routeModel.BindListBox(page.AdvertisedRoutesList, routes, func(route netip.Prefix) *gtk.Widget { - return page.routeRow(route) - }) - routes.Unref() + page.routeModel = gobj.NewList(netip.Prefix.Compare) + gobj.BindListBox(page.AdvertisedRoutesList, page.routeModel, page.routeRow) setPlaceholder(page.AdvertisedRoutesList, locale.Get("No advertised routes.")) - page.fileModel = listmodels.NewModel(waitingFileKey) - files := page.fileModel.NewSortListModel(compareWaitingFiles) - page.fileModel.BindListBox(page.FilesList, files, func(file apitype.WaitingFile) *gtk.Widget { - return page.fileRow(file) - }) - files.Unref() + page.fileModel = gobj.NewList(func(f1, f2 apitype.WaitingFile) int { return cmp.Compare(f1.Name, f2.Name) }) + gobj.BindListBox(page.FilesList, page.fileModel, page.fileRow) setPlaceholder(page.FilesList, locale.Get("No incoming files.")) page.connectSwitch(page.AdvertiseExitNodeRow, "advertise exit node", func(s bool) error { @@ -177,26 +167,6 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { }) type latencyEntry = xiter.Pair[string, time.Duration] - page.latencyRows = rowManager[latencyEntry]{ - Parent: rowAdderParent{page.DERPLatencies}, - New: func(lat latencyEntry) row[latencyEntry] { - label := gtk.NewLabel(lat.V2.String()) - - row := adw.NewActionRow() - row.SetTitle(lat.V1) - row.AddSuffix(&label.Widget) - label.Unref() // The row holds it; the closure below borrows it. - - return &simpleRow[latencyEntry]{ - W: &row.Widget, - U: func(lat latencyEntry) { - label.SetText(lat.V2.String()) - row.SetTitle(lat.V1) - }, - } - }, - } - gutil.Connect(page.NetCheckButton, page.NetCheckButton.ConnectClicked, func(gtk.Button) { r, dm, err := tsutil.NetCheck(context.TODO(), true) if err != nil { @@ -238,7 +208,7 @@ func (page *SelfPage) init(a *App, status *tsutil.IPNStatus) { } } sortedLats := slices.SortedFunc(namedLats, func(p1, p2 latencyEntry) int { return cmp.Compare(p1.V2, p2.V2) }) - page.latencyRows.Update(sortedLats) + page.setLatencies(sortedLats) }) } @@ -355,7 +325,7 @@ func (page *SelfPage) Actions() *gio.SimpleActionGroup { } func (page *SelfPage) Destroy() { - page.latencyRows.Update(nil) + page.setLatencies(nil) page.addrModel.Unref() page.routeModel.Unref() page.fileModel.Unref() @@ -411,14 +381,14 @@ func (page *SelfPage) UpdateIPN(status *tsutil.IPNStatus) bool { } } - page.addrModel.Update(xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr)) - page.routeModel.Update(routes) + page.addrModel.Set(slices.Collect(xiter.Map(xiter.V2(page.peer.Addresses().All()), netip.Prefix.Addr))) + page.routeModel.Set(slices.Collect(routes)) return true } func (page *SelfPage) UpdateFiles(status *tsutil.FileStatus) bool { - page.fileModel.Update(slices.Values(status.Files)) + page.fileModel.Set(status.Files) page.incomingFiles = len(status.Files) page.applyIncomingBadge() return true @@ -431,11 +401,21 @@ func (page *SelfPage) applyIncomingBadge() { page.stackPage.SetBadgeNumber(uint32(page.incomingFiles)) } -func waitingFileKey(f apitype.WaitingFile) string { return f.Name } - -func compareWaitingFiles(f1, f2 apitype.WaitingFile) int { - return cmp.Or( - cmp.Compare(f1.Name, f2.Name), - cmp.Compare(f1.Size, f2.Size), - ) +// setLatencies replaces the DERP latency rows. A netcheck only runs on +// request, so rebuilding them is cheaper than tracking which changed. +func (page *SelfPage) setLatencies(lats []xiter.Pair[string, time.Duration]) { + for _, row := range page.latencyRows { + page.DERPLatencies.Remove(&row.Widget) + row.Unref() + } + page.latencyRows = page.latencyRows[:0] + for _, lat := range lats { + label := gtk.NewLabel(lat.V2.String()) + row := adw.NewActionRow() + row.SetTitle(lat.V1) + row.AddSuffix(&label.Widget) + label.Unref() + page.DERPLatencies.AddRow(&row.Widget) + page.latencyRows = append(page.latencyRows, row) + } } From da7e02f3b809040117d64a6492180c55c11f9ed3 Mon Sep 17 00:00:00 2001 From: Coder Date: Fri, 16 Oct 2026 16:48:10 -0400 Subject: [PATCH 3/3] internal/listmodels: remove Model --- internal/listmodels/listmodels_test.go | 165 ------------------------- internal/listmodels/model.go | 145 ---------------------- 2 files changed, 310 deletions(-) delete mode 100644 internal/listmodels/model.go diff --git a/internal/listmodels/listmodels_test.go b/internal/listmodels/listmodels_test.go index cb1be13..6429188 100644 --- a/internal/listmodels/listmodels_test.go +++ b/internal/listmodels/listmodels_test.go @@ -1,15 +1,11 @@ package listmodels_test import ( - "cmp" "iter" - "net/netip" "slices" - "strconv" "testing" "codeberg.org/puregotk/puregotk/v4/gtk" - "deedles.dev/trayscale/internal/gutil" "deedles.dev/trayscale/internal/listmodels" ) @@ -31,139 +27,6 @@ func once[T any](t *testing.T, vs ...T) iter.Seq[T] { } } -func TestUpdate(t *testing.T) { - tests := []struct { - name string - in []int - s []int - want []int - }{ - {"empty model", nil, []int{1, 2, 3}, []int{1, 2, 3}}, - {"no change", []int{1, 2, 3}, []int{1, 2, 3}, []int{1, 2, 3}}, - {"append", []int{1, 2}, []int{1, 2, 3}, []int{1, 2, 3}}, - {"remove", []int{1, 2, 3}, []int{1, 3}, []int{1, 3}}, - {"remove all", []int{1, 2, 3}, nil, nil}, - {"add and remove at once", []int{1, 2, 3}, []int{3, 4, 1}, []int{1, 3, 4}}, - {"existing order wins", []int{3, 1, 2}, []int{1, 2, 3}, []int{3, 1, 2}}, - {"new values keep s order", []int{3}, []int{1, 2, 3}, []int{3, 1, 2}}, - {"duplicate in s appended once", nil, []int{1, 1, 2, 1}, []int{1, 2}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := listmodels.NewModel(strconv.Itoa) - defer m.Unref() - for _, v := range tt.in { - m.Append(v) - } - - m.Update(once(t, tt.s...)) - - if got := slices.Collect(m.All()); !slices.Equal(got, tt.want) { - t.Errorf("Update(%v, %v) = %v, want %v", tt.in, tt.s, got, tt.want) - } - }) - } -} - -// TestUpdateRefreshesValues checks that an entry whose key is unchanged -// picks up the newer value, and that All and Value agree with the list. -func TestUpdateRefreshesValues(t *testing.T) { - type file struct { - name string - size int - } - m := listmodels.NewModel(func(f file) string { return f.name }) - defer m.Unref() - m.Update(slices.Values([]file{{"a", 1}, {"b", 2}})) - m.Update(slices.Values([]file{{"b", 3}, {"c", 4}})) - - want := []file{{"b", 3}, {"c", 4}} - if got := slices.Collect(m.All()); !slices.Equal(got, want) { - t.Fatalf("All() = %v, want %v", got, want) - } - if m.Len() != len(want) { - t.Fatalf("Len() = %d, want %d", m.Len(), len(want)) - } - for i, w := range want { - obj := m.ListModel().GetObject(uint32(i)) - if got := m.Value(obj); got != w { - t.Errorf("Value(item %d) = %v, want %v", i, got, w) - } - obj.Unref() - } -} - -func TestUpdateRefreshesBoundRow(t *testing.T) { - if !gtk.InitCheck() { - t.Skip("no display") - } - type file struct { - name string - size string - } - m := listmodels.NewModel(func(f file) string { return f.name }) - defer m.Unref() - lb := gtk.NewListBox() - defer lb.Unref() - m.BindListBox(lb, m.ListModel(), func(f file) *gtk.Widget { - label := gtk.NewLabel(f.size) - return &label.Widget - }) - - m.Update(slices.Values([]file{{name: "notes.txt", size: "1.00B"}})) - m.Update(slices.Values([]file{{name: "notes.txt", size: "5.00MB"}})) - - row := lb.GetRowAtIndex(0) - if row == nil { - t.Fatal("no row") - } - defer row.Unref() - child := row.GetChild() - if child == nil { - t.Fatal("row has no child") - } - defer child.Unref() - label := gutil.MustCast[gtk.Label](child, gtk.LabelGLibType()) - if got := label.GetLabel(); got != "5.00MB" { - t.Fatalf("row label = %q, want %q", got, "5.00MB") - } -} - -// TestComparer sorts through a GtkSortListModel, the way the pages use it. -func TestComparer(t *testing.T) { - m := listmodels.NewModel(strconv.Itoa) - defer m.Unref() - m.Update(slices.Values([]int{3, 1, 2})) - sorted := m.NewSortListModel(cmp.Compare[int]) - defer sorted.Unref() - - var got []int - for _, obj := range listmodels.Objects(sorted) { - got = append(got, m.Value(obj)) - } - if want := []int{1, 2, 3}; !slices.Equal(got, want) { - t.Errorf("sorted = %v, want %v", got, want) - } -} - -// TestSorterFreesCallbacks checks that sort models free their callback -// slots when destroyed, so pages can be opened and closed forever. -func TestSorterFreesCallbacks(t *testing.T) { - m := listmodels.NewModel(strconv.Itoa) - defer m.Unref() - m.Update(slices.Values([]int{3, 1, 2})) - - before := gutil.FreeCallbackSlots() - for range 500 { - sorted := m.NewSortListModel(cmp.Compare[int]) - sorted.GetNItems() - sorted.Unref() - } - if after := gutil.FreeCallbackSlots(); after != before { - t.Errorf("free callback slots: %d before, %d after 500 sort models", before, after) - } -} - func TestUpdateStrings(t *testing.T) { tests := []struct { name string @@ -198,31 +61,3 @@ func TestUpdateStrings(t *testing.T) { }) } } - -// BenchmarkUpdate measures the update the UI performs on every status change, -// with the model already holding every value. -func BenchmarkUpdate(b *testing.B) { - for _, n := range []int{100, 1000, 5478} { - b.Run(strconv.Itoa(n), func(b *testing.B) { - m := listmodels.NewModel(netip.Prefix.String) - defer m.Unref() - prefixes := makePrefixes(n) - m.Update(slices.Values(prefixes)) - - b.ResetTimer() - for range b.N { - m.Update(slices.Values(prefixes)) - } - }) - } -} - -// makePrefixes returns n distinct /32 prefixes in 10.0.0.0/8. -func makePrefixes(n int) []netip.Prefix { - prefixes := make([]netip.Prefix, 0, n) - for i := range n { - addr := netip.AddrFrom4([4]byte{10, byte(i >> 16), byte(i >> 8), byte(i)}) - prefixes = append(prefixes, netip.PrefixFrom(addr, 32)) - } - return prefixes -} diff --git a/internal/listmodels/model.go b/internal/listmodels/model.go deleted file mode 100644 index 05acd56..0000000 --- a/internal/listmodels/model.go +++ /dev/null @@ -1,145 +0,0 @@ -package listmodels - -import ( - "iter" - "slices" - - "codeberg.org/puregotk/puregotk/v4/gio" - "codeberg.org/puregotk/puregotk/v4/gobject" - "codeberg.org/puregotk/puregotk/v4/gtk" - "deedles.dev/trayscale/internal/gutil" -) - -// Model is a list model of Go values. GTK only sees a GtkStringList of -// keys; the values stay in a Go map. No Go value is ever stored inside a -// GObject, so the model needs no GObject subclass and no per-item -// pinning. -// -// Keys must be unique per value. Values with equal keys are treated as -// the same entry. -// -// The Model owns its GtkStringList; call Unref when done with it. -type Model[T comparable] struct { - list *gtk.StringList - key func(T) string - values map[string]T -} - -// NewModel returns an empty Model that identifies values by key. -func NewModel[T comparable](key func(T) string) *Model[T] { - return &Model[T]{ - list: gtk.NewStringList(nil), - key: key, - values: make(map[string]T), - } -} - -// Unref drops the Model's reference to its list. -func (m *Model[T]) Unref() { m.list.Unref() } - -// ListModel returns the underlying GTK list model, borrowed from m. -func (m *Model[T]) ListModel() gio.ListModel { return m.list } - -// Len returns the number of entries. -func (m *Model[T]) Len() int { return int(m.list.GetNItems()) } - -// Value returns the value for an item of m's list model, or of a model -// wrapping it such as a GtkSortListModel. -func (m *Model[T]) Value(obj gobject.Ptr) T { - return m.values[gutil.MustCast[gtk.StringObject](obj, gtk.StringObjectGLibType()).GetString()] -} - -// All yields the values in model order. -func (m *Model[T]) All() iter.Seq[T] { - return func(yield func(T) bool) { - for _, k := range StringsForward(m.list) { - if !yield(m.values[k]) { - return - } - } - } -} - -// Append adds v at the end, unless an entry with its key exists. -func (m *Model[T]) Append(v T) { - k := m.key(v) - if _, ok := m.values[k]; ok { - return - } - m.values[k] = v - m.list.Append(k) -} - -// Update removes the entries s does not yield, then appends the ones m -// lacks. Existing entries keep their order and new ones follow s's -// order. s is read once, so a single-pass sequence is fine. -func (m *Model[T]) Update(s iter.Seq[T]) { - m.list.FreezeNotify() - defer m.list.ThawNotify() - - want := slices.Collect(s) - byKey := make(map[string]T, len(want)) - for _, v := range want { - byKey[m.key(v)] = v - } - - // Backwards, so a removal never shifts an entry still to be visited. - for i, k := range StringsBackward(m.list) { - v, ok := byKey[k] - if !ok { - m.list.Remove(i) - delete(m.values, k) - continue - } - if m.values[k] == v { - continue - } - m.values[k] = v - m.list.Splice(i, 1, []string{k}) - } - - for _, v := range want { - m.Append(v) - } -} - -// NewSorter returns a GtkCustomSorter over m's items, owned by the -// caller. Its callbacks are freed when the sorter is destroyed. -func (m *Model[T]) NewSorter(f func(T, T) int) *gtk.CustomSorter { - return gutil.NewCustomSorter(func(a, b uintptr) int { - return f(m.valueAt(a), m.valueAt(b)) - }) -} - -func (m *Model[T]) valueAt(p uintptr) T { - return m.values[gtk.StringObjectNewFromInternalPtr(p).GetString()] -} - -// NewSortListModel returns a sorted view of m, owned by the caller, -// using f to compare values. -func (m *Model[T]) NewSortListModel(f func(T, T) int) *gtk.SortListModel { - // gtk_sort_list_model_new takes ownership of both the model and - // the sorter, so pass it a new sorter and an extra model ref. - sorter := m.NewSorter(f) - m.list.Ref() - return gtk.NewSortListModel(m.list, &sorter.Sorter) -} - -// BindListBox binds lb to list, which must be m's list model or a model -// wrapping it, creating each row with f. f returns a new widget that -// the list box takes over. The callbacks are freed when lb is unbound -// or destroyed. -func (m *Model[T]) BindListBox(lb *gtk.ListBox, list gio.ListModel, f func(T) *gtk.Widget) { - create := func(item uintptr) uintptr { - w := f(m.valueAt(item)) - // puregotk's constructors sink the floating reference. Hand - // ours back as floating, which the list box then sinks. - w.ForceFloating() - return w.GoPointer() - } - lb.BindModel(list, &listBoxCreate, gutil.Hold(create), &gutil.FreeData) -} - -var listBoxCreate gtk.ListBoxCreateWidgetFunc = func(item, id uintptr) uintptr { - return gutil.Held[func(uintptr) uintptr](id)(item) -}