Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ 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).

## [Unreleased]

### Changed

- **refactor(widget): decouple widget/ from gg/scene** (ADR-036 Phase 1) — replaced `*scene.Scene` with `SceneCache` interface in widget package. Third-party widget authors importing `widget/` + `geometry/` + `event/` now compile **71 packages instead of 124** (zero gg/wgpu/naga in the dependency chain). `SceneFactory` registration pattern follows existing `SceneRecorder` DI.
- New: `widget.SceneCache` interface (`Reset()`, `IsEmpty()`), `widget.SceneFactory`, `widget.RegisterSceneFactory()`, `widget.NewSceneCache()`
- Changed: `Canvas.ReplayScene(*scene.Scene)` → `Canvas.ReplayScene(SceneCache)`
- Changed: `WidgetBase.CachedScene()` / `SetCachedScene()` types → `SceneCache`
- Changed: `SceneRecorder` function signature uses `SceneCache`
- 92 files changed, rendering layer uses type assertions where concrete `*scene.Scene` needed
- **deps:** gg v0.50.11 → v0.50.12, gogpu v0.48.5 → v0.50.0, wgpu v0.30.35 → v0.30.36
- **gogpu v0.50.0:** outgoing drag-and-drop, per-pixel-alpha transparency, window Show/Hide/SetPosition/SetSize API, macOS menu Role+Action fix.
- **wgpu v0.30.36:** Vulkan present semaphore fix.

## [0.1.50] — 2026-08-03

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions app/boundary_visibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ func TestIsBoundaryVisible_AboveClip_Invisible(t *testing.T) {
func setupSceneRecorder(t *testing.T) func() {
t.Helper()
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
return func() { widget.RegisterSceneRecorder(prev) }
Expand Down
6 changes: 3 additions & 3 deletions app/compositor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ func (w *containerTestWidget) Draw(_ widget.Context, canvas widget.Canvas) {
func (w *containerTestWidget) Event(_ widget.Context, _ event.Event) bool { return false }
func (w *containerTestWidget) Children() []widget.Widget { return w.children }

// testSceneRecorder creates a SceneCanvas for recording into scene.Scene.
func testSceneRecorder(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
// testSceneRecorder creates a SceneCanvas for recording into SceneCache.
func testSceneRecorder(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
}
24 changes: 12 additions & 12 deletions app/drawchild_skip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ func (w *itemWidget) Children() []widget.Widget { return nil }
func TestDrawChildSkip_ListViewItemBoundaries(t *testing.T) {
// Register SceneRecorder factory (required for boundary recording).
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -154,7 +154,7 @@ func TestDrawChildSkip_ListViewItemBoundaries(t *testing.T) {
t.Logf("item[%d]: bounds=%v (%.0fx%.0f)", i, bounds, bounds.Width(), bounds.Height())

// 5c: Item has cached scene (recorded by PaintBoundaryLayers recursion).
sc, ok := item.(interface{ CachedScene() *scene.Scene })
sc, ok := item.(interface{ CachedScene() widget.SceneCache })
if !ok {
t.Errorf("item[%d]: does not implement CachedScene()", i)
continue
Expand Down Expand Up @@ -187,8 +187,8 @@ func TestDrawChildSkip_ListViewItemBoundaries(t *testing.T) {
// Items should NOT appear in the root's scene -- they have their own scenes.
func TestDrawChildSkip_RootRecordingSkipsItems(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -239,7 +239,7 @@ func TestDrawChildSkip_RootRecordingSkipsItems(t *testing.T) {
// After PaintBoundaryLayers, item boundaries should also have scenes.
items := collectBoundaryDescendants(root)
for i, item := range items {
if sc, ok := item.(interface{ CachedScene() *scene.Scene }); ok {
if sc, ok := item.(interface{ CachedScene() widget.SceneCache }); ok {
cs := sc.CachedScene()
if cs == nil {
t.Errorf("item[%d]: CachedScene nil after PaintBoundaryLayers", i)
Expand All @@ -255,8 +255,8 @@ func TestDrawChildSkip_RootRecordingSkipsItems(t *testing.T) {
// though they were created dynamically during VirtualContent.Draw().
func TestDrawChildSkip_ItemsExistAfterRootRecording(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -324,8 +324,8 @@ func TestDrawChildSkip_ItemsExistAfterRootRecording(t *testing.T) {
// both the Box background and the Text content.
func TestDrawChildSkip_BoxTextItems_ProductionScenario(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -379,7 +379,7 @@ func TestDrawChildSkip_BoxTextItems_ProductionScenario(t *testing.T) {
}

for i, item := range items {
sc, ok := item.(interface{ CachedScene() *scene.Scene })
sc, ok := item.(interface{ CachedScene() widget.SceneCache })
if !ok {
t.Errorf("item[%d]: does not implement CachedScene()", i)
continue
Expand Down Expand Up @@ -505,7 +505,7 @@ func walkTreeDiag(t *testing.T, w widget.Widget, depth int) {
if sd, ok := w.(interface{ IsSceneDirty() bool }); ok {
sceneDirty = sd.IsSceneDirty()
}
if sc, ok := w.(interface{ CachedScene() *scene.Scene }); ok {
if sc, ok := w.(interface{ CachedScene() widget.SceneCache }); ok {
hasScene = sc.CachedScene() != nil
}

Expand Down
2 changes: 1 addition & 1 deletion app/first_frame_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (c *trackingCanvas) PopTransform() {}
func (c *trackingCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
func (c *trackingCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *trackingCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *trackingCanvas) ReplayScene(_ *scene.Scene) {}
func (c *trackingCanvas) ReplayScene(_ widget.SceneCache) {}

// Compile-time check.
var _ widget.Canvas = (*trackingCanvas)(nil)
Expand Down
12 changes: 6 additions & 6 deletions app/hover_boundary_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import (
func TestHoverE2E_ButtonInBoundary_DirtyPropagation(t *testing.T) {
// Register SceneRecorder factory for boundary recording.
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -124,8 +124,8 @@ func TestHoverE2E_ButtonInBoundary_DirtyPropagation(t *testing.T) {
// multiple levels of nesting. Button inside Box inside Box inside root boundary.
func TestHoverE2E_DeepNesting_PropagatesUpward(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -205,8 +205,8 @@ func TestHoverE2E_DeepNesting_PropagatesUpward(t *testing.T) {
// Window.HandleEvent → updateHover → hitTest → MouseEnter → dirty propagation.
func TestHoverE2E_WindowHandleEvent_FullChain(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down
7 changes: 3 additions & 4 deletions app/layer_tree.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package app

import (
"github.com/gogpu/gg/scene"
"github.com/gogpu/gpucontext"
"github.com/gogpu/ui/compositor"
"github.com/gogpu/ui/geometry"
Expand Down Expand Up @@ -31,8 +30,8 @@ type boundaryInfo interface {
widget.Widget
IsRepaintBoundary() bool
IsSceneDirty() bool
CachedScene() *scene.Scene
SetCachedScene(*scene.Scene)
CachedScene() widget.SceneCache
SetCachedScene(widget.SceneCache)
ClearSceneDirty()
SceneCacheSize() (int, int)
SetSceneCacheSize(int, int)
Expand Down Expand Up @@ -519,7 +518,7 @@ func recordBoundary(bi boundaryInfo, ctx widget.Context) {

cachedScene := bi.CachedScene()
if cachedScene == nil {
cachedScene = scene.NewScene()
cachedScene = widget.NewSceneCache()
}
cachedScene.Reset()

Expand Down
28 changes: 14 additions & 14 deletions app/overlay_damage_tracking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ func TestOverlayBoundary_SceneDirtyAfterHover(t *testing.T) {

// Record initial scene to clear dirty state.
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -94,8 +94,8 @@ func TestOverlayBoundary_RecordClearsButVersionIncrements(t *testing.T) {
menu.SetScreenOrigin(geometry.Pt(100, 200))

prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -140,8 +140,8 @@ func TestOverlayBoundary_SyncPictureLayerDetectsVersionChange(t *testing.T) {
menu.SetScreenOrigin(geometry.Pt(100, 200))

prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -201,8 +201,8 @@ func TestOverlayBoundary_SyncPictureLayerDetectsVersionChange(t *testing.T) {
// should still trigger re-render.
func TestOverlayBoundary_FullPipeline_HoverGeneratesDamage(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -339,8 +339,8 @@ func TestOverlayBoundary_FullPipeline_HoverGeneratesDamage(t *testing.T) {
// version does not change, and the PictureLayer stays clean.
func TestOverlayBoundary_CleanHover_NoRender(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -396,8 +396,8 @@ func TestOverlayBoundary_StandalonePropagation(t *testing.T) {
menu.SetScreenOrigin(geometry.Pt(100, 200))

prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down Expand Up @@ -434,8 +434,8 @@ func TestOverlayBoundary_OnBoundaryDirtyCallback(t *testing.T) {
menu.SetScreenOrigin(geometry.Pt(100, 200))

prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down
10 changes: 8 additions & 2 deletions app/scene_recorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ import (
)

func init() {
// Register the SceneFactory so that widget.DrawTree can create new
// SceneCache instances for WidgetBase-based repaint boundaries (ADR-024).
widget.RegisterSceneFactory(func() widget.SceneCache {
return scene.NewScene()
})

// Register the SceneRecorder factory so that widget.DrawTree can create
// recording canvases for WidgetBase-based repaint boundaries (ADR-024).
//
// The widget package cannot import internal/render (circular dep), so
// we inject the factory here during package initialization.
widget.RegisterSceneRecorder(func(s *scene.Scene, width, height int) (widget.Canvas, func()) {
recorder := internalRender.NewSceneCanvas(s, width, height)
widget.RegisterSceneRecorder(func(s widget.SceneCache, width, height int) (widget.Canvas, func()) {
recorder := internalRender.NewSceneCanvas(s.(*scene.Scene), width, height)
return recorder, recorder.Close
})
}
4 changes: 2 additions & 2 deletions app/screen_origin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (w *screenOriginLeaf) Children() []widget.Widget { return

// testSceneRecorder is defined in compositor_test.go but redeclared here
// for this test file. Uses the same pattern.
func testSceneRecorderForOriginTests(s *scene.Scene, w, h int) (widget.Canvas, func()) { //nolint:unused // retained for future screen origin test variants
rec := internalRender.NewSceneCanvas(s, w, h)
func testSceneRecorderForOriginTests(s widget.SceneCache, w, h int) (widget.Canvas, func()) { //nolint:unused // retained for future screen origin test variants
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
}
4 changes: 2 additions & 2 deletions app/spinner_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ func (w *boxContainer) Children() []widget.Widget { return w.ki
// Verifies spinner is found by PaintBoundaryLayers AND visible in composed scene.
func TestSpinnerE2E_VisibleInCompositor(t *testing.T) {
prev := widget.GetSceneRecorderFactory()
widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s, w, h)
widget.RegisterSceneRecorder(func(s widget.SceneCache, w, h int) (widget.Canvas, func()) {
rec := internalRender.NewSceneCanvas(s.(*scene.Scene), w, h)
return rec, rec.Close
})
defer widget.RegisterSceneRecorder(prev)
Expand Down
3 changes: 1 addition & 2 deletions app/window_draw_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package app

import (
"github.com/gogpu/gg/scene"
"image"
"testing"

Expand Down Expand Up @@ -49,7 +48,7 @@ func (c *recordingCanvas) PopTransform() {}
func (c *recordingCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
func (c *recordingCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *recordingCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *recordingCanvas) ReplayScene(_ *scene.Scene) {}
func (c *recordingCanvas) ReplayScene(_ widget.SceneCache) {}

// drawTrackingWidget tracks whether Draw was called and has configurable bounds.
type drawTrackingWidget struct {
Expand Down
3 changes: 1 addition & 2 deletions app/window_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package app

import (
"github.com/gogpu/gg/scene"
"image"
"testing"

Expand Down Expand Up @@ -458,7 +457,7 @@ func (m *mockCanvas) PopTransform() {}
func (m *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
func (m *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (m *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (m *mockCanvas) ReplayScene(_ *scene.Scene) {}
func (m *mockCanvas) ReplayScene(_ widget.SceneCache) {}

// --- Retained-mode rendering tests ---

Expand Down
6 changes: 4 additions & 2 deletions compositor/compositor.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ func (c *Compositor) composeLayer(layer Layer, parentX, parentY float32) {
// PictureLayer: append its scene at accumulated offset.
if po, ok := layer.(PictureOwner); ok {
pic := po.Picture()
if pic != nil && !pic.IsEmpty() {
c.composed.AppendWithTranslation(pic, x, y)
if pic != nil {
if sc, ok2 := pic.(*scene.Scene); ok2 && !sc.IsEmpty() {
c.composed.AppendWithTranslation(sc, x, y)
}
}
layer.ClearNeedsCompositing()
return
Expand Down
Loading
Loading