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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **Layout cache activation** (ADR-032 Phase 2b, [#160](https://github.com/gogpu/ui/pull/160), @TimLai666) — convert 32 parent→child Layout calls to `widget.LayoutChild` across 23 files, activating per-widget layout caching. Layout cost: O(total nodes) → O(affected subtree). Removes `MarkLayoutCleanRecursive` shim. Adds `InvalidateLayoutTree` for downward cache propagation on signal fires. `IsLayoutVerifying` sentinel for debug verifier (Flutter `debugCheckingIntrinsics` pattern).
- **Animation tick before layout** (ADR-032 GAP-3) — `AnimationTicker` interface + `tickAnimationsInTree` walk before layout pass. Collapsible and Transition animations now tick BEFORE layout (Flutter `handleBeginFrame` → `handleDrawFrame` pattern). Layout is a pure function of constraints + widget state, required for RelayoutBoundary (Phase 5).

### Fixed

Expand Down
23 changes: 23 additions & 0 deletions app/window.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,13 @@ func (w *Window) Frame() {
}
}

// ADR-032 GAP-3: Tick layout-affecting animations BEFORE layout.
// Flutter pattern: handleBeginFrame (Animate) → handleDrawFrame (Layout).
// Layout must be a pure function of (constraints + widget state) for
// RelayoutBoundary correctness (Phase 5). Animation ticks mutate state
// (e.g., Collapsible.progress) so they must complete before layout reads it.
tickAnimationsInTree(w.root, w.ctx)

// Update scale factor (may change between frames on multi-monitor setups).
w.updateScale()

Expand Down Expand Up @@ -771,6 +778,22 @@ func (w *Window) syncContextFocusToManager() {
}
}

// tickAnimationsInTree walks the widget tree and calls TickAnimation on
// widgets that implement [widget.AnimationTicker]. Called BEFORE layout
// so that animation state is final when Layout reads it (Flutter pattern:
// handleBeginFrame → Animate → handleDrawFrame → Layout).
func tickAnimationsInTree(w widget.Widget, ctx widget.Context) {
if w == nil {
return
}
if ticker, ok := w.(widget.AnimationTicker); ok {
ticker.TickAnimation(ctx)
}
for _, child := range w.Children() {
tickAnimationsInTree(child, ctx)
}
}

// layout performs the layout pass on the widget tree and overlays.
func (w *Window) layout() {
if w.root == nil {
Expand Down
16 changes: 6 additions & 10 deletions core/collapsible/collapsible.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,6 @@ func (w *Widget) IsAnimating() bool {
// - Expanded: headerHeight + content height
// - Animating: headerHeight + content height * progress
func (w *Widget) Layout(ctx widget.Context, constraints geometry.Constraints) geometry.Size {
// Tick animation if active.
w.tickAnimation(ctx)

headerH := w.cfg.headerHeight

// Layout content to determine its natural height.
Expand Down Expand Up @@ -359,8 +356,10 @@ func (w *Widget) startAnimation(expanding bool) {
Start(w.animCtrl)
}

// tickAnimation advances animation by delta time from context.
func (w *Widget) tickAnimation(ctx widget.Context) {
// TickAnimation advances the expand/collapse animation by delta time.
// Called by the framework BEFORE layout (ADR-032 GAP-3, Flutter pattern).
// Layout reads w.progress without mutation.
func (w *Widget) TickAnimation(ctx widget.Context) {
if w.animCtrl == nil || !w.animCtrl.HasActive() {
return
}
Expand All @@ -375,12 +374,9 @@ func (w *Widget) tickAnimation(ctx widget.Context) {
wasActive := w.animCtrl.HasActive()
w.animCtrl.Tick(dt)

if w.animCtrl.HasActive() {
w.SetNeedsRedraw(true)
ctx.Invalidate()
} else if wasActive {
if w.animCtrl.HasActive() || wasActive {
w.MarkNeedsLayout()
w.SetNeedsRedraw(true)
ctx.Invalidate()
}
}

Expand Down
12 changes: 12 additions & 0 deletions core/collapsible/collapsible_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ func TestLayout_Collapsed(t *testing.T) {
ctx := widget.NewContext()
constraints := geometry.Loose(geometry.Sz(300, 500))

w.TickAnimation(ctx)
size := w.Layout(ctx, constraints)

if size.Height != 40 {
Expand All @@ -382,6 +383,7 @@ func TestLayout_Expanded(t *testing.T) {
ctx := widget.NewContext()
constraints := geometry.Loose(geometry.Sz(300, 500))

w.TickAnimation(ctx)
size := w.Layout(ctx, constraints)

if size.Height != 140 {
Expand All @@ -398,6 +400,7 @@ func TestLayout_NoContent(t *testing.T) {
ctx := widget.NewContext()
constraints := geometry.Loose(geometry.Sz(300, 500))

w.TickAnimation(ctx)
size := w.Layout(ctx, constraints)

if size.Height != 36 {
Expand All @@ -410,6 +413,7 @@ func TestLayout_UsesMaxWidth(t *testing.T) {
ctx := widget.NewContext()
constraints := geometry.Loose(geometry.Sz(400, 500))

w.TickAnimation(ctx)
size := w.Layout(ctx, constraints)

if size.Width != 400 {
Expand Down Expand Up @@ -537,6 +541,7 @@ func TestAnimation_Expand(t *testing.T) {

// Advance 16ms ticks until animation completes (simulates ~60fps).
ctx.BeginFrame(ctx.Now().Add(16 * time.Millisecond))
w.TickAnimation(ctx)
w.Layout(ctx, constraints)

if w.Progress() <= 0.0 || w.Progress() >= 1.0 {
Expand All @@ -546,6 +551,7 @@ func TestAnimation_Expand(t *testing.T) {
// Run remaining ticks until animation is done.
for i := 0; i < 20 && w.IsAnimating(); i++ {
ctx.BeginFrame(ctx.Now().Add(16 * time.Millisecond))
w.TickAnimation(ctx)
w.Layout(ctx, constraints)
}

Expand Down Expand Up @@ -573,6 +579,7 @@ func TestAnimation_Collapse(t *testing.T) {
// Run ticks until animation completes.
for i := 0; i < 20 && w.IsAnimating(); i++ {
ctx.BeginFrame(ctx.Now().Add(16 * time.Millisecond))
w.TickAnimation(ctx)
w.Layout(ctx, constraints)
}

Expand Down Expand Up @@ -753,6 +760,7 @@ func TestAnimation_ProgressAdapter_InvalidatesScene(t *testing.T) {
ctx := widget.NewContext()
constraints := geometry.Loose(geometry.Sz(200, 500))
ctx.BeginFrame(ctx.Now().Add(16 * time.Millisecond))
w.TickAnimation(ctx)
w.Layout(ctx, constraints)

// progressAdapter.Set should have called InvalidateScene.
Expand Down Expand Up @@ -940,6 +948,7 @@ func TestAnimation_ProgressesWithoutMouseEvents(t *testing.T) {
for range 10 {
now = now.Add(16 * time.Millisecond)
ctx.BeginFrame(now)
w.TickAnimation(ctx)
w.Layout(ctx, constraints)
progresses = append(progresses, w.Progress())
}
Expand Down Expand Up @@ -979,6 +988,7 @@ func TestAnimation_DeltaTimeClamping_MinimumOneMilli(t *testing.T) {
now := ctx.Now()
ctx.BeginFrame(now)
ctx.BeginFrame(now) // dt=0
w.TickAnimation(ctx)
w.Layout(ctx, constraints)

if w.Progress() <= initialProgress {
Expand Down Expand Up @@ -1007,6 +1017,7 @@ func TestAnimation_DeltaTimeClamping_MaximumThirtyTwoMilli(t *testing.T) {
ctx.BeginFrame(now)
bigDelta := now.Add(100 * time.Millisecond)
ctx.BeginFrame(bigDelta)
w.TickAnimation(ctx)
w.Layout(ctx, constraints)

// With 200ms duration and 32ms max tick, progress should be at most ~16%.
Expand Down Expand Up @@ -1239,6 +1250,7 @@ func TestTitleSignal_ResolvesTitle(t *testing.T) {
// Signal > Fn > Static
ctx := widget.NewContext()
constraints := geometry.Tight(geometry.Sz(400, 40))
w.TickAnimation(ctx)
w.Layout(ctx, constraints)
w.SetBounds(geometry.NewRect(0, 0, 400, 40))

Expand Down
2 changes: 1 addition & 1 deletion core/collapsible/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ func TestTickAnimation_NilCtrl(t *testing.T) {
w.animCtrl = nil
ctx := widget.NewContext()
// Should not panic.
w.tickAnimation(ctx)
w.TickAnimation(ctx)
}

// --- Draw Content With Non-Settable Bounds Widget ---
Expand Down
8 changes: 6 additions & 2 deletions docs/RENDER-PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,17 @@ Each frame executes these steps in order:
### Step 1: Frame Setup

```
Frame() // flush signals, layout, animations
Frame() // signals, animation ticks, layout (see below)
BeginAcceleratorFrame() // reset GPU frame state
BeginGPUFrame() // prepare gg render context
ResetFrameDamage() // clear damage tracking
```

`Frame()` runs the signal scheduler (up to 2 re-flushes for cascading changes), layout pass if needed, and animation tick.
`Frame()` runs in Flutter-correct order (ADR-032 GAP-3):
1. **BeginFrame** — set DeltaTime
2. **Signal flush** — process pending signal changes (up to 2 re-flushes)
3. **Animation tick** — `tickAnimationsInTree()` walks the tree, calls `AnimationTicker.TickAnimation()` on widgets with layout-affecting animations (Collapsible, Transition). All animation values are final BEFORE layout runs.
4. **Layout** — pure function of (constraints + widget state). No mutation.

### Step 2: Root Invalidation

Expand Down
2 changes: 0 additions & 2 deletions transition/fade.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,7 @@ func (f *Fade) updateAnimation(ctx widget.Context) {
f.progress = 1.0
f.animating = false
} else {
// Request another frame while animating.
f.SetNeedsRedraw(true)
ctx.InvalidateRect(f.Bounds())
}

// Compute opacity from eased progress.
Expand Down
2 changes: 0 additions & 2 deletions transition/slide.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,7 @@ func (s *Slide) updateAnimation(ctx widget.Context) {
s.progress = 1.0
s.animating = false
} else {
// Request another frame while animating.
s.SetNeedsRedraw(true)
ctx.InvalidateRect(s.Bounds())
}
}

Expand Down
25 changes: 14 additions & 11 deletions transition/transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,8 @@ func (t *Transition) Draw(ctx widget.Context, canvas widget.Canvas) {
return
}

// Update animation progress.
if t.animating {
t.updateAnimation(ctx)
}
// Animation progress is updated by TickAnimation (called before layout).
// Draw only reads the current progress — no mutation here.

// Determine the active effect and eased progress.
eff, easedProgress := t.currentEffect()
Expand All @@ -219,11 +217,16 @@ func (t *Transition) Draw(ctx widget.Context, canvas widget.Canvas) {
t.drawWithEffects(ctx, canvas, eff, easedProgress)
}

// updateAnimation advances the animation based on elapsed time.
func (t *Transition) updateAnimation(ctx widget.Context) {
// TickAnimation advances the transition animation based on elapsed time.
// Called by the framework BEFORE layout (ADR-032 GAP-3, Flutter pattern).
// Draw reads t.progress without mutation.
func (t *Transition) TickAnimation(ctx widget.Context) {
if !t.animating {
return
}

now := ctx.Now()

// Initialize start time on first tick.
if t.startTime.IsZero() {
t.startTime = now
}
Expand All @@ -239,13 +242,13 @@ func (t *Transition) updateAnimation(ctx widget.Context) {
t.progress = 1.0
t.animating = false
if !t.entering {
t.shown = false // exit complete: hide
t.shown = false
}
t.MarkNeedsLayout()
t.SetNeedsRedraw(true)
} else {
// Request another frame while animating.
// ADR-028: layout-dependent — animation tick may change widget size.
t.MarkNeedsLayout()
t.SetNeedsRedraw(true)
ctx.Invalidate()
}
}

Expand Down
Loading
Loading