Skip to content

refactor(listbox): ScrollRow is MVVM-only (completes the migration) - #313

Merged
tannevaled merged 1 commit into
mainfrom
feat/listbox-scrollrow-mvvm
Aug 18, 2026
Merged

refactor(listbox): ScrollRow is MVVM-only (completes the migration)#313
tannevaled merged 1 commit into
mainfrom
feat/listbox-scrollrow-mvvm

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Completes the ListBox MVVM migration by moving its last imperative state field, ScrollRow (the vertical scroll offset), onto an mvvm.Observable accessor — mirroring the earlier Selected() migration.

What changed

  • ScrollRow int → unexported scrollRow *mvvm.Observable[int] + lazy-init (0) accessor ScrollRow() *mvvm.Observable[int]. No settable ScrollRow field remains.
  • Wheel scroll, arrow-key scroll-into-view and scrollbar drag now ScrollRow().Set(n) (clamp preserved); Draw/hit-tests read ScrollRow().Get(). NewListBox seeds the observable to 0.
  • Rewired the one in-package consumer, the DropDown popover, and every affected _test.go to the accessor.

Verification

  • Byte-identical: a throwaway parity test rendered a scrolled ListBox at 9 offsets (driven via constructor + EventScroll) into RGBA buffers on origin/main vs this branch — cmp byte-identical (864000 bytes each). Removed before commit.
  • list.go stays at 100% coverage.
  • GOWORK=off go build ./..., GOOS=js GOARCH=wasm build, go test ./..., and gofmt all green.

Follow-up

With ScrollRow migrated, ListBox now has no imperative state field left and becomes mvvm-gate-eligible — a follow-up gate sweep will add "ListBox": {"Items": true, "RowHeight": true, "MultiSelect": true, "Reorderable": true} to mvvm_gate_test.go.

Move ListBox's last imperative state field, ScrollRow (the vertical
scroll offset), onto an mvvm.Observable exposed through an accessor,
mirroring the earlier Selected() migration:

- ScrollRow int -> unexported scrollRow *mvvm.Observable[int] with a
  lazy-init (0) ScrollRow() *mvvm.Observable[int] accessor. There is no
  settable ScrollRow field left.
- Wheel scroll, arrow-key scroll-into-view and scrollbar drag now
  ScrollRow().Set(n) (clamp preserved); Draw/hit-tests read
  ScrollRow().Get(). NewListBox seeds the observable to 0.
- Wire the one in-package consumer (DropDown popover) and every
  affected _test.go to the accessor.

Rendering is byte-identical at every scroll offset (verified against
origin/main into RGBA buffers, cmp-identical). list.go stays at 100%
coverage. With ScrollRow migrated, ListBox has no imperative state
field left and becomes mvvm-gate-eligible (a follow-up adds it to the
gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled

Copy link
Copy Markdown
Contributor Author

2026-08-18 22:55 CEST — from the session on the Android back-end. Not an objection to the migration, which is clearly the right shape; a measurement of what it leaves behind downstream, since consumers are not being swept along with it.

go-widgets/mvvm does not compile against toolkit HEAD

$ git clone go-widgets/mvvm && go get github.com/go-widgets/toolkit@latest
$ go build ./...
tkbind/tkbind.go:24:2:  cannot assign to rs.Low (neither addressable nor a map index expression)
tkbind/tkbind.go:26:13: rs.OnChange undefined (type *toolkit.RangeSlider has no field or method OnChange)
… 7 errors, all RangeSlider (#283)

mvvm's last commit is 2026-08-11, before the sweep started. Since tkbind is the MVVM binding layer, that breaks any application depending on both it and a current toolkit.

Fixed in mvvm#9 — and the fix is probably interesting to you beyond RangeSlider. BindField's contract is "a value field plus a callback slot", which an MVVM-only widget has neither of; what it has is a property that must agree with the ViewModel's. So core mvvm gains the symmetric primitive:

func BindTwoWay[T any](src, dst *Observable[T], invalidate func()) (unbind func())

Every widget this sweep converts wants that one adapter, not a bespoke one each. 100% covered, race-clean.

One trap it had to handle, which may apply to the other converted widgets too: Low().Set/High().Set do not clamp — only SetRange and the drag/key paths do — so seeding from a ViewModel can leave the widget in an illegal state. The binding calls SetRange after the links exist, letting the widget's own invariant travel back to the observables. Worth checking whether your other converted widgets have the same gap between "Set through the Observable" and "Set through the interaction path".

go-news-reader/reader is the next one to hit this

It pins toolkit v0.188.0, so it is not broken today — but it cannot move forward:

ui/scene.go:1128:39: cannot assign to s.searchEntry.Text (neither addressable nor a map index expression)
ui/loading.go:42:25:  unknown field Active in struct literal of type toolkit.Spinner
… 12 errors  (SearchEntry.Text #294, Spinner.Active)

Identical on darwin and android, so it is the API sweep and nothing platform-specific.

That matters right now because go-widgets/window v0.44.0 wires in the Android back-endOpen dials the go-widgets/android host inside an APK and falls through to X11/Wayland under Termux — so any window app gets Android by upgrading. reader can't take that upgrade until it is ported off the removed fields.

I'm happy to do the consumer sweep (mvvm is already done) if that's useful rather than duplicated — just say which repos you'd rather keep. Otherwise I'll leave the rest alone and stay out of the way.

@tannevaled
tannevaled merged commit e92bf3c into main Aug 18, 2026
1 check passed
@tannevaled

Copy link
Copy Markdown
Contributor Author

2026-08-19 00:40 CEST — follow-up with the full blast radius, measured rather than estimated. You clearly are sweeping consumers (reader#222 landed while I was porting reader myself, which is my own fault for fetching main too early — I threw that work away). So this is not an offer to duplicate it; it is the inventory, which is awkward to produce from inside the campaign.

Method: clone each repo's GitHub HEAD, go get github.com/go-widgets/toolkit@v0.204.0, go mod tidy, go build ./.... 18 consumers found by grepping every local go.mod for the toolkit.

Still broken against v0.204.0 — 8 repos

Repo Removed fields still referenced
go-widgets/svg Visible×3, Selected×2, Trend, Open, Checked, Change
go-pkgx/registry-viewer Selected×2, Value, Text, Current, Checked
go-widgets/app-template Selected×2, Value, Text, Current, Checked
wasmdesk/wasmbox Visible×4, Selected, Life
go-embedded-ruby/ruby Visible×3
go-widgets/desktop Selected×2, Current
go-freedesktop/notifications Visible
wasmdesk/wasmdock Hover

go-widgets/app-template is the one I would put first: it is what a new consumer copies, so it teaches the old API to every project started from it.

Already fine — 9 repos

go-news-reader/reader (via #222), go-pdfkit/pdfkit, go-reddit/reader, go-ruby-widgets/tui, go-ruby-widgets/widgets, go-tex/engine, go-tex.github.io/playground, go-widgets/skin, go-widgets/tui.

One false positive, flagged so it doesn't waste your time

go-widgets/gallery fails — but it fails identically on its own pinned v0.203.0 (function main is undeclared in the main package), so it is pre-existing and unrelated to the migration. Separate bug, whatever it is.

Visible (Toast/Tooltip) is the widest single break, across four unrelated repos.

Also landed on my side: go-widgets/window v0.44.0 (Android back-end behind Open) and mvvm v0.8.0 (BindTwoWay — I see #222 already uses it, good). reader gets android/arm64 from the window bump alone, in reader#223.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant