diff --git a/CHANGELOG.md b/CHANGELOG.md index f75482c..d17d250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.24] — 2026-05-14 + +### Fixed + +- **Collapsible ghost pixels during animation** ([#101](https://github.com/gogpu/ui/issues/101) Thread B, @AnyCPU) — during collapse animation the clip area shrinks each frame, but the boundary's GPU texture retained stale pixels from the previous (larger) clip. `progressAdapter.Set()` now calls `InvalidateScene()` to force boundary re-recording. 1 line + 44 LOC test. +- **stampCompositorClip degenerate rects** ([#101](https://github.com/gogpu/ui/issues/101) Thread B') — zero-area or negative-area clip intersections (e.g., widget fully scrolled out of viewport) produced positioned-but-empty rects that defeated downstream `IsEmpty()` culling, allowing stale texture blits. Now normalizes to explicit zero rect. 6 lines + 99 LOC test. +- **Gallery theme dropdown resets on theme change** ([#101](https://github.com/gogpu/ui/issues/101) Thread G) — `buildGallery()` hardcoded `dropdown.Selected(0)`, losing user's theme selection when tree was rebuilt. Now tracks `galleryState.themeIdx`. 3 lines. + +### Dependencies + +- gg v0.46.9 → v0.46.11 (SVG HiDPI scale fix, GPU stroke EvenOdd fill rule, nil texture readback guard — [#101](https://github.com/gogpu/ui/issues/101) Threads C, F) +- wgpu v0.27.3 → v0.27.5 (flaky Windows CI fix, NULL handle guard in TransitionTextures — [#101](https://github.com/gogpu/ui/issues/101)) +- gogpu v0.34.3 → v0.34.6 (macOS PUA filter, Linux EventClose, deferred SetHitTestCallback frameless drag fix — [#101](https://github.com/gogpu/ui/issues/101) Threads A, H) +- goffi v0.5.0 → v0.5.1 (amd64 struct arg passing, XMM0:XMM1 return, CGO_ENABLED=1 — [#101](https://github.com/gogpu/ui/issues/101) Thread A) +- golang.org/x/image v0.39.0 → v0.40.0 +- golang.org/x/text v0.36.0 → v0.37.0 + ## [0.1.23] — 2026-05-13 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index fcc25b1..ae2b738 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -438,13 +438,13 @@ Full Vello 9-stage compute pipeline for GPU-accelerated path rendering: | Dependency | Version | Purpose | Status | |------------|---------|---------|--------| -| gogpu/gg | v0.46.9 | 2D rendering + scene.Scene | ✅ Integrated | +| gogpu/gg | v0.46.11 | 2D rendering + scene.Scene | ✅ Integrated | | gogpu/gpucontext | v0.18.0 | Shared interfaces | ✅ Integrated | -| gogpu/gogpu | v0.34.3 | Windowing (examples) | ✅ Integrated | +| gogpu/gogpu | v0.34.6 | Windowing (examples) | ✅ Integrated | | coregx/signals | v0.1.0 | State management | ✅ Integrated | | golang.org/x/image | v0.39.0 | Inter font (standard) | ✅ Integrated | -**Indirect:** go-text/typesetting v0.3.4, gogpu/gputypes v0.5.0, gogpu/wgpu v0.27.3, gogpu/naga v0.17.13, golang.org/x/text v0.36.0 +**Indirect:** go-text/typesetting v0.3.4, gogpu/gputypes v0.5.0, gogpu/wgpu v0.27.5, gogpu/naga v0.17.14, goffi v0.5.1, golang.org/x/text v0.37.0 --- diff --git a/core/collapsible/collapsible.go b/core/collapsible/collapsible.go index cc634f3..12a56e4 100644 --- a/core/collapsible/collapsible.go +++ b/core/collapsible/collapsible.go @@ -385,9 +385,13 @@ func (a *progressAdapter) Get() float32 { } // Set updates the progress and marks the widget for redraw. +// During collapse animation, the clip area shrinks each frame. +// InvalidateScene ensures the enclosing boundary's GPU texture is +// re-recorded so stale pixels outside the new clip are cleared. func (a *progressAdapter) Set(v float32) { a.w.progress = v a.w.SetNeedsRedraw(true) + a.w.InvalidateScene() } // Verify Widget implements required interfaces at compile time. diff --git a/core/collapsible/collapsible_test.go b/core/collapsible/collapsible_test.go index 7ad21c4..c25ef9a 100644 --- a/core/collapsible/collapsible_test.go +++ b/core/collapsible/collapsible_test.go @@ -711,6 +711,50 @@ func TestExpandedReadonlySignal_Mount_CreatesBinding(t *testing.T) { } } +// --- Animation Scene Invalidation Tests --- + +// TestAnimation_ProgressAdapter_InvalidatesScene verifies that the progress +// adapter calls InvalidateScene during animation, ensuring the enclosing +// boundary's GPU texture is re-recorded. Without this, stale pixels from +// the previous (expanded) frame persist outside the shrinking clip area. +func TestAnimation_ProgressAdapter_InvalidatesScene(t *testing.T) { + content := &mockWidget{preferredSize: geometry.Sz(200, 100)} + w := collapsible.New( + collapsible.Content(content), + collapsible.Expanded(true), + collapsible.Animated(true), + collapsible.Duration(100*time.Millisecond), + ) + // Make the collapsible a RepaintBoundary so we can observe scene invalidation. + w.SetRepaintBoundary(true) + + // Clear the initial dirty state (from SetRepaintBoundary). + w.ClearSceneDirty() + if w.IsSceneDirty() { + t.Fatal("scene should be clean after ClearSceneDirty") + } + + // Start collapse animation. + w.Toggle() + + // After toggle, SetNeedsRedraw(true) is called which calls + // InvalidateScene for boundaries. Clear it to isolate the + // progressAdapter.Set path. + w.ClearSceneDirty() + + // Simulate one animation tick via Layout. + ctx := widget.NewContext() + constraints := geometry.Loose(geometry.Sz(200, 500)) + ctx.BeginFrame(ctx.Now().Add(16 * time.Millisecond)) + w.Layout(ctx, constraints) + + // progressAdapter.Set should have called InvalidateScene. + if !w.IsSceneDirty() { + t.Error("animation progress change should invalidate the boundary scene; " + + "without this, GPU texture retains stale expanded-size pixels") + } +} + // --- TitleFn Tests --- func TestTitleFn(t *testing.T) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a74c4ff..8d0434e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1441,13 +1441,13 @@ The `registry/` package provides a global registry for widget factories: | Dependency | Purpose | Version | |------------|---------|---------| -| `github.com/gogpu/gg` | 2D graphics + scene.Scene tile-parallel rendering | v0.46.9 | +| `github.com/gogpu/gg` | 2D graphics + scene.Scene tile-parallel rendering | v0.46.11 | | `github.com/gogpu/gpucontext` | Window/Platform provider interfaces | v0.18.0 | -| `github.com/gogpu/gogpu` | Application framework, windowing (examples only) | v0.34.3 | +| `github.com/gogpu/gogpu` | Application framework, windowing (examples only) | v0.34.6 | | `github.com/coregx/signals` | Reactive state management | v0.1.0 | | `golang.org/x/image` | Font rendering infrastructure | v0.39.0 | -**Indirect:** gogpu/wgpu v0.27.3, gogpu/naga v0.17.13, gogpu/gputypes v0.5.0, go-text/typesetting v0.3.4, golang.org/x/text v0.36.0 +**Indirect:** gogpu/wgpu v0.27.5, gogpu/naga v0.17.13, gogpu/gputypes v0.5.0, goffi v0.5.1, go-text/typesetting v0.3.4, golang.org/x/text v0.37.0 Go version: **1.25.0** diff --git a/examples/gallery/main.go b/examples/gallery/main.go index 21a44bd..4dc9f9e 100644 --- a/examples/gallery/main.go +++ b/examples/gallery/main.go @@ -146,6 +146,7 @@ type galleryState struct { chart *linechart.Widget progressBar *progressbar.Widget circularPrg *progress.Widget + themeIdx int // currently selected theme index } // theme option names for the design-system dropdown. @@ -173,6 +174,7 @@ func main() { ps := m3Painters(m3) var onThemeChange func(int) onThemeChange = func(idx int) { + gs.themeIdx = idx ps = switchTheme(idx) uiApp.SetTheme(switchUITheme(idx)) newRoot := buildGallery(gs, ps, onThemeChange) @@ -267,7 +269,7 @@ func buildGallery(gs *galleryState, ps painterSet, onThemeChange func(int)) *scr // Header. themeDropdownOpts := []dropdown.Option{ dropdown.Items(themeNames...), - dropdown.Selected(0), + dropdown.Selected(gs.themeIdx), dropdown.PainterOpt(ps.dropdown), } if onThemeChange != nil { diff --git a/go.mod b/go.mod index 42c4c20..3208339 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,19 @@ go 1.25.0 require ( github.com/coregx/signals v0.1.0 - github.com/gogpu/gg v0.46.9 - github.com/gogpu/gogpu v0.34.3 + github.com/gogpu/gg v0.46.11 + github.com/gogpu/gogpu v0.34.6 github.com/gogpu/gpucontext v0.18.0 - golang.org/x/image v0.39.0 + golang.org/x/image v0.40.0 ) require ( github.com/go-text/typesetting v0.3.4 // indirect - github.com/go-webgpu/goffi v0.5.0 // indirect + github.com/go-webgpu/goffi v0.5.1 // indirect github.com/go-webgpu/webgpu v0.4.3 // indirect github.com/gogpu/gputypes v0.5.0 // indirect github.com/gogpu/naga v0.17.13 // indirect - github.com/gogpu/wgpu v0.27.3 // indirect + github.com/gogpu/wgpu v0.27.5 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 2acd39d..03650fc 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZz github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/go-webgpu/goffi v0.5.0 h1:EuvVRiRn9qAfCkYYXbHs9gz8NY+zv2/OA1N7gi56UVE= github.com/go-webgpu/goffi v0.5.0/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= +github.com/go-webgpu/goffi v0.5.1 h1:RSPR+YKT0tmbp5Uon+xwhN1veC9cehmqMptMkQuopok= +github.com/go-webgpu/goffi v0.5.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/go-webgpu/webgpu v0.4.3 h1:dIBf7WgO/7VL2Cj7IFcq151rWqvSknsFe6k/+ZEEXEE= github.com/go-webgpu/webgpu v0.4.3/go.mod h1:HNIBiaMJNdPeQd6hmHdQsXg4t4R99xVQybnoDGOShe0= github.com/gogpu/gg v0.46.6 h1:a55ERoNN714dMSwDCF9+Qw7Ul/+LsYID+6tWt8B1Wtc= @@ -16,10 +18,16 @@ github.com/gogpu/gg v0.46.8 h1:Dp4WBvS5kJhTXMjyazdyYKFKOHXqUFkL50wlatpMQ/Y= github.com/gogpu/gg v0.46.8/go.mod h1:NsQZ0v/wR4yjc8+ykccc/xf9Kh8XoC3OJZeFcXyoHWg= github.com/gogpu/gg v0.46.9 h1:n4cKAiVeCrUeA6CqhcbnyyfeqqvMgSe4qQjJeV74xTk= github.com/gogpu/gg v0.46.9/go.mod h1:NsQZ0v/wR4yjc8+ykccc/xf9Kh8XoC3OJZeFcXyoHWg= +github.com/gogpu/gg v0.46.10 h1:HItrwoUbcxvuJwipZdurFpTqX5gCerbTM6/6C+Ktp+8= +github.com/gogpu/gg v0.46.10/go.mod h1:RISlse0SdncXmVpWjJQ+8T693Y1JoGdkuUh+NsCQUYs= +github.com/gogpu/gg v0.46.11 h1:VcNQTYLLNMKhLtCS0URxEzezUar9S8i35wkPt9ThreY= +github.com/gogpu/gg v0.46.11/go.mod h1:GhTdx4C5FC7l2aEKvSryO0GVh5EYs5KHEQrEXulkLB4= github.com/gogpu/gogpu v0.34.0 h1:lDLBfpONFAn932+OOyr1AuGLgQmrTP4faYIEa1N4xXw= github.com/gogpu/gogpu v0.34.0/go.mod h1:W9QXv4+ZM+VNPU0qkCFtcgzmrtVXjkvEojYNJ30/66A= github.com/gogpu/gogpu v0.34.3 h1:tfnttpKedniwc0lqHgHE5660iuJe5us5BNcXRqm08+A= github.com/gogpu/gogpu v0.34.3/go.mod h1:M03kOiwdf/ZUc+WYb5+FIPO5p1loCmfPY+qMJDlNTFw= +github.com/gogpu/gogpu v0.34.6 h1:mKuD8x1OqxjlQl1S8scPodHMpGqBAlnWbLRDTB7b+Bc= +github.com/gogpu/gogpu v0.34.6/go.mod h1:knsNvdH0AiC/aqQVxOjVOwSH5ZzQqXMs4az3tTand80= github.com/gogpu/gpucontext v0.18.0 h1:Y48ScE0cNPevoqZEhT8CxWGh9C86TeCjtLu5eFU+Grw= github.com/gogpu/gpucontext v0.18.0/go.mod h1:6zwdmYXH5GQltoiHbb3WXVS/UJ5bFsCux0mXCVqGlzY= github.com/gogpu/gputypes v0.5.0 h1:i2ED/9w6m6yLxf8XJT69/NIMSNTLO2y5F1LqvugCKIE= @@ -30,9 +38,17 @@ github.com/gogpu/wgpu v0.27.2 h1:RFViuDLp3dndli6LynaeSUnZWfMdWsgo4Pn3BM/OUAI= github.com/gogpu/wgpu v0.27.2/go.mod h1:LordcEpJM76P0Ispw3r+3F2fAhd8khbBL7PgUa2iW/A= github.com/gogpu/wgpu v0.27.3 h1:VRR17ManIotIYkAN/sKBX1cyGa/jw6utGMXhEckINt4= github.com/gogpu/wgpu v0.27.3/go.mod h1:LordcEpJM76P0Ispw3r+3F2fAhd8khbBL7PgUa2iW/A= +github.com/gogpu/wgpu v0.27.4 h1:9dlucfHFFNStK6usR0UxmMO0vaAgQ17VWqdMCLNG0vc= +github.com/gogpu/wgpu v0.27.4/go.mod h1:icn/JDIIYMxk68DpU7t1f9xV+seRyFI2j3YBMY6qSho= +github.com/gogpu/wgpu v0.27.5 h1:WifeGAYuxbjHZ8NUgeFv+6XKSzR1g9CjCxM4pvMvOFc= +github.com/gogpu/wgpu v0.27.5/go.mod h1:icn/JDIIYMxk68DpU7t1f9xV+seRyFI2j3YBMY6qSho= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/widget/compositor_clip_test.go b/widget/compositor_clip_test.go index e0e09bc..abb8e64 100644 --- a/widget/compositor_clip_test.go +++ b/widget/compositor_clip_test.go @@ -200,3 +200,102 @@ func (c *clipStampCanvas) IsBoundaryRecording() bool { return c.isBoundary } var _ widget.Canvas = (*clipStampCanvas)(nil) var _ widget.BoundaryRecorder = (*clipStampCanvas)(nil) + +// TestDrawChild_DegenerateClipNormalizesToZeroRect verifies that when the +// canvas clip intersection produces a zero-area rect (widget scrolled fully +// out of viewport), stampCompositorClip normalizes it to an explicit zero +// rect rather than passing through a degenerate positioned rect. +func TestDrawChild_DegenerateClipNormalizesToZeroRect(t *testing.T) { + tests := []struct { + name string + clipBounds geometry.Rect + base geometry.Point + }{ + { + name: "zero width", + clipBounds: geometry.Rect{Min: geometry.Pt(100, 200), Max: geometry.Pt(100, 500)}, + base: geometry.Pt(0, 0), + }, + { + name: "zero height", + clipBounds: geometry.Rect{Min: geometry.Pt(0, 300), Max: geometry.Pt(400, 300)}, + base: geometry.Pt(0, 0), + }, + { + name: "negative width", + clipBounds: geometry.Rect{Min: geometry.Pt(200, 100), Max: geometry.Pt(100, 400)}, + base: geometry.Pt(0, 0), + }, + { + name: "negative height", + clipBounds: geometry.Rect{Min: geometry.Pt(0, 500), Max: geometry.Pt(400, 200)}, + base: geometry.Pt(0, 0), + }, + { + name: "zero area with non-zero base", + clipBounds: geometry.Rect{Min: geometry.Pt(50, 100), Max: geometry.Pt(50, 100)}, + base: geometry.Pt(10, 20), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + child := &clipTestWidget{} + child.SetVisible(true) + child.SetRepaintBoundary(true) + child.SetBounds(geometry.NewRect(0, 0, 200, 48)) + + canvas := &clipStampCanvas{ + clipBounds: tt.clipBounds, + transformOffset: geometry.Pt(0, 0), + screenOriginBase: tt.base, + isBoundary: true, + } + + widget.DrawChild(child, nil, canvas) + + if !child.HasCompositorClip() { + t.Fatal("DrawChild should still set CompositorClip even for degenerate clips") + } + + got := child.CompositorClip() + want := geometry.Rect{} // explicit zero rect + if got != want { + t.Errorf("CompositorClip = %v, want zero rect %v", got, want) + } + }) + } +} + +// TestDrawChild_ValidClipPassesThrough verifies that a normal positive-area +// clip rect is passed through unchanged (not zeroed). +func TestDrawChild_ValidClipPassesThrough(t *testing.T) { + child := &clipTestWidget{} + child.SetVisible(true) + child.SetRepaintBoundary(true) + child.SetBounds(geometry.NewRect(0, 0, 200, 48)) + + viewportClip := geometry.NewRect(10, 20, 400, 600) + base := geometry.Pt(5, 10) + canvas := &clipStampCanvas{ + clipBounds: viewportClip, + transformOffset: geometry.Pt(0, 0), + screenOriginBase: base, + isBoundary: true, + } + + widget.DrawChild(child, nil, canvas) + + if !child.HasCompositorClip() { + t.Fatal("should have compositor clip") + } + + got := child.CompositorClip() + want := geometry.Rect{ + Min: viewportClip.Min.Add(base), + Max: viewportClip.Max.Add(base), + } + if got != want { + t.Errorf("CompositorClip = %v, want %v", got, want) + } +} diff --git a/widget/stamp.go b/widget/stamp.go index 8cc7b4e..696cd31 100644 --- a/widget/stamp.go +++ b/widget/stamp.go @@ -74,5 +74,15 @@ func stampCompositorClip(child Widget, canvas Canvas) { Min: localClip.Min.Add(base), Max: localClip.Max.Add(base), } + + // Guard against degenerate rects from zero-area clip intersections + // (e.g., widget fully scrolled out of viewport). A zero-area rect at + // a non-zero position can confuse downstream culling, so normalize to + // an explicit zero rect. + if screenClip.Width() <= 0 || screenClip.Height() <= 0 { + setter.SetCompositorClip(geometry.Rect{}) + return + } + setter.SetCompositorClip(screenClip) }