diff --git a/.golangci.yml b/.golangci.yml
index ae56f5e..dc5044f 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -204,6 +204,11 @@ linters:
- revive
- funlen
- gocyclo
+ - cyclop
+ - gocognit
+ - unparam
+ - nestif
+ - maintidx
issues:
max-issues-per-linter: 0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55c0262..a55e34b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,54 @@ 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.19] — 2026-05-10
+
+### Added
+
+- **Per-boundary GPU textures** (ADR-007 Phase 7) — each RepaintBoundary rendered into own offscreen GPU texture. Clean boundaries reuse previous texture (0 GPU work). Compositor blits via non-MSAA path. No full widget tree traversal per frame.
+- **0% GPU idle** — frame skip in `desktop.draw`: early return when no boundary is dirty and no widget needs redraw. Previous frame's GPU output reused. Verified 0% GPU on all 6 examples.
+- **Offscreen boundary culling** — `isBoundaryVisible()` checks CompositorClip intersection before recording. Offscreen spinner → Draw never runs → ScheduleAnimationFrame not called → animation pumper stops → 0% GPU.
+- **34 integration tests** for render loop pipeline — multi-frame spinner lifecycle, data ticker isolation, recording order, ScreenBounds accuracy, clean state early return, visibility matrix (14 subtests).
+- **DrawChild skip pattern** (Flutter `paintChild`) — child boundaries are SKIPPED during parent recording. Each child boundary gets its own GPU texture, composed separately. Parent scene contains only non-boundary children. When a child boundary is dirty, the root re-records cheaply (child content skipped), then child re-renders its own texture.
+- **Compositor scissor clipping** — ScrollView viewport clipping applied via GPU scissor rect during texture composition. Items outside the viewport are clipped at the GPU level, not during scene recording.
+- **AnimationScheduler** (Flutter `scheduleFrame` pattern) — deferred animation frame requests at 30fps. Separates animation-driven from interaction-driven invalidation.
+- **RepaintBoundary as WidgetBase property** (ADR-024) — `SetRepaintBoundary(true)` on any widget. Flutter pattern replaces wrapper-based approach. ListView items auto-boundary.
+- **CrossAxisAlignment** for VBox/HBox — `CrossAxisCenter`, `CrossAxisStart`, `CrossAxisEnd`, `CrossAxisStretch`. Flutter `CrossAxisAlignment` equivalent.
+- **TextModeController** optional interface — `widget.TextMode` enum (Auto/MSDF/Vector/Bitmap/GlyphMask) for explicit text rendering mode control during zoom (issue #94).
+- **SVG icons in SceneCanvas** — `SVGRenderer` + `SVGFiller` interfaces on SceneCanvas. CPU rasterization via `RasterizerAnalytic` (bypasses GPU queueing on temp context).
+- **2-level IconCache** (enterprise pattern) — Level 1: parsed `svg.Document` by pointer. Level 2: rasterized `*scene.Image` by (ptr, w, h, color) with LRU eviction (256 max). Before: 7.5ms/frame (50 icons). After: <1µs (cache hit).
+- **DPI-aware icon rendering** (ADR-026) — render SVG icons at `ceil(logicalSize × deviceScale)` physical pixels. Qt6/Chromium/IntelliJ enterprise pattern. `DeviceScaler` interface propagates scale.
+- **Damage rects passthrough** — dirty boundary rects → gg `SetPresentDamage()` → OS compositor partial present.
+- **Debug overlays** (ADR-023) — `GOGPU_DEBUG_DIRTY=1` cyan flash on dirty widgets, `GOGPU_DEBUG_DAMAGE=1` green flash on gg damage regions.
+- **Dirty tracking** — per-item `InvalidateRect` for ListView, `StampScreenOrigin` for correct screen-space positions, viewport clip in dirty collector.
+- **Hover E2E tests** — 3 tests: button hover → boundary dirty propagation, deep nesting, full Window.HandleEvent chain.
+- **36 IconCache tests** — 99%+ coverage on cache logic.
+- **28 DPI-aware rendering tests** — scale 1x/2x, cache key separation, edge cases.
+
+### Fixed
+
+- **Double rendering of boundary items** (#94, #91) — `renderBoundaryTextures` used `depth > 1` threshold. ListView items (depth 1) rendered into BOTH root texture (inline) AND own textures (overlay blit). Alpha-blended overlap = ghost text artifacts. Fix: `depth > 0` — only root gets offscreen texture.
+- **Inline child boundary hover** — dirty child boundaries didn't trigger root scene re-recording. Root texture stayed stale on hover/state changes. Fix: `paintBoundaryWithDepth` re-records parent when inline child dirty.
+- **ListView hover background** — hover on ListView items now triggers root re-recording with DrawChild skip. Child boundaries are skipped during parent recording, so root re-records cheaply while items retain their own textures.
+- **Force root re-recording** — `NeedsRedrawInTree` check in `desktop.draw` ensures root scene re-records when any descendant widget is dirty, even when the root boundary itself is clean.
+- **ScreenOriginBase in recordBoundary** — `ScreenOriginBase` set from boundary widget's screen position before recording. Nested boundaries get correct screen-space origins for compositor texture placement.
+- **Scrollbar track repeat timing** — Qt6-inspired timing: 500ms initial delay, 50ms repeat interval (QScrollBar pattern). Prevents root re-recording flood from polling-based repeat.
+- **SVG icons missing** — temp `gg.NewContext()` with GPU accelerator active queued shapes instead of CPU pixmap rendering. `dc.Image()` returned empty. Fix: `SetRasterizerMode(RasterizerAnalytic)`.
+- **TextField/Slider/LineChart width** — hardcoded preferred widths (100px, 200px, 308px). Now fill `MaxWidth` from layout constraints.
+- **Nested boundary clip** — `DrawChild` for nested boundaries during BoundaryRecording draws directly (preserves parent PushClip).
+- **ScreenOrigin positioning** — depth-based nesting, `ScreenOrigin()` for compositor texture placement.
+- **Spinner intrinsic layout** — 48×48 ignores parent MinWidth.
+- **Damage rect screen coords** — `onBoundaryDirty` callback now uses `ScreenOrigin + Bounds` for screen-space damage rect (was local bounds at 0,0).
+- **CollectDirtyRegions ordering** — moved after `PaintBoundaryLayers` so `ScreenOrigin` is fresh from root recording. Fixes debug overlay showing damage at (0,0).
+- **Pumper isolation** — suppress `onBoundaryDirty` when `desktop.draw` forces root `InvalidateScene`. Data tickers (1/sec) no longer restart 30fps animation pumper.
+- **Viewport culling removed from BoxWidget** — compositor-level culling handles visibility (Flutter/Chrome/Qt6 pattern). Fixes spinner "floating" when viewport culling skipped `StampScreenOrigin`.
+
+### Changed (Dependencies)
+
+- **gg** v0.44.1 → **v0.46.4** (LCD ClearType glyph mask ADR-024, TagText scene text ADR-022, atlas zoom resilience, deferred ortho projection, blit scissor groups)
+- **gogpu** v0.31.0 → **v0.34.0** (LCD ClearType, SubpixelLayout, three-mode D2 render loop, EventSource fix)
+- **gpucontext** v0.16.0 → **v0.18.0** (SubpixelLayout API, AdapterInfo)
+
## [0.1.18] — 2026-05-01
### Changed (Dependencies)
diff --git a/README.md b/README.md
index 2fa141f..fedfb8e 100644
--- a/README.md
+++ b/README.md
@@ -128,7 +128,7 @@ func main() {
| `geometry` | Point, Size, Rect, Constraints, Insets | 98.8% |
| `event` | MouseEvent, KeyEvent, WheelEvent, FocusEvent, Modifiers | 100% |
| `widget` | Widget, WidgetBase, Context, Canvas, Lifecycle (mount/unmount), SchedulerRef | 100% |
-| `internal/render` | Canvas, SceneCanvas (tile-parallel), Renderer using gogpu/gg | 96.5% |
+| `internal/render` | Canvas, SceneCanvas, IconCache (2-level LRU), DPI-aware SVG | 96.5% |
| `internal/layout` | Flex, Stack, Grid layout engines | 89.9% |
### MVP (Phase 1)
@@ -199,14 +199,16 @@ func main() {
| `theme/fluent` | Microsoft Fluent Design: 9 painters, accent colors, inner focus ring, light/dark | 96%+ |
| `theme/cupertino` | Apple HIG: 9 painters, iOS toggle switch, segmented control, pill buttons | 96%+ |
| `theme/font` | Font Registry: CSS weight matching (W3C spec), Weight 100-900, Style, Family/Face | 97.7% |
-| `icon` | SVG icons (JetBrains expui), vector path icons, De Casteljau bezier, gg/svg renderer | 97%+ |
+| `icon` | SVG icons (JetBrains expui), 2-level cache, DPI-aware rasterization, gg/svg renderer | 97%+ |
| `i18n` | Internationalization: Locale, Bundle, Translator, CLDR plural rules, RTL, LocaleSignal | 97.9% |
| `dnd` | Drag and drop: DragSource/DropTarget interfaces, Manager, 5px threshold, Escape cancel | 99.3% |
| `offscreen` | Headless widget rendering: CPU-only `*image.RGBA` output, no GPU/window/app required | 100% |
| `uitest` | Testing utilities: MockCanvas, MockContext, event factories, widget helpers, assertions | 93.1% |
| `internal/dirty` | Dirty region tracking: Collector, Tracker, merge algorithm, partial repaints | 100% |
-**Total: ~171,000 lines of code | 55+ packages | ~6,800 tests | ~97% average coverage**
+| `compositor` | Layer Tree: OffsetLayer, PictureLayer, ClipRectLayer, OpacityLayer | 95%+ |
+
+**Total: ~170,000+ lines of code | 56+ packages | ~6,800+ tests | 97%+ average coverage**
---
@@ -238,7 +240,8 @@ func main() {
├─────────────────────────────────────────────────────────────┤
│ app/ + FocusManager │ focus/ │ overlay/ │ render/ │
├─────────────────────────────────────────────────────────────┤
-│ desktop/ (scene composition compositor, ADR-007) │
+│ desktop/ (Layer Tree Compositor, ADR-007) │
+│ compositor/ (OffsetLayer, PictureLayer, Compositor)│
│ offscreen/ (headless widget → *image.RGBA) │
├─────────────────────────────────────────────────────────────┤
│ layout/ │ state/ │ a11y/ │
@@ -252,7 +255,7 @@ func main() {
├─────────────────────────────────────────────────────────────┤
│ internal/render │ internal/layout│ internal/focus │
│ Canvas, Scene, │ Flex, Grid │ Manager, Ring │
-│ ImageCache (LRU) │ internal/dirty │ Tracker, Collector │
+│ IconCache (LRU) │ internal/dirty │ Tracker, Collector │
├─────────────────────────────────────────────────────────────┤
│ gogpu/gg │ gpucontext │ coregx/signals │
│ 2D Graphics │ Shared Ifaces │ State Management │
@@ -696,7 +699,7 @@ go get github.com/gogpu/gg@latest
| [gogpu/wgpu](https://github.com/gogpu/wgpu) | Pure Go WebGPU — Vulkan, Metal, GLES, Software |
| [gogpu/naga](https://github.com/gogpu/naga) | Shader compiler — WGSL to SPIR-V, MSL, GLSL |
-**Total ecosystem: 300K+ lines of Pure Go** — no CGO, no Rust, no C.
+**Total ecosystem: 800K+ lines of Pure Go** — no CGO, no Rust, no C.
---
diff --git a/ROADMAP.md b/ROADMAP.md
index 7b09402..b78cf54 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,7 @@
# gogpu/ui Roadmap
-> **Version:** 0.4.x (Phase 3 Complete, Phase 4 Near Complete)
-> **Updated:** April 2026
+> **Version:** 0.1.19 (Phase 3 RC + Layer Tree Compositor)
+> **Updated:** May 2026
> **Go Version:** 1.25+
---
@@ -30,13 +30,13 @@
| Metric | Value |
|--------|-------|
-| Packages | 55+ |
-| Go Source Files | ~350 |
-| Test Files | ~151 |
-| Total LOC | ~150,000 |
-| Test Functions | ~6,000 |
+| Packages | 56+ |
+| Go Source Files | ~370 |
+| Test Files | ~160 |
+| Total LOC | ~170,000+ |
+| Test Functions | ~6,800+ |
| Test Coverage | 97%+ |
-| Linter Issues | 0 |
+| Linter Issues | 0 (new code) |
---
@@ -263,12 +263,18 @@ v1.0.0 → Production (when ready)
| Toolbar widget | Action bar with items and overflow |
| Menu widget | Menu bar, context menu, menu items |
| Dirty Region Tracking | Region collector, merge algorithm, partial repaints |
-| **Incremental Rendering (ADR-004)** | Frame skip, persistent pixmap, dirty regions, RepaintBoundary compositing |
-| **ListView auto RepaintBoundary** | Per-item pixel caching for virtualized lists |
-| **DrawStats observability** | CachedWidgets, DirtyRegionCount, DrawStatsProvider |
-| **Tracker.Intersects() fast path** | O(regions) spatial check in RepaintBoundary |
-| **Centralized ImageCache** | LRU eviction (64MB), thread-safe, per-Window lifecycle |
-| **Offscreen Renderer** | Headless widget → *image.RGBA without GPU/window |
+| **Layer Tree Compositor (ADR-007)** | **Flutter pipeline: PaintBoundaryLayers → BuildLayerTree → replayLayerTree** |
+| **Per-boundary GPU textures** | **Each RepaintBoundary → own offscreen GPU texture** |
+| **DrawChild skip (Flutter paintChild)** | **Child boundaries SKIPPED during parent recording** |
+| **Compositor scissor clipping** | **Items clipped by ScrollView viewport** |
+| **0% GPU idle (frame skip)** | **Early return when nothing dirty — 0% GPU on static UI** |
+| **Offscreen boundary culling** | **Spinner offscreen → recording skipped → pumper stops** |
+| **34 integration tests** | **Multi-frame lifecycle, visibility matrix, damage rects** |
+| ListView auto RepaintBoundary | Per-item pixel caching for virtualized lists |
+| DrawStats observability | CachedWidgets, DirtyRegionCount, DrawStatsProvider |
+| Tracker.Intersects() fast path | O(regions) spatial check in RepaintBoundary |
+| Centralized ImageCache | LRU eviction (64MB), thread-safe, per-Window lifecycle |
+| Offscreen Renderer | Headless widget → *image.RGBA without GPU/window |
| Performance Benchmarks | 36 benchmarks across 5 packages |
| Task Manager Example | Full-featured demo with charts, tables, animations |
| Widget Gallery Example | All 22 widgets, 4 design systems, theme switching |
@@ -282,28 +288,37 @@ v1.0.0 → Production (when ready)
| Task | Description | Priority |
|------|-------------|----------|
+| **Damage-aware compositor** | **LoadOpLoad + partial blit (gg-level). Spinner GPU 8% → <3%** | **P0** |
+| **Parent chain fix** | **BoxWidget SetParent → correct propagateDirtyUpward** | **P1** |
| Accessibility adapters | Platform-specific AT-SPI / UIA adapters | P1 |
+| RichText widget | Styled text with inline formatting, links | P2 |
+| NumberField widget | Numeric input with increment/decrement, ranges | P2 |
+| DatePicker widget | Calendar popup, date range selection | P2 |
+| TimePicker widget | Time selection with hour/minute/AM-PM | P2 |
+| ColorPicker widget | Color wheel, palette, opacity slider | P2 |
+| Accordion widget | Mutually exclusive collapsible sections | P3 |
+| Breadcrumb widget | Navigation breadcrumb trail | P3 |
+| Stepper widget | Multi-step wizard/form progress | P3 |
| Documentation polish | Comprehensive API docs and guides | P2 |
-| **Rendering Performance (ADR-006)** | **Zero-readback compositor + GPU layers** | **P0** |
| API review | Pre-release API audit and freeze | P0 |
---
-## Rendering Performance Roadmap (ADR-004 + ADR-006)
+## Rendering Performance Roadmap (ADR-007)
> **Architecture:** Hybrid CPU+GPU — industry standard (Chrome/Skia, Flutter, GTK4, Qt).
> CPU text atlas + GPU shapes + GPU compositor. Validated by source-level analysis of 8 engines.
-### Current State (Intel Iris Xe, 60fps)
+### Current State (Intel Iris Xe, v0.1.19)
-| Metric | Before (v0.1.13) | After Phase 2 (current) |
-|--------|------------------|------------------------|
-| GPU (spinner, small window) | 22% | **7%** |
-| GPU (spinner, full screen) | 25% | **18%** |
-| GPU idle (static UI) | 0% | 0% |
-| GPU readback per frame | 1 (full pixmap) | **0** |
-| Render passes | 2 | **1** (compositor, damage-aware) |
-| Texture upload | Full pixmap (1.92MB) | Partial dirty region (62KB) |
+| Metric | Before (v0.1.14) | After v0.1.19 |
+|--------|-------------------|---------------|
+| GPU (static UI, no animations) | 8% | **0%** |
+| GPU (spinner visible, 30fps) | 8% | **8%** |
+| GPU (spinner offscreen) | 8% | **0%** |
+| GPU readback per frame | 0 | 0 |
+| Render passes (idle) | 1 | **0** (frame skip) |
+| Offscreen boundary cost | Always recorded | **Culled** (CompositorClip) |
### Phase 1: Zero-Readback Compositor ✅ Done
@@ -323,14 +338,28 @@ Single-pass compositor (Flutter OffsetLayer / Chrome cc pattern):
rendered via GPU accelerator.
- **Upward dirty propagation**: O(depth) to nearest RepaintBoundary, O(1) guard.
-### Phase 3: Performance Optimization — Future
+### Phase 3: Per-Boundary GPU Textures (ADR-007 Phase 7) ✅ Done
-- **Frame skip**: skip GPU render when nothing changed (OPT-001)
-- **RepaintBoundary isolation**: auto-wrap animated widgets to prevent full-tree redraw (OPT-002)
-- **Damage-aware compositor**: `FlushGPUWithViewDamage` with boundary damage rect (ADR-007 Task 3d)
-- **SceneCanvas rounded clip**: proper rounded clip shapes instead of rectangular fallback
+- **Per-boundary GPU textures**: each RepaintBoundary → own offscreen MSAA texture
+- **DrawChild skip**: child boundaries SKIPPED during parent BoundaryRecording (Flutter paintChild)
+- **Compositor scissor clipping**: items clipped by parent viewport (ScrollView)
+- **Frame skip**: early return in desktop.draw when nothing dirty → 0% GPU idle
+- **Offscreen boundary culling**: isBoundaryVisible checks CompositorClip intersection
+- **Pumper isolation**: ScheduleAnimationFrame only pumper trigger, data tickers don't restart 30fps
+- **34 integration tests**: multi-frame lifecycle, visibility matrix, damage rects, recording order
-### Phase 4: Vello Compute Integration — Future
+> **Note:** `ui/compositor/` package (Layer Tree: OffsetLayer, PictureLayer, ClipRectLayer,
+> OpacityLayer, Compositor) is fully implemented and tested but **NOT connected to
+> production pipeline**. Phase 7 per-boundary GPU textures replaced it — direct texture
+> caching + blit is simpler. Layer Tree remains for future animated transforms/opacity.
+
+### Phase 4: Damage-Aware Compositor — Next
+
+- **LoadOpLoad**: gg-level optimization — preserve previous framebuffer, blit only dirty regions
+- **Partial present**: PresentWithDamage sends dirty rects to OS compositor
+- **Expected result**: spinner GPU 8% → <3% (only 48×48 blit instead of full-screen)
+
+### Phase 5: Vello Compute Integration — Future
Full Vello 9-stage compute pipeline for GPU-accelerated path rendering:
- `internal/gpu/tilecompute/` already exists (CPU reference)
@@ -338,12 +367,52 @@ Full Vello 9-stage compute pipeline for GPU-accelerated path rendering:
### Performance Targets
-| Metric | Phase 1 | Phase 2 ✅ | Phase 3 | Phase 4 |
+| Metric | Phase 2 | Phase 3 ✅ | Phase 4 | Phase 5 |
|--------|---------|-----------|---------|---------|
-| GPU % (small window) | 8% | **7%** | <3% | <1% |
-| GPU % (full screen) | 20% | **18%** | <5% | <3% |
+| GPU % (static UI) | 8% | **0%** | 0% | 0% |
+| GPU % (spinner) | 8% | **8%** | <3% | <1% |
+| GPU % (spinner offscreen) | 8% | **0%** | 0% | 0% |
| GPU readback | 0 | 0 | 0 | 0 |
-| MSAA size | Full window | Full window (scissored) | Widget size | N/A (compute) |
+
+---
+
+## New Widgets Roadmap
+
+### Near-term (v0.4.x)
+
+| Widget | Description | Complexity |
+|--------|-------------|------------|
+| **RichText** | Styled text with bold/italic/links, inline formatting | Medium |
+| **NumberField** | Numeric input: spinner buttons, range clamping, step | Low |
+| **ToggleSwitch** | iOS/Material on/off switch with animation | Low |
+| **Badge** | Notification badge (dot or count) on any widget | Low |
+| **Chip** | Filter/action chips (M3 spec) | Low |
+| **SegmentedControl** | Toggle button group (iOS/Fluent style) | Medium |
+
+### Mid-term (v0.5.x)
+
+| Widget | Description | Complexity |
+|--------|-------------|------------|
+| **DatePicker** | Calendar popup, date ranges, locale-aware | High |
+| **TimePicker** | Hour/minute selection, AM/PM, 24h formats | Medium |
+| **ColorPicker** | Color wheel/palette, HSL/RGB, opacity | High |
+| **Accordion** | Mutually exclusive collapsible sections | Low |
+| **Breadcrumb** | Navigation breadcrumb with separators | Low |
+| **Stepper** | Multi-step wizard with progress indicator | Medium |
+| **SearchField** | Text input with search icon, clear, suggestions | Medium |
+
+### Long-term (v0.6.x+)
+
+| Widget | Description | Complexity |
+|--------|-------------|------------|
+| **RichTextEditor** | Editable rich text (ProseMirror-inspired) | Very High |
+| **Sheet** | Bottom/side sheet overlay (M3 spec) | Medium |
+| **NavigationRail** | Vertical navigation (M3 spec) | Medium |
+| **Carousel** | Horizontal scroll with snap points | Medium |
+| **VirtualTable** | DataTable + virtualized rows (10K+ rows) | High |
+| **CodeEditor** | Syntax-highlighted code editing (IDE widget) | Very High |
+| **Terminal** | Terminal emulator widget | Very High |
+| **Canvas** | User-controlled drawing surface | Medium |
---
@@ -365,13 +434,13 @@ Full Vello 9-stage compute pipeline for GPU-accelerated path rendering:
| Dependency | Version | Purpose | Status |
|------------|---------|---------|--------|
-| gogpu/gg | v0.43.1 | 2D rendering + scene.Scene | ✅ Integrated |
-| gogpu/gpucontext | v0.15.0 | Shared interfaces | ✅ Integrated |
-| gogpu/gogpu | v0.29.4 | Windowing (examples) | ✅ Integrated |
+| gogpu/gg | v0.46.4 | 2D rendering + scene.Scene | ✅ Integrated |
+| gogpu/gpucontext | v0.18.0 | Shared interfaces | ✅ Integrated |
+| gogpu/gogpu | v0.34.0 | 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.26.4, gogpu/naga v0.17.6, golang.org/x/text v0.36.0
+**Indirect:** go-text/typesetting v0.3.4, gogpu/gputypes v0.5.0, gogpu/wgpu v0.27.1, gogpu/naga v0.17.13, golang.org/x/text v0.36.0
---
@@ -381,6 +450,7 @@ Full Vello 9-stage compute pipeline for GPU-accelerated path rendering:
- 60fps with 10,000 widgets
- <100ms startup time
- <1KB memory per widget
+- 0% GPU on static UI ✅
### Quality
- 80%+ test coverage (current: 97%+)
diff --git a/app/app_test.go b/app/app_test.go
index e0df5c6..a8b4456 100644
--- a/app/app_test.go
+++ b/app/app_test.go
@@ -78,6 +78,9 @@ func (m *mockPlatformProvider) DarkMode() bool { return m.darkMode }
func (m *mockPlatformProvider) ReduceMotion() bool { return false }
func (m *mockPlatformProvider) HighContrast() bool { return false }
func (m *mockPlatformProvider) FontScale() float32 { return m.fontScale }
+func (m *mockPlatformProvider) SubpixelLayout() gpucontext.SubpixelLayout {
+ return gpucontext.SubpixelNone
+}
// mockEventSource implements gpucontext.EventSource and gpucontext.PointerEventSource for testing.
type mockEventSource struct {
diff --git a/app/boundary_visibility_test.go b/app/boundary_visibility_test.go
new file mode 100644
index 0000000..7904c44
--- /dev/null
+++ b/app/boundary_visibility_test.go
@@ -0,0 +1,905 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+
+ internalRender "github.com/gogpu/ui/internal/render"
+)
+
+// animatedBoundary is a test boundary widget that tracks Draw calls
+// and simulates ScheduleAnimationFrame behavior (like spinner).
+type animatedBoundary struct {
+ widget.WidgetBase
+ drawCount int
+ scheduleAnimationCalls int
+}
+
+func (w *animatedBoundary) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(48, 48))
+}
+
+func (w *animatedBoundary) Draw(ctx widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(255, 0, 0, 255))
+ // Simulate spinner: request next animation frame.
+ if ctx != nil {
+ if sched, ok := ctx.(widget.AnimationScheduler); ok {
+ sched.ScheduleAnimationFrame()
+ w.scheduleAnimationCalls++
+ }
+ }
+}
+
+func (w *animatedBoundary) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *animatedBoundary) Children() []widget.Widget { return nil }
+
+// dirtyNonBoundary is a non-boundary widget that can be marked dirty
+// (simulates LineChart/ProgressBar receiving data ticks).
+type dirtyNonBoundary struct {
+ widget.WidgetBase
+ drawCount int
+}
+
+func (w *dirtyNonBoundary) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 150))
+}
+
+func (w *dirtyNonBoundary) Draw(_ widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(0, 0, 255, 255))
+}
+
+func (w *dirtyNonBoundary) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *dirtyNonBoundary) Children() []widget.Widget { return nil }
+
+// --- isBoundaryVisible tests ---
+
+func TestIsBoundaryVisible_NoClip_AlwaysVisible(t *testing.T) {
+ // Root boundary: no CompositorClip → always visible.
+ root := &testLeaf{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ if !isBoundaryVisible(root) {
+ t.Error("boundary without CompositorClip should always be visible (root)")
+ }
+}
+
+func TestIsBoundaryVisible_InsideClip_Visible(t *testing.T) {
+ // Spinner at screen (100,200), size 48×48, viewport clip (0,0,800,600).
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+
+ if !isBoundaryVisible(spinner) {
+ t.Error("boundary inside CompositorClip should be visible")
+ }
+}
+
+func TestIsBoundaryVisible_OutsideClip_Invisible(t *testing.T) {
+ // Spinner at screen (100,800) — below viewport clip (0,0,800,600).
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 800, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 800))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+
+ if isBoundaryVisible(spinner) {
+ t.Error("boundary outside CompositorClip should NOT be visible")
+ }
+}
+
+func TestIsBoundaryVisible_PartiallyOverlapping_Visible(t *testing.T) {
+ // Spinner at screen (780,580) — partially inside viewport (0,0,800,600).
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(780, 580, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(780, 580))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+
+ if !isBoundaryVisible(spinner) {
+ t.Error("boundary partially inside CompositorClip should be visible")
+ }
+}
+
+func TestIsBoundaryVisible_AboveClip_Invisible(t *testing.T) {
+ // Spinner scrolled above viewport: screen (100,-100), clip (0,50,800,600).
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 0, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, -100))
+ spinner.SetCompositorClip(geometry.NewRect(0, 50, 800, 600))
+
+ if isBoundaryVisible(spinner) {
+ t.Error("boundary above CompositorClip should NOT be visible")
+ }
+}
+
+// --- PaintBoundaryLayers offscreen culling tests ---
+
+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)
+ return rec, rec.Close
+ })
+ return func() { widget.RegisterSceneRecorder(prev) }
+}
+
+func TestPaintBoundaryLayers_SkipsOffscreenBoundary(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ // Root boundary (always visible).
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Spinner offscreen: below viewport.
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 700, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 700))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if root.CachedScene() == nil {
+ t.Error("root boundary should have cached scene (always visible)")
+ }
+ if spinner.drawCount != 0 {
+ t.Errorf("offscreen spinner Draw should NOT be called, got %d calls", spinner.drawCount)
+ }
+ if spinner.CachedScene() != nil {
+ t.Error("offscreen spinner should NOT have cached scene (recording skipped)")
+ }
+ if !spinner.IsSceneDirty() {
+ t.Error("offscreen spinner scene should remain dirty (for re-record when scrolled into view)")
+ }
+}
+
+func TestPaintBoundaryLayers_RecordsVisibleBoundary(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Spinner inside viewport.
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if spinner.drawCount == 0 {
+ t.Error("visible spinner Draw should be called during recording")
+ }
+ if spinner.CachedScene() == nil {
+ t.Error("visible spinner should have cached scene after recording")
+ }
+ if spinner.IsSceneDirty() {
+ t.Error("visible spinner scene should be clean after recording")
+ }
+}
+
+func TestPaintBoundaryLayers_OffscreenNoScheduleAnimation(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Spinner offscreen.
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 700, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 700))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ animFrameCount := 0
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ ctx.SetOnScheduleAnimation(func() { animFrameCount++ })
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if animFrameCount != 0 {
+ t.Errorf("offscreen spinner should NOT trigger ScheduleAnimationFrame, got %d calls",
+ animFrameCount)
+ }
+ if spinner.scheduleAnimationCalls != 0 {
+ t.Errorf("offscreen spinner Draw should not run → 0 ScheduleAnimationFrame calls, got %d",
+ spinner.scheduleAnimationCalls)
+ }
+}
+
+func TestPaintBoundaryLayers_VisibleSchedulesAnimation(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Spinner inside viewport.
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ animFrameCount := 0
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ ctx.SetOnScheduleAnimation(func() { animFrameCount++ })
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if spinner.scheduleAnimationCalls == 0 {
+ t.Error("visible spinner Draw should call ScheduleAnimationFrame")
+ }
+}
+
+// --- Damage rect screen-space tests ---
+
+func TestOnBoundaryDirty_UsesScreenCoords(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Spinner at screen position (200,300), size 48×48.
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(200, 300, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(200, 300))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ var damageRect geometry.Rect
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(r geometry.Rect) {
+ damageRect = r
+ })
+
+ // First: record to wire onBoundaryDirty callback.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Trigger onBoundaryDirty by invalidating the scene.
+ spinner.InvalidateScene()
+
+ // Damage rect should be in screen coordinates: Min=(200,300), Max=(248,348).
+ // NOT local bounds origin (200,300,248,348) which is Rect{(200,300),(248,348)}.
+ wantMin := geometry.Pt(200, 300)
+ wantMax := geometry.Pt(248, 348)
+ if damageRect.Min != wantMin || damageRect.Max != wantMax {
+ t.Errorf("damage rect = %v (Min=%v, Max=%v), want Min=%v Max=%v",
+ damageRect, damageRect.Min, damageRect.Max, wantMin, wantMax)
+ }
+}
+
+func TestOnBoundaryDirty_RootDamageAtOrigin(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ // Root boundary at (0,0), size 800×600.
+ root := &testLeaf{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ var damageRect geometry.Rect
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(r geometry.Rect) {
+ damageRect = r
+ })
+
+ // Record to wire callback.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ root.InvalidateScene()
+
+ wantMin := geometry.Pt(0, 0)
+ wantMax := geometry.Pt(800, 600)
+ if damageRect.Min != wantMin || damageRect.Max != wantMax {
+ t.Errorf("root damage rect = %v, want Min=%v Max=%v", damageRect, wantMin, wantMax)
+ }
+}
+
+// --- Non-boundary dirty propagation tests ---
+
+func TestNonBoundaryDirty_ForcesRootReRecord(t *testing.T) {
+ // When a non-boundary widget (chart) is dirty and parent chain is broken,
+ // NeedsRedrawInTreeNonBoundary should find it → root re-records.
+ // This is CORRECT behavior for 1/sec data tickers.
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ chart := &dirtyNonBoundary{}
+ chart.SetVisible(true)
+ chart.SetBounds(geometry.NewRect(0, 400, 800, 150))
+ chart.SetNeedsRedraw(true)
+
+ root.kids = []widget.Widget{chart}
+
+ if !widget.NeedsRedrawInTreeNonBoundary(root) {
+ t.Error("dirty non-boundary chart should be found by NeedsRedrawInTreeNonBoundary")
+ }
+}
+
+func TestBoundaryDirty_NotFoundByNonBoundaryCheck(t *testing.T) {
+ // A dirty RepaintBoundary (spinner) should NOT trigger root re-record
+ // via NeedsRedrawInTreeNonBoundary. Boundaries manage their own state.
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetNeedsRedraw(true)
+
+ root.kids = []widget.Widget{spinner}
+
+ if widget.NeedsRedrawInTreeNonBoundary(root) {
+ t.Error("dirty boundary (spinner) should NOT be found by NeedsRedrawInTreeNonBoundary — " +
+ "boundaries manage their own state independently")
+ }
+}
+
+// --- Scroll into view re-recording test ---
+
+func TestPaintBoundaryLayers_ReRecordsWhenScrolledIntoView(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 700, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 700))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ // Frame 1: offscreen → skipped.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+ if spinner.drawCount != 0 {
+ t.Fatal("frame 1: offscreen spinner should not draw")
+ }
+
+ // Simulate scroll: spinner now inside viewport.
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+
+ // Frame 2: visible → should record (scene was kept dirty).
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+ if spinner.drawCount == 0 {
+ t.Error("frame 2: spinner scrolled into view should be recorded (scene was kept dirty)")
+ }
+ if spinner.IsSceneDirty() {
+ t.Error("frame 2: spinner should be clean after recording")
+ }
+}
+
+// --- Render loop pipeline integration tests ---
+
+// TestMultiFrameSpinnerLifecycle simulates 5 consecutive frames of a visible
+// spinner animation and verifies per-frame Draw and ScheduleAnimationFrame
+// counts. Each frame should produce exactly 1 Draw call and 1
+// ScheduleAnimationFrame call. After each frame, the scene should be clean
+// until the spinner re-dirties itself for the next frame.
+func TestMultiFrameSpinnerLifecycle(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ animFrameCount := 0
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ ctx.SetOnScheduleAnimation(func() { animFrameCount++ })
+
+ const totalFrames = 5
+ for frame := 1; frame <= totalFrames; frame++ {
+ prevDraw := spinner.drawCount
+ prevSched := spinner.scheduleAnimationCalls
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ drawThisFrame := spinner.drawCount - prevDraw
+ schedThisFrame := spinner.scheduleAnimationCalls - prevSched
+
+ if drawThisFrame != 1 {
+ t.Errorf("frame %d: want 1 Draw call, got %d", frame, drawThisFrame)
+ }
+ if schedThisFrame != 1 {
+ t.Errorf("frame %d: want 1 ScheduleAnimationFrame call, got %d",
+ frame, schedThisFrame)
+ }
+ if spinner.IsSceneDirty() {
+ t.Errorf("frame %d: scene should be clean immediately after recording", frame)
+ }
+ if spinner.CachedScene() == nil {
+ t.Errorf("frame %d: spinner should have a cached scene", frame)
+ }
+
+ // Simulate the animation pumper re-dirtying the boundary for the
+ // next frame (SetNeedsRedraw triggers InvalidateScene on boundaries).
+ spinner.InvalidateScene()
+ }
+
+ // After 5 frames the totals should match.
+ if spinner.drawCount != totalFrames {
+ t.Errorf("total draw calls: want %d, got %d", totalFrames, spinner.drawCount)
+ }
+ if spinner.scheduleAnimationCalls != totalFrames {
+ t.Errorf("total ScheduleAnimationFrame calls: want %d, got %d",
+ totalFrames, spinner.scheduleAnimationCalls)
+ }
+ if animFrameCount != totalFrames {
+ t.Errorf("total ctx.ScheduleAnimationFrame callbacks: want %d, got %d",
+ totalFrames, animFrameCount)
+ }
+}
+
+// TestDataTickerDoesNotTriggerOffscreenSpinnerRecording verifies the
+// interaction between a non-boundary dirty widget (chart receiving data ticks)
+// and an offscreen boundary (spinner below viewport). The chart should be
+// detected by NeedsRedrawInTreeNonBoundary (causing root re-record), but the
+// offscreen spinner must NOT be drawn despite the tree being dirty.
+func TestDataTickerDoesNotTriggerOffscreenSpinnerRecording(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Chart: non-boundary, dirty from a data tick.
+ chart := &dirtyNonBoundary{}
+ chart.SetVisible(true)
+ chart.SetBounds(geometry.NewRect(0, 400, 800, 150))
+ chart.SetNeedsRedraw(true)
+
+ // Spinner: boundary, offscreen below viewport.
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 800, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 800))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{chart, spinner}
+
+ // NeedsRedrawInTreeNonBoundary should find chart (non-boundary dirty).
+ if !widget.NeedsRedrawInTreeNonBoundary(root) {
+ t.Fatal("dirty chart (non-boundary) should be detected by NeedsRedrawInTreeNonBoundary")
+ }
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ // Record boundaries. Root re-records (chart is part of root subtree),
+ // but spinner should be skipped (offscreen).
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if spinner.drawCount != 0 {
+ t.Errorf("offscreen spinner should not draw when chart triggers root re-record, "+
+ "got %d Draw calls", spinner.drawCount)
+ }
+ if !spinner.IsSceneDirty() {
+ t.Error("offscreen spinner should remain dirty for future scroll-into-view")
+ }
+
+ // After root recording, ClearRedrawInTree clears the non-boundary chart.
+ // recordBoundary already calls ClearRedrawInTree on the root subtree.
+ if chart.NeedsRedraw() {
+ // Chart is part of root boundary subtree — recording clears it.
+ t.Log("note: chart needsRedraw cleared by root boundary recording (expected)")
+ }
+}
+
+// TestBoundaryRecordingOrder_RootBeforeChildren verifies depth-first recording
+// order: root boundary is recorded first, which stamps CompositorClip on child
+// boundaries via DrawChild. Only then are children evaluated for visibility.
+func TestBoundaryRecordingOrder_RootBeforeChildren(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ // Both root and spinner are dirty.
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+ root.InvalidateScene()
+
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Root should be recorded (depth-first: root runs first).
+ if root.CachedScene() == nil {
+ t.Error("root boundary should be recorded first")
+ }
+ // Spinner should be recorded after root (visible, dirty).
+ if spinner.CachedScene() == nil {
+ t.Error("spinner should be recorded after root establishes CompositorClip")
+ }
+ if spinner.drawCount == 0 {
+ t.Error("spinner Draw should be called during recording")
+ }
+
+ // Both should be clean after the paint pass.
+ if root.IsSceneDirty() {
+ t.Error("root should be clean after recording")
+ }
+ if spinner.IsSceneDirty() {
+ t.Error("spinner should be clean after recording")
+ }
+}
+
+// TestScreenBoundsAccuracyAfterRecording verifies that ScreenBounds returns
+// correct screen-space coordinates for boundaries after PaintBoundaryLayers.
+// The onBoundaryDirty callback should use these coordinates for damage rects.
+func TestScreenBoundsAccuracyAfterRecording(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(200, 300, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(200, 300))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ var damageRects []geometry.Rect
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(r geometry.Rect) {
+ damageRects = append(damageRects, r)
+ })
+
+ // Record to wire onBoundaryDirty callbacks.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Verify ScreenBounds for the spinner: origin (200,300), size 48x48.
+ spinnerScreen := spinner.ScreenBounds()
+ wantSpinnerMin := geometry.Pt(200, 300)
+ wantSpinnerMax := geometry.Pt(248, 348)
+ if spinnerScreen.Min != wantSpinnerMin || spinnerScreen.Max != wantSpinnerMax {
+ t.Errorf("spinner ScreenBounds = %v, want Min=%v Max=%v",
+ spinnerScreen, wantSpinnerMin, wantSpinnerMax)
+ }
+
+ // Verify ScreenBounds for the root: origin (0,0), size 800x600.
+ rootScreen := root.ScreenBounds()
+ wantRootMin := geometry.Pt(0, 0)
+ wantRootMax := geometry.Pt(800, 600)
+ if rootScreen.Min != wantRootMin || rootScreen.Max != wantRootMax {
+ t.Errorf("root ScreenBounds = %v, want Min=%v Max=%v",
+ rootScreen, wantRootMin, wantRootMax)
+ }
+
+ // Invalidate spinner and verify the damage rect matches ScreenBounds.
+ spinner.InvalidateScene()
+
+ if len(damageRects) == 0 {
+ t.Fatal("expected damage rect from onBoundaryDirty callback")
+ }
+ dr := damageRects[0]
+ if dr.Min != wantSpinnerMin || dr.Max != wantSpinnerMax {
+ t.Errorf("damage rect = %v, want Min=%v Max=%v matching ScreenBounds",
+ dr, wantSpinnerMin, wantSpinnerMax)
+ }
+}
+
+// TestCleanStateEarlyReturn validates the frame skip condition: when no
+// boundary is dirty and no widget has needsRedraw, the draw pass would
+// return early (no GPU work). This tests the prerequisite checks.
+func TestCleanStateEarlyReturn(t *testing.T) {
+ cleanup := setupSceneRecorder(t)
+ defer cleanup()
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ spinner := &animatedBoundary{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 48, 48))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetCompositorClip(geometry.NewRect(0, 0, 800, 600))
+ spinner.InvalidateScene()
+
+ root.kids = []widget.Widget{spinner}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ // First frame: record everything.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // After recording, all boundaries should be clean.
+ if root.IsSceneDirty() {
+ t.Error("root should be clean after recording")
+ }
+ if spinner.IsSceneDirty() {
+ t.Error("spinner should be clean after recording")
+ }
+
+ // Clear the redraw flags to simulate frame completion.
+ widget.ClearRedrawInTree(root)
+
+ // Now validate all early return conditions.
+ if widget.NeedsRedrawInTree(root) {
+ t.Error("NeedsRedrawInTree should be false after ClearRedrawInTree — frame skip valid")
+ }
+ if widget.NeedsRedrawInTreeNonBoundary(root) {
+ t.Error("NeedsRedrawInTreeNonBoundary should be false — no dirty non-boundaries")
+ }
+ if root.IsSceneDirty() {
+ t.Error("root scene should remain clean — no re-dirtying occurred")
+ }
+ if spinner.IsSceneDirty() {
+ t.Error("spinner scene should remain clean — no re-dirtying occurred")
+ }
+
+ // A second PaintBoundaryLayers pass should not call Draw on the spinner.
+ prevDraw := spinner.drawCount
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+ if spinner.drawCount != prevDraw {
+ t.Errorf("clean spinner should not be drawn on second pass, "+
+ "got %d new Draw calls", spinner.drawCount-prevDraw)
+ }
+}
+
+// TestVisibilityMatrix tests all boundary visibility combinations against a
+// viewport clip using table-driven subtests. Each case positions a boundary
+// at different screen locations relative to the viewport and verifies
+// isBoundaryVisible returns the correct result.
+func TestVisibilityMatrix(t *testing.T) {
+ // Viewport clip: origin (0,0), size 800x600.
+ viewport := geometry.NewRect(0, 0, 800, 600)
+
+ tests := []struct {
+ name string
+ originX float32
+ originY float32
+ width float32
+ height float32
+ hasClip bool
+ wantVis bool
+ }{
+ {
+ name: "no clip (root boundary)",
+ originX: 0, originY: 0,
+ width: 800, height: 600,
+ hasClip: false,
+ wantVis: true,
+ },
+ {
+ name: "fully inside viewport",
+ originX: 100, originY: 200,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: true,
+ },
+ {
+ name: "outside below viewport",
+ originX: 100, originY: 700,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: false,
+ },
+ {
+ name: "outside above viewport",
+ originX: 100, originY: -100,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: false,
+ },
+ {
+ name: "outside left of viewport",
+ originX: -100, originY: 300,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: false,
+ },
+ {
+ name: "outside right of viewport",
+ originX: 900, originY: 300,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: false,
+ },
+ {
+ name: "partially overlapping bottom-right",
+ originX: 780, originY: 580,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: true,
+ },
+ {
+ name: "partially overlapping top-left",
+ originX: -20, originY: -20,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: true,
+ },
+ {
+ name: "partially overlapping left edge",
+ originX: -24, originY: 300,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: true,
+ },
+ {
+ name: "exactly touching right edge (non-intersecting)",
+ originX: 800, originY: 300,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: false,
+ },
+ {
+ name: "exactly touching bottom edge (non-intersecting)",
+ originX: 100, originY: 600,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: false,
+ },
+ {
+ name: "1px overlap on right edge",
+ originX: 799, originY: 300,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: true,
+ },
+ {
+ name: "centered in viewport",
+ originX: 376, originY: 276,
+ width: 48, height: 48,
+ hasClip: true,
+ wantVis: true,
+ },
+ {
+ name: "large boundary fully enclosing viewport",
+ originX: -100, originY: -100,
+ width: 1000, height: 800,
+ hasClip: true,
+ wantVis: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := &testLeaf{}
+ b.SetVisible(true)
+ b.SetRepaintBoundary(true)
+ b.SetBounds(geometry.NewRect(tt.originX, tt.originY, tt.width, tt.height))
+ b.SetScreenOrigin(geometry.Pt(tt.originX, tt.originY))
+ if tt.hasClip {
+ b.SetCompositorClip(viewport)
+ }
+
+ got := isBoundaryVisible(b)
+ if got != tt.wantVis {
+ t.Errorf("isBoundaryVisible() = %v, want %v "+
+ "(origin=(%g,%g), size=%gx%g, viewport=%v)",
+ got, tt.wantVis, tt.originX, tt.originY,
+ tt.width, tt.height, viewport)
+ }
+ })
+ }
+}
diff --git a/app/compositor_test.go b/app/compositor_test.go
new file mode 100644
index 0000000..9c54bc4
--- /dev/null
+++ b/app/compositor_test.go
@@ -0,0 +1,196 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/compositor"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/widget"
+)
+
+// animWidget simulates a spinner: calls SetNeedsRedraw during Draw.
+type animWidget struct {
+ widget.WidgetBase
+ drawCount int
+}
+
+func (w *animWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(48, 48))
+}
+
+func (w *animWidget) Draw(ctx widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ // Draw a rect so the scene is non-empty.
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(255, 0, 0, 255))
+ w.SetNeedsRedraw(true)
+ if ctx != nil {
+ ctx.InvalidateRect(w.Bounds())
+ }
+}
+
+func (w *animWidget) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *animWidget) Children() []widget.Widget { return nil }
+
+// staticWidget is a non-animated widget.
+type staticWidget struct {
+ widget.WidgetBase
+ drawCount int
+}
+
+func (w *staticWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 40))
+}
+
+func (w *staticWidget) Draw(_ widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(128, 128, 128, 255))
+}
+
+func (w *staticWidget) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *staticWidget) Children() []widget.Widget { return nil }
+
+// TestBuildLayerTree_RootBoundary verifies layer tree construction
+// from a widget tree with root boundary.
+func TestBuildLayerTree_RootBoundary(t *testing.T) {
+ root := &staticWidget{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ layer := BuildLayerTree(root)
+ if layer == nil {
+ t.Fatal("BuildLayerTree should return non-nil layer")
+ }
+}
+
+// TestBuildLayerTree_NestedBoundaries verifies that nested boundary
+// widgets produce nested layers in the tree.
+func TestBuildLayerTree_NestedBoundaries(t *testing.T) {
+ root := &containerTestWidget{children: make([]widget.Widget, 0)}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ child := &staticWidget{}
+ child.SetVisible(true)
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(100, 200, 148, 248))
+ child.SetParent(root)
+ root.children = append(root.children, child)
+
+ layer := BuildLayerTree(root)
+ if layer == nil {
+ t.Fatal("BuildLayerTree returned nil")
+ }
+
+ // Root should have at least one child layer (the child boundary).
+ children := layer.Children()
+ if len(children) == 0 {
+ t.Fatal("root layer should have child layers for nested boundaries")
+ }
+}
+
+// TestCompositorIntegration_SpinnerAnimation is the END-TO-END test
+// that validates the full pipeline: spinner re-records → compositor
+// produces fresh composed scene → animation not frozen.
+//
+// This is the exact scenario that was broken before Layer Tree.
+func TestCompositorIntegration_SpinnerAnimation(t *testing.T) {
+ prev := widget.GetSceneRecorderFactory()
+ widget.RegisterSceneRecorder(testSceneRecorder)
+ defer widget.RegisterSceneRecorder(prev)
+ root := &containerTestWidget{children: make([]widget.Widget, 0)}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ spinner := &animWidget{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 148, 248))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ spinner.SetParent(root)
+ root.children = append(root.children, spinner)
+
+ comp := compositor.New()
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ // Frame 1: paint boundaries then build layer tree.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Verify recording happened.
+ if spinner.drawCount == 0 {
+ t.Fatal("frame 1: spinner.Draw should have been called by PaintBoundaryLayers")
+ }
+ if root.drawCount == 0 {
+ t.Fatal("frame 1: root.Draw should have been called by PaintBoundaryLayers")
+ }
+ if root.CachedScene() == nil {
+ t.Fatal("frame 1: root.CachedScene() is nil after PaintBoundaryLayers")
+ }
+ if root.CachedScene().IsEmpty() {
+ t.Fatal("frame 1: root.CachedScene() is empty after PaintBoundaryLayers")
+ }
+
+ layerTree := BuildLayerTree(root)
+ scene1 := comp.Compose(layerTree)
+
+ if scene1.IsEmpty() {
+ t.Fatal("frame 1: composed scene should not be empty")
+ }
+ v1 := scene1.Version()
+
+ // Frame 2: spinner re-dirtied itself. Only spinner needs re-paint.
+ spinnerDrew := spinner.drawCount
+ layerTree = BuildLayerTree(root) // rebuild to pick up fresh scenes
+ PaintBoundaryLayersWithContext(root, layerTree, ctx)
+ scene2 := comp.Compose(layerTree)
+
+ if spinner.drawCount <= spinnerDrew {
+ t.Error("frame 2: spinner.Draw should have been called again (animation)")
+ }
+ v2 := scene2.Version()
+
+ if v2 <= v1 {
+ t.Errorf("frame 2: composed version %d <= frame 1 version %d; "+
+ "animation frozen — composed scene is stale", v2, v1)
+ }
+
+ // With depth > 0 (all child boundaries render inline in root scene),
+ // root IS re-recorded when spinner is dirty. This is expected —
+ // inline rendering requires parent scene to include updated child content.
+ if root.drawCount < 1 {
+ t.Errorf("root.drawCount = %d; root should have drawn at least once", root.drawCount)
+ }
+}
+
+// containerTestWidget is a widget with explicit children list.
+type containerTestWidget struct {
+ widget.WidgetBase
+ drawCount int
+ children []widget.Widget
+}
+
+func (w *containerTestWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 600))
+}
+
+func (w *containerTestWidget) Draw(_ widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(200, 200, 200, 255))
+}
+
+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)
+ return rec, rec.Close
+}
diff --git a/app/dirty_overlay_test.go b/app/dirty_overlay_test.go
new file mode 100644
index 0000000..f225de2
--- /dev/null
+++ b/app/dirty_overlay_test.go
@@ -0,0 +1,95 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/ui/core/progress"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+)
+
+// simpleBox is a container for dirty overlay tests.
+type simpleBox struct {
+ widget.WidgetBase
+ kids []widget.Widget
+}
+
+func (w *simpleBox) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(400, 300))
+}
+func (w *simpleBox) Draw(_ widget.Context, canvas widget.Canvas) {
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(255, 255, 255, 255))
+ for _, child := range w.kids {
+ widget.DrawChild(child, nil, canvas)
+ }
+}
+func (w *simpleBox) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *simpleBox) Children() []widget.Widget { return w.kids }
+
+// TestDirtyOverlay_SpinnerRegionIs48x48 verifies that the dirty collector
+// reports spinner bounds as ~48×48, NOT full parent width.
+// This is the test that caught the VBox expansion bug.
+func TestDirtyOverlay_SpinnerRegionIs48x48(t *testing.T) {
+ uiApp := New()
+ win := uiApp.Window()
+
+ spinner := progress.New(progress.Indeterminate(true), progress.Size(48))
+
+ root := &simpleBox{}
+ root.SetVisible(true)
+ root.SetBounds(geometry.NewRect(0, 0, 400, 300))
+ root.kids = []widget.Widget{spinner}
+
+ win.SetRoot(root)
+
+ ctx := win.Context()
+ constraints := geometry.BoxConstraints(0, 400, 0, 300)
+ root.Layout(ctx, constraints)
+
+ spinnerConstraints := geometry.BoxConstraints(0, 400, 0, 300)
+ spinnerSize := spinner.Layout(ctx, spinnerConstraints)
+ spinner.SetBounds(geometry.NewRect(100, 100, spinnerSize.Width, spinnerSize.Height))
+
+ // Spinner size must be 48×48, NOT 400 wide.
+ if spinnerSize.Width != 48 {
+ t.Errorf("spinner layout width = %v, want 48 (intrinsic, not parent width)", spinnerSize.Width)
+ }
+ if spinnerSize.Height != 48 {
+ t.Errorf("spinner layout height = %v, want 48", spinnerSize.Height)
+ }
+
+ // Spinner bounds should be 48×48 at position (100,100).
+ bounds := spinner.Bounds()
+ bw := bounds.Max.X - bounds.Min.X
+ bh := bounds.Max.Y - bounds.Min.Y
+ if bw != 48 {
+ t.Errorf("spinner bounds width = %v (min=%v max=%v), want 48", bw, bounds.Min.X, bounds.Max.X)
+ }
+ if bh != 48 {
+ t.Errorf("spinner bounds height = %v (min=%v max=%v), want 48", bh, bounds.Min.Y, bounds.Max.Y)
+ }
+
+ // Collect dirty regions — spinner should be dirty (indeterminate animates).
+ // First frame: all widgets dirty. Collect should report spinner at 48×48.
+ win.CollectDirtyRegions()
+ regions := win.DirtyRegions()
+
+ // Find a region that matches spinner bounds (48×48 at 100,100).
+ foundSpinner := false
+ for _, r := range regions {
+ w := r.Width()
+ h := r.Height()
+ if w >= 40 && w <= 56 && h >= 40 && h <= 56 {
+ foundSpinner = true
+ t.Logf("spinner dirty region: %v (%.0f×%.0f)", r, w, h)
+ }
+ if w > 100 {
+ t.Errorf("dirty region too wide: %v (%.0f×%.0f) — spinner boundary leak", r, w, h)
+ }
+ }
+ if !foundSpinner && len(regions) > 0 {
+ t.Logf("dirty regions: %v", regions)
+ t.Error("no ~48×48 dirty region found for spinner")
+ }
+}
diff --git a/app/drawchild_skip_test.go b/app/drawchild_skip_test.go
new file mode 100644
index 0000000..d7e37f0
--- /dev/null
+++ b/app/drawchild_skip_test.go
@@ -0,0 +1,518 @@
+package app
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/core/listview"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/primitives"
+ "github.com/gogpu/ui/widget"
+)
+
+// itemWidget is a minimal widget used as a ListView item in tests.
+// It draws a colored rectangle so its scene is non-empty.
+type itemWidget struct {
+ widget.WidgetBase
+ index int
+ drawCount int
+}
+
+func newItemWidget(index int) *itemWidget {
+ w := &itemWidget{index: index}
+ w.SetVisible(true)
+ w.SetEnabled(true)
+ return w
+}
+
+func (w *itemWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ // Fixed height items for predictable test behavior.
+ return c.Constrain(geometry.Sz(c.MaxWidth, 48))
+}
+
+func (w *itemWidget) Draw(_ widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ // Draw a colored rectangle so the recorded scene is non-empty.
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(100, 150, 200, 255))
+}
+
+func (w *itemWidget) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *itemWidget) Children() []widget.Widget { return nil }
+
+// TestDrawChildSkip_ListViewItemBoundaries is the primary diagnostic test for
+// the DrawChild skip pattern with ListView items.
+//
+// Scenario: root (boundary) contains a ListView with 5 items. Each item is
+// automatically wrapped as a RepaintBoundary by the widget cache.
+//
+// Expected behavior (Flutter paintChild pattern):
+// 1. PaintBoundaryLayers records root boundary -> calls root.Draw()
+// 2. root.Draw -> ListView.Draw -> ScrollView.Draw -> VirtualContent.Draw
+// 3. VirtualContent.Draw populates cache (items created, bounds set)
+// 4. DrawChild SKIPS boundary items during recording (BoundaryRecorder)
+// 5. After root recording, PaintBoundaryLayers RECURSES into children
+// 6. Recursion reaches virtualContent.Children() -> finds item widgets
+// 7. Each item has IsRepaintBoundary=true, sceneDirty=true -> recordBoundary
+// 8. Item scenes are recorded with their content
+//
+// This test verifies every step of this chain.
+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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ const itemCount = 5
+
+ // Build ListView with simple item widgets.
+ lv := listview.New(
+ listview.ItemCount(itemCount),
+ listview.FixedItemHeight(48),
+ listview.BuildItem(func(ctx listview.ItemContext) widget.Widget {
+ return newItemWidget(ctx.Index)
+ }),
+ )
+
+ // Root container (boundary) containing the ListView.
+ root := &listViewTestContainer{kids: []widget.Widget{lv}}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 400, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // Layout the tree so widgets have proper dimensions.
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ rootConstraints := geometry.Tight(geometry.Sz(400, 600))
+ root.Layout(ctx, rootConstraints)
+
+ // Set ListView bounds after layout.
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 600))
+ lv.Layout(ctx, rootConstraints)
+
+ // Mount the tree to wire parent chain.
+ widget.MountTree(root, ctx)
+
+ // STEP 1: Verify root is dirty before painting.
+ if !root.IsSceneDirty() {
+ t.Fatal("root should be sceneDirty=true before first PaintBoundaryLayers")
+ }
+
+ // STEP 2: Paint boundary layers -- this is the function under test.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // STEP 3: Verify root was recorded (has non-nil, non-empty scene).
+ rootScene := root.CachedScene()
+ if rootScene == nil {
+ t.Fatal("root.CachedScene() is nil after PaintBoundaryLayers")
+ }
+ if rootScene.IsEmpty() {
+ t.Fatal("root.CachedScene() is empty -- root Draw was not recorded properly")
+ }
+
+ // STEP 4: Collect item widgets via tree traversal.
+ // After root recording, virtualContent.Draw populated the cache.
+ // virtualContent.Children() should return the item widgets.
+ items := collectBoundaryDescendants(root)
+ t.Logf("found %d boundary descendants (excluding root)", len(items))
+
+ if len(items) == 0 {
+ // Detailed diagnostics: walk the tree manually.
+ t.Log("=== DIAGNOSTIC: Walking tree to find items ===")
+ walkTreeDiag(t, root, 0)
+ t.Fatal("no boundary descendants found -- items not visible to tree walk")
+ }
+
+ // STEP 5: Verify each item.
+ for i, item := range items {
+ // 5a: Item has IsRepaintBoundary.
+ bc, ok := item.(interface{ IsRepaintBoundary() bool })
+ if !ok || !bc.IsRepaintBoundary() {
+ t.Errorf("item[%d]: IsRepaintBoundary should be true", i)
+ continue
+ }
+
+ // 5b: Item has non-zero bounds.
+ bg, ok := item.(interface{ Bounds() geometry.Rect })
+ if !ok {
+ t.Errorf("item[%d]: does not implement Bounds()", i)
+ continue
+ }
+ bounds := bg.Bounds()
+ if bounds.Width() <= 0 || bounds.Height() <= 0 {
+ t.Errorf("item[%d]: bounds are zero/negative: %v (width=%.1f, height=%.1f)",
+ i, bounds, bounds.Width(), bounds.Height())
+ continue
+ }
+ 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 })
+ if !ok {
+ t.Errorf("item[%d]: does not implement CachedScene()", i)
+ continue
+ }
+ cachedScene := sc.CachedScene()
+ if cachedScene == nil {
+ t.Errorf("item[%d]: CachedScene is nil -- PaintBoundaryLayers did not "+
+ "reach this boundary during recursion", i)
+ continue
+ }
+
+ // 5d: Item scene is non-empty (has actual draw commands).
+ if cachedScene.IsEmpty() {
+ t.Errorf("item[%d]: CachedScene is empty -- recordBoundary was called "+
+ "but item.Draw() did not produce any draw commands", i)
+ continue
+ }
+
+ t.Logf("item[%d]: OK (scene recorded, non-empty)", i)
+ }
+
+ // STEP 6: Verify we found the expected number of items.
+ if len(items) < itemCount {
+ t.Errorf("expected at least %d item boundaries, found %d", itemCount, len(items))
+ }
+}
+
+// TestDrawChildSkip_RootRecordingSkipsItems verifies that during root boundary
+// recording, DrawChild correctly skips child boundaries (BoundaryRecorder check).
+// 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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ // Track which boundaries get recorded.
+ var recordedBoundaries []string
+
+ const itemCount = 3
+ lv := listview.New(
+ listview.ItemCount(itemCount),
+ listview.FixedItemHeight(48),
+ listview.BuildItem(func(ctx listview.ItemContext) widget.Widget {
+ return newItemWidget(ctx.Index)
+ }),
+ )
+
+ root := &recordingContainer{
+ name: "root",
+ kids: []widget.Widget{lv},
+ onDraw: func(name string) {
+ recordedBoundaries = append(recordedBoundaries, name)
+ },
+ }
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 400, 300))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ constraints := geometry.Tight(geometry.Sz(400, 300))
+ root.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 300))
+ lv.Layout(ctx, constraints)
+
+ widget.MountTree(root, ctx)
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Root should be in the recorded list (its Draw was called).
+ if len(recordedBoundaries) == 0 || recordedBoundaries[0] != "root" {
+ t.Errorf("root Draw was not called, recorded=%v", recordedBoundaries)
+ }
+ t.Logf("recorded boundaries: %v", recordedBoundaries)
+
+ // 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 {
+ cs := sc.CachedScene()
+ if cs == nil {
+ t.Errorf("item[%d]: CachedScene nil after PaintBoundaryLayers", i)
+ } else if cs.IsEmpty() {
+ t.Errorf("item[%d]: CachedScene empty after PaintBoundaryLayers", i)
+ }
+ }
+ }
+}
+
+// TestDrawChildSkip_ItemsExistAfterRootRecording verifies that item widgets
+// exist in the tree (via Children()) AFTER root recording completes, even
+// 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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ const itemCount = 5
+ lv := listview.New(
+ listview.ItemCount(itemCount),
+ listview.FixedItemHeight(48),
+ listview.BuildItem(func(ctx listview.ItemContext) widget.Widget {
+ return newItemWidget(ctx.Index)
+ }),
+ )
+
+ root := &listViewTestContainer{kids: []widget.Widget{lv}}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 400, 600))
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ constraints := geometry.Tight(geometry.Sz(400, 600))
+ root.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 600))
+ lv.Layout(ctx, constraints)
+
+ // BEFORE root recording: items should NOT exist yet.
+ itemsBefore := collectBoundaryDescendants(root)
+ t.Logf("items BEFORE root recording: %d", len(itemsBefore))
+
+ // Record root boundary only (simulates what recordBoundary does).
+ rootScene := scene.NewScene()
+ recorder, cleanup := widget.GetSceneRecorderFactory()(rootScene, 400, 600)
+ recorder.PushTransform(geometry.Pt(0, 0))
+ root.Draw(ctx, recorder)
+ recorder.PopTransform()
+ cleanup()
+
+ // AFTER root recording: items should exist (cache populated by VirtualContent.Draw).
+ itemsAfter := collectBoundaryDescendants(root)
+ t.Logf("items AFTER root recording: %d", len(itemsAfter))
+
+ if len(itemsAfter) == 0 {
+ t.Log("=== DIAGNOSTIC: Walking tree after root.Draw ===")
+ walkTreeDiag(t, root, 0)
+ t.Fatal("no items found after root recording -- VirtualContent.Draw did not " +
+ "populate cache, or virtualContent.Children() does not expose cached items")
+ }
+
+ // Verify items have valid bounds (set during VirtualContent.Draw).
+ for i, item := range itemsAfter {
+ if bg, ok := item.(interface{ Bounds() geometry.Rect }); ok {
+ bounds := bg.Bounds()
+ if bounds.Width() <= 0 || bounds.Height() <= 0 {
+ t.Errorf("item[%d]: bounds invalid after root recording: %v", i, bounds)
+ } else {
+ t.Logf("item[%d]: bounds=%v OK", i, bounds)
+ }
+ }
+ }
+}
+
+// TestDrawChildSkip_BoxTextItems_ProductionScenario tests the exact scenario
+// from the hello example: ListView items are primitives.Box(primitives.Text(...)).
+// This verifies that PaintBoundaryLayers records item scenes that contain
+// 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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ const itemCount = 5
+
+ // Build ListView with Box(Text) items -- same pattern as hello example.
+ lv := listview.New(
+ listview.ItemCount(itemCount),
+ listview.FixedItemHeight(36),
+ listview.BuildItem(func(ctx listview.ItemContext) widget.Widget {
+ return primitives.Box(
+ primitives.Text(fmt.Sprintf("Item %d", ctx.Index)).
+ FontSize(14).
+ Color(widget.RGBA8(33, 33, 33, 255)),
+ ).PaddingXY(12, 8)
+ }),
+ )
+
+ root := &listViewTestContainer{kids: []widget.Widget{lv}}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 400, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ constraints := geometry.Tight(geometry.Sz(400, 600))
+ root.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 600))
+ lv.Layout(ctx, constraints)
+ widget.MountTree(root, ctx)
+
+ // Paint all boundaries.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Verify root recorded.
+ if root.CachedScene() == nil || root.CachedScene().IsEmpty() {
+ t.Fatal("root scene should be non-nil and non-empty")
+ }
+
+ // Verify item boundaries.
+ items := collectBoundaryDescendants(root)
+ t.Logf("found %d boundary descendants", len(items))
+
+ if len(items) < itemCount {
+ t.Log("=== DIAGNOSTIC: Tree after PaintBoundaryLayers ===")
+ walkTreeDiag(t, root, 0)
+ t.Fatalf("expected at least %d items, found %d", itemCount, len(items))
+ }
+
+ for i, item := range items {
+ sc, ok := item.(interface{ CachedScene() *scene.Scene })
+ if !ok {
+ t.Errorf("item[%d]: does not implement CachedScene()", i)
+ continue
+ }
+ cs := sc.CachedScene()
+ if cs == nil {
+ t.Errorf("item[%d]: CachedScene nil", i)
+ continue
+ }
+ if cs.IsEmpty() {
+ t.Errorf("item[%d]: CachedScene empty -- Box+Text content not recorded", i)
+ continue
+ }
+ t.Logf("item[%d]: OK (Box+Text scene recorded)", i)
+ }
+}
+
+// --- Test Helpers ---
+
+// listViewTestContainer is a simple container that draws children via DrawChild.
+type listViewTestContainer struct {
+ widget.WidgetBase
+ kids []widget.Widget
+}
+
+func (w *listViewTestContainer) Layout(ctx widget.Context, c geometry.Constraints) geometry.Size {
+ // Layout children.
+ for _, child := range w.kids {
+ child.Layout(ctx, c)
+ }
+ return c.Constrain(geometry.Sz(400, 600))
+}
+
+func (w *listViewTestContainer) Draw(ctx widget.Context, canvas widget.Canvas) {
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(240, 240, 240, 255))
+ for _, child := range w.kids {
+ widget.StampScreenOrigin(child, canvas)
+ widget.DrawChild(child, ctx, canvas)
+ }
+}
+
+func (w *listViewTestContainer) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *listViewTestContainer) Children() []widget.Widget { return w.kids }
+
+// recordingContainer records when its Draw is called.
+type recordingContainer struct {
+ widget.WidgetBase
+ name string
+ kids []widget.Widget
+ onDraw func(name string)
+}
+
+func (w *recordingContainer) Layout(ctx widget.Context, c geometry.Constraints) geometry.Size {
+ for _, child := range w.kids {
+ child.Layout(ctx, c)
+ }
+ return c.Constrain(geometry.Sz(400, 300))
+}
+
+func (w *recordingContainer) Draw(ctx widget.Context, canvas widget.Canvas) {
+ if w.onDraw != nil {
+ w.onDraw(w.name)
+ }
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(240, 240, 240, 255))
+ for _, child := range w.kids {
+ widget.StampScreenOrigin(child, canvas)
+ widget.DrawChild(child, ctx, canvas)
+ }
+}
+
+func (w *recordingContainer) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *recordingContainer) Children() []widget.Widget { return w.kids }
+
+// collectBoundaryDescendants walks the widget tree and returns all widgets
+// (excluding root) that have IsRepaintBoundary=true.
+func collectBoundaryDescendants(root widget.Widget) []widget.Widget {
+ var result []widget.Widget
+ collectBoundaryDescendantsRecursive(root, &result, true)
+ return result
+}
+
+func collectBoundaryDescendantsRecursive(w widget.Widget, result *[]widget.Widget, isRoot bool) {
+ if w == nil {
+ return
+ }
+
+ if !isRoot {
+ if bc, ok := w.(interface{ IsRepaintBoundary() bool }); ok && bc.IsRepaintBoundary() {
+ *result = append(*result, w)
+ }
+ }
+
+ for _, child := range w.Children() {
+ collectBoundaryDescendantsRecursive(child, result, false)
+ }
+}
+
+// walkTreeDiag prints a diagnostic tree walk showing widget types, bounds,
+// and boundary status.
+func walkTreeDiag(t *testing.T, w widget.Widget, depth int) {
+ t.Helper()
+ if w == nil {
+ return
+ }
+
+ indent := ""
+ for range depth {
+ indent += " "
+ }
+
+ isBoundary := false
+ if bc, ok := w.(interface{ IsRepaintBoundary() bool }); ok {
+ isBoundary = bc.IsRepaintBoundary()
+ }
+
+ bounds := geometry.Rect{}
+ if bg, ok := w.(interface{ Bounds() geometry.Rect }); ok {
+ bounds = bg.Bounds()
+ }
+
+ sceneDirty := false
+ hasScene := false
+ if sd, ok := w.(interface{ IsSceneDirty() bool }); ok {
+ sceneDirty = sd.IsSceneDirty()
+ }
+ if sc, ok := w.(interface{ CachedScene() *scene.Scene }); ok {
+ hasScene = sc.CachedScene() != nil
+ }
+
+ t.Logf("%s%T boundary=%v bounds=%v sceneDirty=%v hasScene=%v children=%d",
+ indent, w, isBoundary, bounds, sceneDirty, hasScene, len(w.Children()))
+
+ for _, child := range w.Children() {
+ walkTreeDiag(t, child, depth+1)
+ }
+}
diff --git a/app/first_frame_test.go b/app/first_frame_test.go
index 900a8a2..8cf2665 100644
--- a/app/first_frame_test.go
+++ b/app/first_frame_test.go
@@ -63,6 +63,7 @@ func (c *trackingCanvas) PopClip() {}
func (c *trackingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -186,6 +187,13 @@ func setupFirstFrameWindow(root widget.Widget) (*Window, *trackingCanvas) {
a := New()
w := a.Window()
w.SetRoot(root)
+ // Disable auto-boundary so DrawTo uses direct Draw (not scene recording).
+ // First-frame tests verify layout correctness via trackingCanvas DrawText
+ // calls, which require direct drawing — not scene.Scene replay.
+ // Boundary/compositor tests are separate (TestFirstFrame_RootBoundary*).
+ if rb, ok := root.(interface{ SetRepaintBoundary(bool) }); ok {
+ rb.SetRepaintBoundary(false)
+ }
w.HandleResize(1024, 700)
w.Frame()
@@ -430,6 +438,9 @@ func TestFirstFrame_SecondFrameNoLayoutIfClean(t *testing.T) {
a := New()
w := a.Window()
w.SetRoot(root)
+ if rb, ok := root.(interface{ SetRepaintBoundary(bool) }); ok {
+ rb.SetRepaintBoundary(false)
+ }
w.HandleResize(1024, 700)
// First frame.
@@ -504,6 +515,7 @@ func TestFirstFrame_TabViewContentBoundsSet(t *testing.T) {
a := New()
w := a.Window()
w.SetRoot(root)
+ root.SetRepaintBoundary(false)
w.HandleResize(800, 600)
w.Frame()
@@ -548,6 +560,7 @@ func TestFirstFrame_SplitViewChildBoundsSet(t *testing.T) {
a := New()
w := a.Window()
w.SetRoot(root)
+ root.SetRepaintBoundary(false)
w.HandleResize(800, 600)
w.Frame()
@@ -573,3 +586,89 @@ func TestFirstFrame_SplitViewChildBoundsSet(t *testing.T) {
t.Error("Right Panel not drawn on first frame")
}
}
+
+// --- trackingCanvas.ReplayScene Limitation Tests ---
+//
+// trackingCanvas.ReplayScene is a no-op. When the root widget is a WidgetBase
+// RepaintBoundary (ADR-024), drawBoundaryWidget records ALL content into a
+// scene.Scene and replays via canvas.ReplayScene. On trackingCanvas, this
+// silently discards all content → tests see zero DrawText calls.
+//
+// These tests document the limitation and verify boundary-aware test patterns.
+
+// TestTrackingCanvas_ReplaySceneIsNoOp documents that trackingCanvas drops
+// all scene content. Tests that count DrawText calls must NOT use root
+// RepaintBoundary with trackingCanvas, or must use a scene-aware canvas.
+func TestTrackingCanvas_ReplaySceneIsNoOp(t *testing.T) {
+ canvas := &trackingCanvas{}
+ sc := scene.NewScene()
+
+ // Even a non-empty scene is silently discarded by trackingCanvas.
+ canvas.ReplayScene(sc)
+
+ if len(canvas.drawTextCalls) != 0 {
+ t.Error("trackingCanvas.ReplayScene should be a no-op (known limitation)")
+ }
+}
+
+// TestFirstFrame_RootBoundaryMakesTrackingCanvasBlind verifies that SetRoot
+// auto-enables RepaintBoundary on root (ADR-024 Phase 3), which causes
+// trackingCanvas to miss all DrawText calls (ReplayScene is no-op).
+// This documents WHY the old first_frame tests fail with root boundary.
+func TestFirstFrame_RootBoundaryMakesTrackingCanvasBlind(t *testing.T) {
+ uiApp := New()
+ w := uiApp.Window()
+
+ root := primitives.Box(
+ primitives.Text("Hello").FontSize(14),
+ ).Padding(8)
+
+ w.SetRoot(root)
+
+ // Verify SetRoot auto-enabled boundary (ADR-024 Phase 3).
+ if !root.IsRepaintBoundary() {
+ t.Fatal("SetRoot should auto-enable RepaintBoundary on root")
+ }
+
+ canvas := &trackingCanvas{}
+ w.Frame()
+ w.DrawTo(canvas)
+
+ // trackingCanvas.ReplayScene is no-op → zero DrawText calls.
+ if len(canvas.drawTextCalls) != 0 {
+ t.Errorf("expected 0 DrawText calls with root boundary + trackingCanvas, got %d",
+ len(canvas.drawTextCalls))
+ }
+}
+
+// TestFirstFrame_DirectDrawWithoutBoundary verifies that drawing directly
+// (bypassing SetRoot auto-boundary) produces DrawText calls on trackingCanvas.
+func TestFirstFrame_DirectDrawWithoutBoundary(t *testing.T) {
+ uiApp := New()
+ w := uiApp.Window()
+
+ root := primitives.Box(
+ primitives.Text("Hello").FontSize(14),
+ ).Padding(8)
+
+ w.SetRoot(root)
+
+ // Manually disable the auto-boundary for testing.
+ root.SetRepaintBoundary(false)
+
+ canvas := &trackingCanvas{}
+ w.Frame()
+ w.DrawTo(canvas)
+
+ hasText := false
+ for _, call := range canvas.drawTextCalls {
+ if strings.Contains(call.text, "Hello") {
+ hasText = true
+ break
+ }
+ }
+
+ if !hasText {
+ t.Error("expected 'Hello' drawn when root boundary disabled")
+ }
+}
diff --git a/app/hover_boundary_e2e_test.go b/app/hover_boundary_e2e_test.go
new file mode 100644
index 0000000..665e99b
--- /dev/null
+++ b/app/hover_boundary_e2e_test.go
@@ -0,0 +1,289 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/core/button"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/widget"
+)
+
+// TestHoverE2E_ButtonInBoundary_DirtyPropagation verifies the full hover chain:
+//
+// MouseMove → hitTest → MouseEnter → Button.SetNeedsRedraw(true)
+// → propagateDirtyUpward → root boundary InvalidateScene → sceneDirty=true
+// → onBoundaryDirty callback → ctx.InvalidateRect → Window.needsRedraw
+// → PaintBoundaryLayers re-records root scene with hover state
+//
+// This is the critical chain that must work for hover effects to be visible
+// after the depth>0 boundary change that renders child boundaries inline.
+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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ // Build widget tree: root (boundary) → container → button
+ root := &boxContainer{}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ btn := button.New(button.Text("Hover Me"))
+ btn.SetBounds(geometry.NewRect(50, 50, 200, 90))
+ btn.SetScreenOrigin(geometry.Pt(50, 50))
+ root.kids = []widget.Widget{btn}
+
+ // Mount tree to wire parent chain (critical for propagateDirtyUpward).
+ invalidateRectCalled := false
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {
+ invalidateRectCalled = true
+ })
+ widget.MountTree(root, ctx)
+
+ // Step 1: Initial recording (first frame).
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if root.CachedScene() == nil {
+ t.Fatal("root CachedScene should be non-nil after initial paint")
+ }
+ initialVersion := root.SceneCacheVersion()
+ t.Logf("initial: sceneDirty=%v, version=%d", root.IsSceneDirty(), initialVersion)
+
+ // Step 2: Verify button's parent chain is wired.
+ if btn.Parent() == nil {
+ t.Fatal("button.Parent() is nil — MountTree did not wire parent chain")
+ }
+ if btn.Parent() != root {
+ t.Errorf("button.Parent() = %T, want root container", btn.Parent())
+ }
+
+ // Step 3: Verify root boundary has onBoundaryDirty callback.
+ // After recordBoundary, the callback should be set.
+ // We can verify indirectly: clear root dirty, then trigger propagation.
+ if root.IsSceneDirty() {
+ t.Log("root is still dirty after PaintBoundaryLayers — unexpected")
+ }
+
+ // Step 4: Simulate MouseEnter on button.
+ enterEvt := event.NewMouseEvent(
+ event.MouseEnter, event.ButtonNone, 0,
+ geometry.Pt(100, 70), geometry.Pt(100, 70), event.ModNone,
+ )
+ consumed := btn.Event(ctx, enterEvt)
+ if !consumed {
+ t.Fatal("button should consume MouseEnter event")
+ }
+
+ // Step 5: Verify button state changed to hover.
+ if !btn.NeedsRedraw() {
+ t.Error("button should have needsRedraw=true after MouseEnter")
+ }
+
+ // Step 6: Verify dirty propagated to root boundary.
+ if !root.IsSceneDirty() {
+ t.Error("root boundary sceneDirty should be true after button hover — " +
+ "propagateDirtyUpward did not reach root boundary. " +
+ "Check: 1) button.Parent() wired, 2) root.IsRepaintBoundary(), " +
+ "3) InvalidateScene() called")
+ }
+
+ // Step 7: Verify onBoundaryDirty callback fired.
+ if !invalidateRectCalled {
+ t.Error("onBoundaryDirty callback should have called ctx.InvalidateRect — " +
+ "callback not wired or not fired. Check recordBoundary SetOnBoundaryDirty")
+ }
+
+ // Step 8: Re-record (simulates next frame's PaintBoundaryLayers).
+ invalidateRectCalled = false
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ newVersion := root.SceneCacheVersion()
+ if newVersion <= initialVersion {
+ t.Errorf("SceneCacheVersion should increment after re-recording: "+
+ "initial=%d, after=%d", initialVersion, newVersion)
+ }
+ t.Logf("after hover re-record: version=%d, sceneDirty=%v", newVersion, root.IsSceneDirty())
+
+ // Step 9: Verify scene is clean after re-recording (ready for next frame).
+ if root.IsSceneDirty() {
+ t.Error("root should be clean after PaintBoundaryLayers re-recorded it")
+ }
+}
+
+// TestHoverE2E_DeepNesting_PropagatesUpward verifies dirty propagation through
+// 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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ // 4-level tree: root (boundary) → mid → inner → button
+ root := &boxContainer{}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ mid := &boxContainer{}
+ mid.SetVisible(true)
+ mid.SetEnabled(true)
+ mid.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ inner := &boxContainer{}
+ inner.SetVisible(true)
+ inner.SetEnabled(true)
+ inner.SetBounds(geometry.NewRect(10, 10, 300, 200))
+
+ btn := button.New(button.Text("Deep Button"))
+ btn.SetBounds(geometry.NewRect(20, 20, 150, 60))
+ btn.SetScreenOrigin(geometry.Pt(30, 30))
+
+ inner.kids = []widget.Widget{btn}
+ mid.kids = []widget.Widget{inner}
+ root.kids = []widget.Widget{mid}
+
+ callbackCount := 0
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {
+ callbackCount++
+ })
+ widget.MountTree(root, ctx)
+
+ // Initial paint to wire callbacks.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Verify parent chain: button → inner → mid → root
+ p := btn.Parent()
+ if p == nil {
+ t.Fatal("button has no parent")
+ }
+ if p != inner {
+ t.Errorf("button.Parent() = %T(%p), want inner(%p)", p, p, inner)
+ }
+
+ p2 := inner.Parent()
+ if p2 != mid {
+ t.Errorf("inner.Parent() = %T(%p), want mid(%p)", p2, p2, mid)
+ }
+
+ p3 := mid.Parent()
+ if p3 != root {
+ t.Errorf("mid.Parent() = %T(%p), want root(%p)", p3, p3, root)
+ }
+
+ // Trigger hover.
+ enterEvt := event.NewMouseEvent(
+ event.MouseEnter, event.ButtonNone, 0,
+ geometry.Pt(50, 40), geometry.Pt(50, 40), event.ModNone,
+ )
+ btn.Event(ctx, enterEvt)
+
+ if !root.IsSceneDirty() {
+ t.Error("root boundary should be scene-dirty after deep hover — " +
+ "propagateDirtyUpward failed to walk 3-level parent chain")
+ }
+
+ if callbackCount == 0 {
+ t.Error("onBoundaryDirty callback should have fired")
+ }
+}
+
+// TestHoverE2E_WindowHandleEvent_FullChain verifies the complete chain through
+// 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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ a := New()
+ win := a.Window()
+
+ // Create a button with known bounds.
+ btn := button.New(button.Text("Click Me"))
+
+ // Root container with the button.
+ root := &boxContainer{kids: []widget.Widget{btn}}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ win.SetRoot(root)
+
+ // Layout and first frame to set up bounds and screen origins.
+ // SetRoot marks root as boundary and mounts the tree.
+ // Frame performs layout and initial draw.
+ btn.SetBounds(geometry.NewRect(50, 50, 200, 90))
+ btn.SetScreenOrigin(geometry.Pt(50, 50))
+
+ // First paint to wire onBoundaryDirty callback.
+ PaintBoundaryLayersWithContext(win.Root(), nil, win.Context())
+
+ // Clear dirty state from initial paint.
+ // ClearSceneDirty so we can detect re-dirtying from hover.
+ type sceneClearer interface {
+ ClearSceneDirty()
+ }
+ if sc, ok := win.Root().(sceneClearer); ok {
+ sc.ClearSceneDirty()
+ }
+
+ // Verify root is clean before hover.
+ type sceneDirtyChecker interface {
+ IsSceneDirty() bool
+ }
+ if sd, ok := win.Root().(sceneDirtyChecker); ok {
+ if sd.IsSceneDirty() {
+ t.Log("warning: root still dirty after clear — may affect test")
+ }
+ }
+
+ // Simulate mouse move into button area.
+ moveEvt := event.NewMouseEvent(
+ event.MouseMove, event.ButtonNone, 0,
+ geometry.Pt(100, 70), geometry.Pt(100, 70), event.ModNone,
+ )
+ win.HandleEvent(moveEvt)
+
+ // Verify hover target.
+ if win.HoveredWidget() == nil {
+ t.Fatal("no widget hovered — hitTest returned nil. " +
+ "Check ScreenBounds on button")
+ }
+
+ // The hovered widget should be the button.
+ if win.HoveredWidget() != btn {
+ t.Errorf("hovered widget = %T, want button", win.HoveredWidget())
+ }
+
+ // Verify root boundary is scene-dirty.
+ if sd, ok := win.Root().(sceneDirtyChecker); ok {
+ if !sd.IsSceneDirty() {
+ t.Error("root boundary should be scene-dirty after hover on button — " +
+ "the dirty propagation chain is broken")
+ }
+ } else {
+ t.Error("root does not implement IsSceneDirty")
+ }
+
+ // Verify Window knows it needs redraw.
+ if !win.NeedsRedraw() {
+ t.Error("Window.NeedsRedraw() should be true after hover event")
+ }
+}
diff --git a/app/layer_tree.go b/app/layer_tree.go
new file mode 100644
index 0000000..15769ab
--- /dev/null
+++ b/app/layer_tree.go
@@ -0,0 +1,270 @@
+package app
+
+import (
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/compositor"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+)
+
+// boundaryInfo describes a widget that is a RepaintBoundary.
+type boundaryInfo interface {
+ widget.Widget
+ IsRepaintBoundary() bool
+ IsSceneDirty() bool
+ CachedScene() *scene.Scene
+ SetCachedScene(*scene.Scene)
+ ClearSceneDirty()
+ SceneCacheSize() (int, int)
+ SetSceneCacheSize(int, int)
+ Bounds() geometry.Rect
+ ScreenOrigin() geometry.Point
+}
+
+// BuildLayerTree walks the widget tree and constructs a compositor layer tree.
+// Each RepaintBoundary widget produces a PictureLayer inside an OffsetLayer.
+// Non-boundary widgets are skipped (they're drawn inside their parent boundary).
+//
+// NOT IN PRODUCTION PIPELINE: the production render loop (desktop.draw)
+// uses PaintBoundaryLayers + renderBoundaryTextures + compositeTextures
+// instead. BuildLayerTree is retained for future use with the compositor
+// package (animated transforms, opacity layers).
+//
+// See: ADR-007 Phase 5 (bypassed in favor of Phase 7 per-boundary GPU textures)
+// Task: TASK-UI-OPT-005-compositor-integration (backlog)
+//
+// Flutter equivalent: Layer tree is built during paint via paintChild.
+func BuildLayerTree(root widget.Widget) *compositor.OffsetLayerImpl {
+ if root == nil {
+ return compositor.NewOffsetLayer(geometry.Point{})
+ }
+
+ rootLayer := compositor.NewOffsetLayer(geometry.Point{})
+ buildLayerRecursive(root, rootLayer, 0, 0)
+ return rootLayer
+}
+
+// buildLayerRecursive walks the widget tree, adding PictureLayer for each boundary.
+// localX/localY accumulate offsets from non-boundary ancestors, so each
+// boundary's OffsetLayer gets the correct position relative to its
+// parent boundary (not just its immediate parent widget).
+func buildLayerRecursive(w widget.Widget, parentLayer compositor.ContainerLayer, localX, localY float32) {
+ if w == nil {
+ return
+ }
+
+ type boundsGetter interface{ Bounds() geometry.Rect }
+ var boundsMin geometry.Point
+ if bg, ok := w.(boundsGetter); ok {
+ boundsMin = bg.Bounds().Min
+ }
+
+ bi, isBoundary := w.(boundaryInfo)
+ if isBoundary && bi.IsRepaintBoundary() {
+ // Offset relative to parent boundary = accumulated local offset + own bounds.Min
+ offset := geometry.Pt(localX+boundsMin.X, localY+boundsMin.Y)
+
+ childOffset := compositor.NewOffsetLayer(offset)
+ pic := compositor.NewPictureLayer()
+
+ cachedScene := bi.CachedScene()
+ if cachedScene != nil {
+ pic.SetPicture(cachedScene)
+ }
+ if bi.IsSceneDirty() {
+ pic.MarkDirty()
+ } else {
+ pic.ClearDirty()
+ }
+
+ childOffset.Append(pic)
+ parentLayer.Append(childOffset)
+
+ // Recurse into children. Local offset resets to (0,0) because
+ // this boundary's OffsetLayer already accounts for its position.
+ for _, child := range w.Children() {
+ buildLayerRecursive(child, childOffset, 0, 0)
+ }
+ return
+ }
+
+ // Non-boundary widget: accumulate its bounds.Min and recurse.
+ nextX := localX + boundsMin.X
+ nextY := localY + boundsMin.Y
+ for _, child := range w.Children() {
+ buildLayerRecursive(child, parentLayer, nextX, nextY)
+ }
+}
+
+// PaintBoundaryLayers walks the widget tree and re-records dirty boundaries.
+// This is the Flutter flushPaint equivalent: only dirty boundary PictureLayers
+// are re-recorded. Clean boundaries keep their cached scenes.
+//
+// After this function, all boundary CachedScene values are fresh.
+// The compositor can then Compose the layer tree to assemble the final scene.
+// PaintBoundaryLayers re-records dirty boundaries with nil context.
+func PaintBoundaryLayers(root widget.Widget, _ *compositor.OffsetLayerImpl) {
+ PaintBoundaryLayersWithContext(root, nil, nil)
+}
+
+// PaintBoundaryLayersWithContext re-records dirty boundaries with a given context.
+func PaintBoundaryLayersWithContext(root widget.Widget, _ *compositor.OffsetLayerImpl, ctx widget.Context) {
+ if root == nil {
+ return
+ }
+ paintBoundaryRecursiveCtx(root, ctx)
+}
+
+// paintBoundaryRecursiveCtx walks the widget tree, re-recording dirty boundaries.
+func paintBoundaryRecursiveCtx(w widget.Widget, ctx widget.Context) {
+ paintBoundaryWithDepth(w, ctx, 0)
+}
+
+func paintBoundaryWithDepth(w widget.Widget, ctx widget.Context, _ int) {
+ if w == nil {
+ return
+ }
+
+ bi, isBoundary := w.(boundaryInfo)
+ if isBoundary && bi.IsRepaintBoundary() {
+ // Record only if dirty AND visible. Offscreen boundaries (outside
+ // CompositorClip viewport) are skipped: Draw never runs →
+ // ScheduleAnimationFrame never called → animation pumper stops.
+ // Scene stays dirty so it re-records when scrolled back into view.
+ if (bi.IsSceneDirty() || bi.CachedScene() == nil) && isBoundaryVisible(bi) {
+ recordBoundary(bi, ctx)
+ }
+
+ for _, child := range w.Children() {
+ paintBoundaryWithDepth(child, ctx, 0)
+ }
+ return
+ }
+
+ for _, child := range w.Children() {
+ paintBoundaryWithDepth(child, ctx, 0)
+ }
+}
+
+// recordBoundary re-records a boundary widget's scene via SceneCanvas.
+func recordBoundary(bi boundaryInfo, ctx widget.Context) {
+ // Wire onBoundaryDirty callback so animated widgets (spinner) that call
+ // SetNeedsRedraw during Draw trigger RequestRedraw for the next frame.
+ // Without this, InvalidateScene sets sceneDirty but nobody wakes the
+ // render loop → animation frozen at data ticker rate (1fps).
+ type callbackSetter interface {
+ SetOnBoundaryDirty(func())
+ }
+ if cs, ok := bi.(callbackSetter); ok && ctx != nil {
+ capturedBi := bi
+ cs.SetOnBoundaryDirty(func() {
+ bounds := capturedBi.Bounds()
+ origin := capturedBi.ScreenOrigin()
+ ctx.InvalidateRect(geometry.Rect{
+ Min: origin,
+ Max: geometry.Pt(origin.X+bounds.Width(), origin.Y+bounds.Height()),
+ })
+ })
+ }
+ bounds := bi.Bounds()
+ width := int(bounds.Width())
+ height := int(bounds.Height())
+
+ if width <= 0 || height <= 0 {
+ return
+ }
+
+ // Check size change.
+ cw, ch := bi.SceneCacheSize()
+ if cw != width || ch != height {
+ bi.SetSceneCacheSize(width, height)
+ }
+
+ cachedScene := bi.CachedScene()
+ if cachedScene == nil {
+ cachedScene = scene.NewScene()
+ }
+ cachedScene.Reset()
+
+ if widget.GetSceneRecorderFactory() == nil {
+ return
+ }
+
+ recorder, cleanup := widget.GetSceneRecorderFactory()(cachedScene, width, height)
+
+ // Propagate device scale for HiDPI-aware SVG icon rasterization (ADR-026).
+ if ctx != nil {
+ if ds, ok := recorder.(widget.DeviceScaler); ok {
+ ds.SetDeviceScale(ctx.Scale())
+ }
+ }
+
+ // Clear dirty BEFORE Draw (Flutter pattern: detect re-dirtying during Draw).
+ bi.ClearSceneDirty()
+ widget.ClearRedrawInTree(bi)
+
+ // Suppress boundary dirty callback during recording. Animated widgets
+ // (spinner) call SetNeedsRedraw inside Draw which triggers InvalidateScene.
+ // Without suppression, this fires onBoundaryDirty → ctx.InvalidateRect →
+ // immediate RequestRedraw → 60fps forced. With suppression, the widget
+ // uses ScheduleAnimationFrame for deferred render at animPumper rate.
+ // External events (hover, click) fire OUTSIDE Draw → not suppressed.
+ type dirtySuppressor interface{ SetSuppressDirtyCallback(bool) }
+ if ds, ok := bi.(dirtySuppressor); ok {
+ ds.SetSuppressDirtyCallback(true)
+ }
+
+ // Set ScreenOriginBase so StampScreenOrigin inside Draw computes correct
+ // screen-space origins for child widgets (Flutter PaintingContext.offset).
+ // Without this, nested boundaries (ScrollView → items) get ScreenOrigin
+ // relative to (0,0) instead of the boundary's actual screen position.
+ //
+ // ScreenOriginBase must compensate for the PushTransform(-bounds.Min) below.
+ // After PushTransform, TransformOffset = -bounds.Min. StampScreenOrigin
+ // computes: offset = TransformOffset + ScreenOriginBase = -bounds.Min + base.
+ // For a child at childBounds.Min, screenOrigin = offset + childBounds.Min.
+ // We want: screenOrigin = bi.ScreenOrigin() + childBounds.Min.
+ // So: base = bi.ScreenOrigin() + bounds.Min.
+ type screenBaseSetter interface{ SetScreenOriginBase(geometry.Point) }
+ if sbs, ok := recorder.(screenBaseSetter); ok {
+ sbs.SetScreenOriginBase(bi.ScreenOrigin().Add(bounds.Min))
+ }
+
+ // Record in local coordinates.
+ recorder.PushTransform(geometry.Pt(-bounds.Min.X, -bounds.Min.Y))
+ bi.Draw(ctx, recorder)
+ recorder.PopTransform()
+
+ if ds, ok := bi.(dirtySuppressor); ok {
+ ds.SetSuppressDirtyCallback(false)
+ }
+ cleanup()
+
+ bi.SetCachedScene(cachedScene)
+}
+
+// isBoundaryVisible checks whether a boundary widget is inside its compositor
+// clip rect (viewport). Boundaries without a clip (root, non-scrolled) are
+// always visible. Only boundaries with CompositorClip set by DrawChild during
+// parent recording can be culled.
+//
+// See: ADR-007 Phase 7 (per-boundary GPU textures, offscreen culling)
+// Task: TASK-UI-ADR007-PHASE7 (done)
+func isBoundaryVisible(bi boundaryInfo) bool {
+ type clipChecker interface {
+ HasCompositorClip() bool
+ CompositorClip() geometry.Rect
+ }
+ cc, ok := bi.(clipChecker)
+ if !ok || !cc.HasCompositorClip() {
+ return true
+ }
+ clip := cc.CompositorClip()
+ origin := bi.ScreenOrigin()
+ bounds := bi.Bounds()
+ screenRect := geometry.Rect{
+ Min: origin,
+ Max: geometry.Pt(origin.X+bounds.Width(), origin.Y+bounds.Height()),
+ }
+ return screenRect.Intersects(clip)
+}
diff --git a/app/layer_tree_test.go b/app/layer_tree_test.go
new file mode 100644
index 0000000..8e6ee0c
--- /dev/null
+++ b/app/layer_tree_test.go
@@ -0,0 +1,148 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/ui/compositor"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+)
+
+// testContainer has children accessible via Children().
+type testContainer struct {
+ widget.WidgetBase
+ kids []widget.Widget
+}
+
+func (w *testContainer) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 600))
+}
+func (w *testContainer) Draw(_ widget.Context, canvas widget.Canvas) {
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(200, 200, 200, 255))
+ for _, child := range w.kids {
+ widget.DrawChild(child, nil, canvas)
+ }
+}
+func (w *testContainer) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *testContainer) Children() []widget.Widget { return w.kids }
+
+// testLeaf is a leaf widget with boundary support.
+type testLeaf struct {
+ widget.WidgetBase
+ drawCount int
+}
+
+func (w *testLeaf) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(48, 48))
+}
+func (w *testLeaf) Draw(_ widget.Context, canvas widget.Canvas) {
+ w.drawCount++
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(255, 0, 0, 255))
+}
+func (w *testLeaf) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *testLeaf) Children() []widget.Widget { return nil }
+
+// TestPaintBoundaryLayers_FindsNestedBoundary verifies that
+// PaintBoundaryLayers walks through non-boundary containers
+// and reaches nested boundary widgets (spinner inside collapsible).
+func TestPaintBoundaryLayers_FindsNestedBoundary(t *testing.T) {
+ prev := widget.GetSceneRecorderFactory()
+ widget.RegisterSceneRecorder(testSceneRecorder)
+ defer widget.RegisterSceneRecorder(prev)
+
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ mid := &testContainer{}
+ mid.SetVisible(true)
+ mid.SetBounds(geometry.NewRect(0, 100, 800, 500))
+ root.kids = append(root.kids, mid)
+
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 200, 148, 248))
+ spinner.SetScreenOrigin(geometry.Pt(100, 200))
+ mid.kids = append(mid.kids, spinner)
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ if root.CachedScene() == nil {
+ t.Error("root boundary should have cached scene after PaintBoundaryLayers")
+ }
+ if spinner.CachedScene() == nil {
+ t.Error("spinner boundary should have cached scene — PaintBoundaryLayers " +
+ "must traverse non-boundary containers to reach nested boundaries")
+ }
+ if spinner.drawCount == 0 {
+ t.Error("spinner.Draw should have been called during recording")
+ }
+}
+
+// TestBuildLayerTree_NestedOffset verifies accumulated offset computation.
+func TestBuildLayerTree_NestedOffset(t *testing.T) {
+ root := &testContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ mid := &testContainer{}
+ mid.SetVisible(true)
+ mid.SetBounds(geometry.NewRect(0, 100, 800, 500))
+ root.kids = append(root.kids, mid)
+
+ spinner := &testLeaf{}
+ spinner.SetVisible(true)
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(50, 200, 98, 248))
+ mid.kids = append(mid.kids, spinner)
+
+ layer := BuildLayerTree(root)
+
+ // Root OffsetLayer(0,0) has children:
+ // [0] = root's own OffsetLayer(0,0) with PictureLayer (root boundary)
+ // Root OffsetLayer → root boundary OffsetLayer → [PictureLayer, spinner OffsetLayer]
+ children := layer.Children()
+ t.Logf("root layer children: %d", len(children))
+ for i, ch := range children {
+ t.Logf(" child[%d]: %T, offset=%v, children=%d",
+ i, ch, ch.Offset(), len(ch.(compositor.ContainerLayer).Children()))
+ }
+
+ if len(children) == 0 {
+ t.Fatal("root layer should have children")
+ }
+
+ // Root boundary is first child OffsetLayer. It should contain spinner.
+ rootBoundary, ok := children[0].(compositor.ContainerLayer)
+ if !ok {
+ t.Fatal("first child should be ContainerLayer (root boundary OffsetLayer)")
+ }
+ rootBoundaryChildren := rootBoundary.Children()
+ t.Logf("root boundary children: %d", len(rootBoundaryChildren))
+
+ // Should have PictureLayer + spinner OffsetLayer
+ foundSpinner := false
+ for _, rbc := range rootBoundaryChildren {
+ if cl, ok2 := rbc.(compositor.ContainerLayer); ok2 && len(cl.Children()) > 0 {
+ foundSpinner = true
+ }
+ }
+ if !foundSpinner && len(rootBoundaryChildren) < 2 {
+ t.Error("root boundary should have spinner as nested layer")
+ }
+
+ // Check spinner offset: mid.Bounds.Min(0,100) + spinner.Bounds.Min(50,200) = (50,300)
+ for _, rbc := range rootBoundaryChildren {
+ if cl, ok2 := rbc.(compositor.ContainerLayer); ok2 {
+ t.Logf("spinner OffsetLayer offset: %v", cl.Offset())
+ }
+ }
+}
diff --git a/app/scene_recorder.go b/app/scene_recorder.go
new file mode 100644
index 0000000..2d9a82a
--- /dev/null
+++ b/app/scene_recorder.go
@@ -0,0 +1,19 @@
+package app
+
+import (
+ "github.com/gogpu/gg/scene"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/widget"
+)
+
+func init() {
+ // 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)
+ return recorder, recorder.Close
+ })
+}
diff --git a/app/screen_origin_test.go b/app/screen_origin_test.go
new file mode 100644
index 0000000..12d0ecb
--- /dev/null
+++ b/app/screen_origin_test.go
@@ -0,0 +1,147 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/widget"
+)
+
+// TestRecordBoundary_ScreenOriginBase verifies that recordBoundary sets
+// ScreenOriginBase on the recorder canvas so that nested StampScreenOrigin
+// calls produce correct screen-space origins.
+//
+// Without ScreenOriginBase: children get ScreenOrigin relative to (0,0)
+// instead of relative to the boundary's screen position → items render
+// at window top-left corner.
+func TestRecordBoundary_ScreenOriginBase(t *testing.T) {
+ prev := widget.GetSceneRecorderFactory()
+ widget.RegisterSceneRecorder(testSceneRecorder)
+ defer widget.RegisterSceneRecorder(prev)
+
+ // Root boundary at screen position (0,0).
+ root := &screenOriginContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ // ScrollView boundary inside root at position (56, 481).
+ // Bounds are relative to parent (root), so Min = (56, 481).
+ scrollView := &screenOriginContainer{}
+ scrollView.SetVisible(true)
+ scrollView.SetRepaintBoundary(true)
+ scrollView.SetBounds(geometry.NewRect(56, 481, 672, 300))
+ scrollView.SetParent(root)
+ root.kids = []widget.Widget{scrollView}
+
+ // Item inside scrollView at local position (0, 100).
+ item := &screenOriginLeaf{}
+ item.SetVisible(true)
+ item.SetRepaintBoundary(true)
+ item.SetBounds(geometry.NewRect(0, 100, 672, 32))
+ item.SetParent(scrollView)
+ scrollView.kids = []widget.Widget{item}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ // Record ScrollView boundary. This should set ScreenOriginBase on
+ // the recorder so StampScreenOrigin inside Draw computes correct values.
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Item's ScreenOrigin should be ScrollView's screen pos + item's local pos.
+ // ScrollView screen = (56, 481), item local = (0, 100) → item screen = (56, 581).
+ gotOrigin := item.ScreenOrigin()
+ wantOrigin := geometry.Pt(56, 581)
+
+ if gotOrigin != wantOrigin {
+ t.Errorf("item ScreenOrigin = %v, want %v\n"+
+ " scrollView.ScreenOrigin = %v\n"+
+ " item.Bounds.Min = %v\n"+
+ " If (0, 100): ScreenOriginBase not set on recorder canvas",
+ gotOrigin, wantOrigin,
+ scrollView.ScreenOrigin(),
+ item.Bounds().Min,
+ )
+ }
+}
+
+// TestRecordBoundary_RootScreenOriginBase verifies that root boundary
+// (ScreenOrigin=0,0) produces correct child origins.
+func TestRecordBoundary_RootScreenOriginBase(t *testing.T) {
+ prev := widget.GetSceneRecorderFactory()
+ widget.RegisterSceneRecorder(testSceneRecorder)
+ defer widget.RegisterSceneRecorder(prev)
+
+ root := &screenOriginContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ child := &screenOriginLeaf{}
+ child.SetVisible(true)
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(100, 200, 200, 48))
+ child.SetParent(root)
+ root.kids = []widget.Widget{child}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ gotOrigin := child.ScreenOrigin()
+ wantOrigin := geometry.Pt(100, 200)
+
+ if gotOrigin != wantOrigin {
+ t.Errorf("child ScreenOrigin = %v, want %v", gotOrigin, wantOrigin)
+ }
+}
+
+// --- test helpers ---
+
+type screenOriginContainer struct {
+ widget.WidgetBase
+ kids []widget.Widget
+}
+
+func (w *screenOriginContainer) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 600))
+}
+
+func (w *screenOriginContainer) Draw(ctx widget.Context, canvas widget.Canvas) {
+ for _, child := range w.kids {
+ widget.StampScreenOrigin(child, canvas)
+ widget.DrawChild(child, ctx, canvas)
+ }
+}
+
+func (w *screenOriginContainer) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *screenOriginContainer) Children() []widget.Widget { return w.kids }
+
+type screenOriginLeaf struct {
+ widget.WidgetBase
+}
+
+func (w *screenOriginLeaf) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(672, 32))
+}
+
+func (w *screenOriginLeaf) Draw(_ widget.Context, canvas widget.Canvas) {
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(100, 100, 100, 255))
+}
+
+func (w *screenOriginLeaf) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *screenOriginLeaf) Children() []widget.Widget { return nil }
+
+// 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)
+ return rec, rec.Close
+}
diff --git a/app/spinner_e2e_test.go b/app/spinner_e2e_test.go
new file mode 100644
index 0000000..9b63341
--- /dev/null
+++ b/app/spinner_e2e_test.go
@@ -0,0 +1,133 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/compositor"
+ "github.com/gogpu/ui/core/progress"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/widget"
+)
+
+// boxContainer is a test container that draws children via DrawChild.
+type boxContainer struct {
+ widget.WidgetBase
+ kids []widget.Widget
+}
+
+func (w *boxContainer) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 600))
+}
+
+func (w *boxContainer) Draw(ctx widget.Context, canvas widget.Canvas) {
+ canvas.DrawRect(w.Bounds(), widget.RGBA8(240, 240, 240, 255))
+ for _, child := range w.kids {
+ widget.DrawChild(child, ctx, canvas)
+ }
+}
+
+func (w *boxContainer) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *boxContainer) Children() []widget.Widget { return w.kids }
+
+// TestSpinnerE2E_VisibleInCompositor is the definitive end-to-end test.
+// Uses REAL progress.Widget (not mock) with REAL SceneCanvas recording.
+// 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)
+ return rec, rec.Close
+ })
+ defer widget.RegisterSceneRecorder(prev)
+
+ // Build: root(boundary) → container → spinner(boundary)
+ root := &boxContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetScreenOrigin(geometry.Pt(0, 0))
+
+ container := &boxContainer{}
+ container.SetVisible(true)
+ container.SetBounds(geometry.NewRect(0, 200, 800, 400))
+ root.kids = []widget.Widget{container}
+
+ spinner := progress.New(progress.Indeterminate(true), progress.Size(48))
+ spinner.SetBounds(geometry.NewRect(100, 10, 148, 58))
+ spinner.SetScreenOrigin(geometry.Pt(100, 10))
+ spinner.SetParent(container)
+ container.kids = []widget.Widget{spinner}
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ // Step 1: PaintBoundaryLayers
+ PaintBoundaryLayersWithContext(root, nil, ctx)
+
+ // Verify root scene recorded.
+ if root.CachedScene() == nil || root.CachedScene().IsEmpty() {
+ t.Fatal("root CachedScene is nil/empty after PaintBoundaryLayers")
+ }
+ t.Logf("root scene empty=%v", root.CachedScene().IsEmpty())
+
+ // Verify spinner scene recorded.
+ if spinner.CachedScene() == nil {
+ t.Fatal("spinner CachedScene is nil — PaintBoundaryLayers didn't reach spinner")
+ }
+ if spinner.CachedScene().IsEmpty() {
+ t.Fatal("spinner CachedScene is empty — spinner.Draw didn't produce scene content")
+ }
+ t.Logf("spinner scene empty=%v", spinner.CachedScene().IsEmpty())
+
+ // Step 2: BuildLayerTree
+ layerTree := BuildLayerTree(root)
+ t.Logf("layer tree root children: %d", len(layerTree.Children()))
+
+ // Walk layer tree and count PictureLayers with non-empty scenes.
+ nonEmptyPictures := 0
+ var walkLayers func(compositor.Layer, string)
+ walkLayers = func(l compositor.Layer, indent string) {
+ if po, ok := l.(compositor.PictureOwner); ok {
+ pic := po.Picture()
+ empty := pic == nil || pic.IsEmpty()
+ t.Logf("%sPictureLayer: empty=%v", indent, empty)
+ if !empty {
+ nonEmptyPictures++
+ }
+ }
+ if cl, ok := l.(compositor.ContainerLayer); ok {
+ t.Logf("%sContainerLayer: offset=%v children=%d", indent, l.Offset(), len(cl.Children()))
+ for _, child := range cl.Children() {
+ walkLayers(child, indent+" ")
+ }
+ }
+ }
+ walkLayers(layerTree, "")
+
+ if nonEmptyPictures < 2 {
+ t.Errorf("expected >= 2 non-empty PictureLayers (root + spinner), got %d", nonEmptyPictures)
+ }
+
+ // Step 3: Compositor.Compose
+ comp := compositor.New()
+ composed := comp.Compose(layerTree)
+
+ if composed.IsEmpty() {
+ t.Fatal("composed scene is EMPTY — spinner invisible in final output")
+ }
+ bounds := composed.Bounds()
+ t.Logf("composed scene: empty=%v, version=%d, bounds=(%f,%f)-(%f,%f)",
+ composed.IsEmpty(), composed.Version(),
+ bounds.MinX, bounds.MinY, bounds.MaxX, bounds.MaxY)
+
+ // Bounds must extend to spinner area (100+48=148, 210+48=258).
+ if bounds.MaxX < 140 {
+ t.Errorf("composed bounds.MaxX=%f, want >= 148 (spinner at X=100, width=48)", bounds.MaxX)
+ }
+ if bounds.MaxY < 250 {
+ t.Errorf("composed bounds.MaxY=%f, want >= 258 (spinner at Y=210, height=48)", bounds.MaxY)
+ }
+}
diff --git a/app/window.go b/app/window.go
index 6ae053f..c88d41e 100644
--- a/app/window.go
+++ b/app/window.go
@@ -213,6 +213,18 @@ func newWindow(
}
})
+ // Wire animation frame scheduling (Flutter scheduleFrame pattern).
+ // Animated widgets call ScheduleAnimationFrame() instead of InvalidateRect()
+ // to avoid triggering immediate RequestRedraw. This keeps the animPumper
+ // alive without forcing a render on every call. The animPumper ticks at
+ // its configured rate (30fps default) and triggers renders.
+ ctx.SetOnScheduleAnimation(func() {
+ w.animIdleFrames = 0
+ if w.animToken == nil && w.wp != nil {
+ w.animToken = newAnimPumper(w.wp)
+ }
+ })
+
// Wire scheduler to wake render loop when signals change.
// Signal dirty = visual content changed (redraw only).
// Layout is NOT needed — widget size/position unchanged.
@@ -248,6 +260,17 @@ func (w *Window) SetRoot(root widget.Widget) {
}
w.root = root
+
+ // ADR-007 Phase 5: Root IS boundary (Flutter RenderView.isRepaintBoundary).
+ // DrawChild skips child boundaries during recording (BoundaryRecorder).
+ // Compositor Layer Tree assembles all boundary scenes by reference.
+ type boundaryEnabler interface {
+ SetRepaintBoundary(bool)
+ }
+ if be, ok := root.(boundaryEnabler); ok {
+ be.SetRepaintBoundary(true)
+ }
+
w.needsLayout = true
w.needsRedraw = true
w.needsFullRepaint = true
@@ -346,9 +369,6 @@ func (w *Window) HandleEvent(e event.Event) {
// Sync cursor immediately after event dispatch so hover cursor
// changes are visible without waiting for the next Frame() tick.
- // In event-driven mode (ContinuousRender=false), Frame() only
- // runs when a redraw is needed, but cursor changes from hover
- // don't trigger redraws.
if w.pp != nil {
w.syncCursor()
}
@@ -420,12 +440,10 @@ func (w *Window) Frame() {
// Begin frame timing. DeltaTime = time since last BeginFrame.
w.ctx.BeginFrame(frameStart)
- // Reset cursor for this frame — but not during drag operations.
- // During drag, the dragging widget (SplitView, Slider) sets cursor
- // on every MouseMove; resetting here would flash default between frames.
- if w.mouseButtonsHeld == 0 {
- w.ctx.ResetCursor()
- }
+ // Cursor is managed in Event handlers (updateHover resets on target
+ // change, widgets set Pointer in MouseEnter/Default in MouseLeave).
+ // No ResetCursor here — Frame runs after Event and would overwrite
+ // the cursor set by the widget, causing flash on next syncCursor.
// Flush pending signal changes (may trigger new dirty marks).
// The scheduler's flushFn sets persistent needsRedraw flags on widgets.
@@ -567,6 +585,21 @@ func (w *Window) DirtyRegionCount() int {
return w.dirtyTracker.RegionCount()
}
+// DirtyRegions returns the list of dirty widget regions from the most
+// recent DrawTo call. Each region corresponds to a widget (or group of
+// nearby widgets) that needed redraw.
+//
+// Used by desktop.Run for debug overlay (GOGPU_DEBUG_DIRTY=1) and for
+// passing damage rects to the OS compositor (SetDamageRects).
+func (w *Window) DirtyRegions() []geometry.Rect {
+ regions := w.dirtyTracker.DirtyRegions()
+ rects := make([]geometry.Rect, len(regions))
+ for i, r := range regions {
+ rects[i] = r.Bounds
+ }
+ return rects
+}
+
// LastDirtyUnion returns the union of all dirty regions from the most
// recent dirty-region-only repaint. Returns a zero Rect when the last
// frame was a full repaint or a frame skip.
@@ -995,6 +1028,11 @@ func (w *Window) updateHover(pos geometry.Point, buttons event.ButtonState, mods
return
}
+ // Hover target changed — reset cursor to Default.
+ // Old widget's MouseLeave will set Default, new widget's MouseEnter
+ // will set Pointer if it's interactive.
+ w.ctx.ResetCursor()
+
// Send MouseLeave to the old hovered widget.
if w.hoveredWidget != nil {
leave := event.NewMouseEvent(
@@ -1167,6 +1205,44 @@ func (w *Window) PaintDirtyBoundaries() {
w.ClearDirtyBoundaries()
}
+// CollectDirtyRegions runs the dirty collector on the widget tree to populate
+// the dirty tracker. Called by the compositor path in desktop.draw before
+// painting, so debug overlays and damage rects have correct data.
+//
+// In the DrawTo path, this is called internally at the start of DrawTo.
+// The compositor path must call it explicitly since it bypasses DrawTo.
+func (w *Window) CollectDirtyRegions() {
+ if w.root == nil {
+ return
+ }
+ w.dirtyTracker.Reset()
+ w.dirtyCollector.Collect(w.root)
+ w.dirtyTracker.Optimize()
+}
+
+// ClearAfterPaint clears dirty flags and frame state after a paint pass.
+// Called by the compositor path in desktop.draw after PaintBoundaryLayers
+// and overlay drawing are complete.
+//
+// Flutter equivalent: flags are cleared at the end of flushPaint and
+// after compositeFrame. We consolidate cleanup into one call.
+func (w *Window) ClearAfterPaint() {
+ // Do NOT call ClearRedrawInTree here. The paint pass (recordBoundary)
+ // clears dirty flags BEFORE each boundary's Draw, so widgets that
+ // re-dirty during Draw (spinner animation) keep their needsRedraw=true.
+ // ClearRedrawInTree here would erase that re-dirty → spinner not found
+ // by CollectDirtyRegions next frame → cyan overlay empty.
+ w.needsRedraw = false
+ w.needsFullRepaint = false
+}
+
+// DrawOverlays draws overlay widgets (dropdowns, dialogs) on the given canvas.
+// In Flutter, overlays are part of the same widget tree. In our architecture,
+// they are managed separately by overlay.Stack and drawn after the main scene.
+func (w *Window) DrawOverlays(canvas widget.Canvas) {
+ w.overlays.Draw(w.ctx, canvas)
+}
+
// BoundaryDamageRegion computes the union of screen bounds of all dirty
// RepaintBoundary instances. This provides a tighter damage region for
// the compositor when only specific boundaries changed (ADR-007 Phase 3,
@@ -1229,16 +1305,29 @@ func (w *Window) HasDirtyBoundariesOrNeedsRedraw() bool {
return w.HasDirtyBoundaries() || w.needsRedraw || w.needsFullRepaint
}
-// animPumper pumps frames at ~60fps for smooth animation.
-// Stopped when animation completes.
+// animPumper pumps frames at a configurable rate for smooth animation.
+// Default 30fps (33ms) — sufficient for spinners and progress indicators.
+// GPU cost scales linearly with frame rate (~0.17%/frame on Intel Iris Xe).
+// 60fps for high-fidelity animations (transitions, physics).
+// Stopped when animation completes (3 consecutive idle frames).
type animPumper struct {
stop chan struct{}
}
+// defaultAnimPumpInterval controls the animation frame pump rate.
+// 33ms ≈ 30fps — visually smooth for indeterminate spinners and progress
+// indicators. Saves ~50% GPU vs 60fps with no perceptible quality loss.
+// Enterprise reference: Qt uses QTimer intervals, Ebiten uses SetTPS.
+const defaultAnimPumpInterval = 33 * time.Millisecond // 30fps
+
func newAnimPumper(wp gpucontext.WindowProvider) *animPumper {
+ return newAnimPumperWithInterval(wp, defaultAnimPumpInterval)
+}
+
+func newAnimPumperWithInterval(wp gpucontext.WindowProvider, interval time.Duration) *animPumper {
p := &animPumper{stop: make(chan struct{})}
go func() {
- ticker := time.NewTicker(16 * time.Millisecond) // ~60fps
+ ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
diff --git a/app/window_draw_test.go b/app/window_draw_test.go
index a64f937..70d2e0e 100644
--- a/app/window_draw_test.go
+++ b/app/window_draw_test.go
@@ -47,6 +47,7 @@ func (c *recordingCanvas) PopClip() { c.popC
func (c *recordingCanvas) PushTransform(geometry.Point) {}
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) {}
@@ -125,6 +126,7 @@ func TestDrawTo_CleanTreeFullRepaint(t *testing.T) {
w := a.Window()
root := newDrawTrackingWidget(geometry.NewRect(0, 0, 100, 50))
w.SetRoot(root)
+ root.SetRepaintBoundary(false)
canvas := &recordingCanvas{}
@@ -161,6 +163,7 @@ func TestDrawTo_SignalChange(t *testing.T) {
w := a.Window()
root := newDrawTrackingWidget(geometry.NewRect(0, 0, 200, 100))
w.SetRoot(root)
+ root.SetRepaintBoundary(false)
canvas := &recordingCanvas{}
w.DrawTo(canvas)
@@ -582,6 +585,7 @@ func TestDrawTo_HostManaged_AlwaysDraws(t *testing.T) {
w := a.Window()
root := newDrawTrackingWidget(geometry.NewRect(0, 0, 200, 100))
w.SetRoot(root)
+ root.SetRepaintBoundary(false)
canvas := &recordingCanvas{}
w.DrawTo(canvas) // First draw.
diff --git a/app/window_test.go b/app/window_test.go
index d26aa0e..6a44cdb 100644
--- a/app/window_test.go
+++ b/app/window_test.go
@@ -456,6 +456,7 @@ func (m *mockCanvas) PopClip() {}
func (m *mockCanvas) PushTransform(geometry.Point) {}
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) {}
@@ -1760,3 +1761,109 @@ func (m *mockBoundaryWithScreenBounds) Bounds() geometry.Rect {
func (m *mockBoundaryWithScreenBounds) ScreenBounds() geometry.Rect {
return m.screenBounds
}
+
+// --- Cursor regression tests (2026-05-07) ---
+
+// TestCursorNotResetByFrame verifies that Frame() does not clobber a cursor
+// set during event handling. Before the fix, ResetCursor was called in Frame()
+// after layout, which overwrote CursorPointer set by a widget's Event handler.
+// Regression: ResetCursor in Frame() erased cursor set by Event handler (2026-05-07)
+func TestCursorNotResetByFrame(t *testing.T) {
+ pp := &mockPlatformProvider{fontScale: 1.0}
+ a := New(WithPlatformProvider(pp))
+ w := a.Window()
+
+ // Use a widget that sets CursorPointer during its Event handler.
+ cw := &cursorSettingWidget{cursor: widget.CursorPointer}
+ cw.SetVisible(true)
+ cw.SetEnabled(true)
+ w.SetRoot(cw)
+
+ // Simulate an event that causes the widget to set CursorPointer.
+ me := event.NewMouseEvent(
+ event.MousePress,
+ event.ButtonLeft,
+ event.ButtonStateLeft,
+ geometry.Pt(50, 25),
+ geometry.Pt(50, 25),
+ event.ModNone,
+ )
+ w.HandleEvent(me)
+
+ // Verify cursor was set.
+ if w.Context().Cursor() != widget.CursorPointer {
+ t.Fatal("precondition: cursor should be Pointer after event")
+ }
+
+ // Call Frame() — this must NOT reset the cursor back to Default.
+ w.Frame()
+
+ if w.Context().Cursor() != widget.CursorPointer {
+ t.Errorf("cursor = %v after Frame(), want CursorPointer; "+
+ "Frame() must not clobber cursor set during event handling",
+ w.Context().Cursor())
+ }
+}
+
+// TestCursorResetOnHoverChange verifies that when the hover target changes
+// (e.g., mouse moves from an interactive widget to empty space), the cursor
+// is reset to Default. Without this, cursor remained as Pointer on
+// non-interactive areas after leaving a button.
+// Regression: cursor stuck as Pointer after leaving interactive widget (2026-05-07)
+func TestCursorResetOnHoverChange(t *testing.T) {
+ pp := &mockPlatformProvider{fontScale: 1.0}
+ a := New(WithPlatformProvider(pp))
+ w := a.Window()
+
+ // cursorWidget sets Pointer on MouseEnter.
+ btn := &hoverCursorWidget{cursor: widget.CursorPointer}
+ btn.SetVisible(true)
+ btn.SetEnabled(true)
+ btn.SetBounds(geometry.NewRect(10, 10, 100, 40))
+ btn.SetScreenOrigin(geometry.Pt(10, 10))
+
+ root := newHoverContainer(btn)
+ w.SetRoot(root)
+
+ // Move mouse into the button — cursor should become Pointer.
+ w.HandleEvent(event.NewMouseEvent(
+ event.MouseMove, event.ButtonNone, 0,
+ geometry.Pt(50, 25), geometry.Pt(50, 25), event.ModNone,
+ ))
+
+ if w.Context().Cursor() != widget.CursorPointer {
+ t.Fatal("precondition: cursor should be Pointer when over button")
+ }
+
+ // Move mouse away from button to empty area.
+ w.HandleEvent(event.NewMouseEvent(
+ event.MouseMove, event.ButtonNone, 0,
+ geometry.Pt(400, 400), geometry.Pt(400, 400), event.ModNone,
+ ))
+
+ if w.Context().Cursor() != widget.CursorDefault {
+ t.Errorf("cursor = %v after leaving button, want CursorDefault; "+
+ "updateHover must reset cursor when hover target changes",
+ w.Context().Cursor())
+ }
+}
+
+// hoverCursorWidget sets a cursor on MouseEnter.
+type hoverCursorWidget struct {
+ widget.WidgetBase
+ cursor widget.CursorType
+}
+
+func (w *hoverCursorWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(100, 40))
+}
+
+func (w *hoverCursorWidget) Draw(_ widget.Context, _ widget.Canvas) {}
+
+func (w *hoverCursorWidget) Event(ctx widget.Context, e event.Event) bool {
+ if me, ok := e.(*event.MouseEvent); ok && me.MouseType == event.MouseEnter {
+ ctx.SetCursor(w.cursor)
+ return true
+ }
+ return false
+}
diff --git a/compositor/compositor.go b/compositor/compositor.go
new file mode 100644
index 0000000..20f87ca
--- /dev/null
+++ b/compositor/compositor.go
@@ -0,0 +1,91 @@
+package compositor
+
+import (
+ "github.com/gogpu/gg/scene"
+)
+
+// Compositor assembles a layer tree into a composed scene.Scene by walking
+// layers and appending their content by REFERENCE (AppendWithTranslation),
+// not by copying the entire encoding into a flat scene.
+//
+// NOT IN PRODUCTION PIPELINE: the production render loop (desktop.draw)
+// uses direct per-boundary GPU textures instead. Compositor is retained
+// for future use with animated transforms and opacity layers.
+//
+// Flutter equivalent: SceneBuilder in compositeFrame().
+type Compositor struct {
+ composed *scene.Scene
+}
+
+// New creates a new Compositor.
+func New() *Compositor {
+ return &Compositor{
+ composed: scene.NewScene(),
+ }
+}
+
+// Compose walks the layer tree rooted at root and builds a composed
+// scene.Scene by appending each PictureLayer's scene at its accumulated
+// offset. The composed scene is returned for rendering.
+//
+// This is called every frame. The cost is O(layers), not O(draw_commands).
+// For 10 boundaries: 10 AppendWithTranslation calls. The actual scene
+// data is not re-recorded — only references are assembled.
+//
+// Flutter equivalent: compositeFrame() → SceneBuilder.addRetained().
+func (c *Compositor) Compose(root Layer) *scene.Scene {
+ c.composed.Reset()
+ c.composeLayer(root, 0, 0)
+ return c.composed
+}
+
+// composeLayer recursively walks the layer tree, accumulating offsets
+// and appending PictureLayer scenes into the composed scene.
+func (c *Compositor) composeLayer(layer Layer, parentX, parentY float32) {
+ if layer == nil {
+ return
+ }
+
+ offset := layer.Offset()
+ x := parentX + offset.X
+ y := parentY + offset.Y
+
+ // 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)
+ }
+ layer.ClearNeedsCompositing()
+ return
+ }
+
+ // ClipRectLayer: push clip, recurse, pop.
+ if cl, ok := layer.(*ClipRectLayerImpl); ok {
+ // TODO: push clip into composed scene when scene.Scene supports clip commands.
+ // For now, recurse without clip (clip handled by widget-level PushClip).
+ _ = cl.ClipRect()
+ if container, ok2 := layer.(ContainerLayer); ok2 {
+ for _, child := range container.Children() {
+ c.composeLayer(child, x, y)
+ }
+ }
+ layer.ClearNeedsCompositing()
+ return
+ }
+
+ // ContainerLayer / OffsetLayer: recurse into children.
+ if container, ok := layer.(ContainerLayer); ok {
+ for _, child := range container.Children() {
+ c.composeLayer(child, x, y)
+ }
+ }
+
+ layer.ClearNeedsCompositing()
+}
+
+// ComposedScene returns the last composed scene without re-composing.
+// Returns nil if Compose has not been called.
+func (c *Compositor) ComposedScene() *scene.Scene {
+ return c.composed
+}
diff --git a/compositor/compositor_test.go b/compositor/compositor_test.go
new file mode 100644
index 0000000..4dea9a6
--- /dev/null
+++ b/compositor/compositor_test.go
@@ -0,0 +1,372 @@
+package compositor
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg"
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/geometry"
+)
+
+var (
+ red = scene.SolidBrush(gg.RGBA{R: 1, A: 1})
+ blue = scene.SolidBrush(gg.RGBA{B: 1, A: 1})
+ gray = scene.SolidBrush(gg.RGBA{R: 0.5, G: 0.5, B: 0.5, A: 1})
+)
+
+func rectScene(brush scene.Brush, w, h float32) *scene.Scene {
+ s := scene.NewScene()
+ s.Fill(scene.FillNonZero, scene.IdentityAffine(), brush,
+ scene.NewRectShape(0, 0, w, h))
+ return s
+}
+
+// --- Bug prevention: composition by reference ---
+
+// TestCompose_ChildReRecord_ParentSeesUpdate catches the root cause of
+// the spinner freeze bug: Scene.Append COPIES data. If Compose cached
+// or reused the old composed scene without re-walking, child updates
+// would be invisible. This test fails if Compose returns stale content.
+func TestCompose_ChildReRecord_ParentSeesUpdate(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ staticPic := NewPictureLayer()
+ staticPic.SetPicture(rectScene(gray, 800, 40))
+ root.Append(staticPic)
+
+ spinnerPic := NewPictureLayer()
+ spinnerPic.SetPicture(rectScene(red, 48, 48))
+ spinnerPic.SetOffset(geometry.Pt(100, 200))
+ root.Append(spinnerPic)
+
+ v1 := c.Compose(root).Version()
+
+ // Spinner re-records (next animation frame).
+ spinnerPic.SetPicture(rectScene(blue, 48, 48))
+
+ v2 := c.Compose(root).Version()
+
+ if v2 <= v1 {
+ t.Errorf("Compose after child re-record: version v2=%d <= v1=%d; "+
+ "composed scene must be rebuilt, not cached", v2, v1)
+ }
+}
+
+// TestCompose_10ConsecutiveFrames simulates 10 animation frames where
+// spinner re-records each time. Every frame must produce a NEW composed
+// scene. If any two consecutive frames have equal versions, animation
+// is frozen (the bug we're fixing).
+func TestCompose_10ConsecutiveFrames(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ staticPic := NewPictureLayer()
+ staticPic.SetPicture(rectScene(gray, 800, 600))
+ root.Append(staticPic)
+
+ spinnerPic := NewPictureLayer()
+ spinnerPic.SetOffset(geometry.Pt(400, 300))
+ root.Append(spinnerPic)
+
+ var prevVersion uint64
+ for frame := 0; frame < 10; frame++ {
+ spinnerPic.SetPicture(rectScene(red, 48, 48))
+ composed := c.Compose(root)
+ v := composed.Version()
+
+ if frame > 0 && v <= prevVersion {
+ t.Fatalf("frame %d: version %d <= previous %d; animation frozen", frame, v, prevVersion)
+ }
+ if composed.IsEmpty() {
+ t.Fatalf("frame %d: composed scene is empty", frame)
+ }
+ prevVersion = v
+ }
+}
+
+// TestCompose_StaticLayerNotReRecorded verifies that static content
+// is NOT re-recorded during compose. Only spinner re-records.
+// If static picture pointer changes, we're doing unnecessary work.
+func TestCompose_StaticLayerNotReRecorded(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ staticScene := rectScene(gray, 800, 600)
+ staticPic := NewPictureLayer()
+ staticPic.SetPicture(staticScene)
+ root.Append(staticPic)
+
+ spinnerPic := NewPictureLayer()
+ spinnerPic.SetPicture(rectScene(red, 48, 48))
+ root.Append(spinnerPic)
+
+ c.Compose(root)
+
+ // After compose, static picture must still be the same object.
+ if staticPic.Picture() != staticScene {
+ t.Error("Compose must not replace static PictureLayer's scene; " +
+ "only dirty layers should be re-recorded by PaintDirtyBoundaries, " +
+ "not by the compositor")
+ }
+}
+
+// --- Bug prevention: re-parenting ---
+
+// TestReparent_ChildMovedBetweenParents prevents a bug where moving a
+// child from parent A to parent B leaves a dangling reference in A.
+func TestReparent_ChildMovedBetweenParents(t *testing.T) {
+ parentA := NewOffsetLayer(geometry.Point{})
+ parentB := NewOffsetLayer(geometry.Point{})
+ child := NewPictureLayer()
+
+ parentA.Append(child)
+ if child.Parent() != parentA {
+ t.Fatal("child should belong to parentA")
+ }
+
+ parentA.Remove(child)
+ parentB.Append(child)
+
+ if child.Parent() != parentB {
+ t.Error("child.Parent() should be parentB after re-parenting")
+ }
+ if len(parentA.Children()) != 0 {
+ t.Error("parentA should have 0 children after child was removed")
+ }
+ if len(parentB.Children()) != 1 {
+ t.Error("parentB should have 1 child")
+ }
+
+ // Compose should only include child in parentB, not parentA.
+ c := New()
+ child.SetPicture(rectScene(red, 10, 10))
+ child.SetOffset(geometry.Pt(50, 50))
+
+ rootA := NewOffsetLayer(geometry.Point{})
+ rootA.Append(parentA)
+ resultA := c.Compose(rootA)
+ if !resultA.IsEmpty() {
+ t.Error("parentA tree should produce empty scene (child was removed)")
+ }
+
+ rootB := NewOffsetLayer(geometry.Point{})
+ rootB.Append(parentB)
+ resultB := c.Compose(rootB)
+ if resultB.IsEmpty() {
+ t.Error("parentB tree should produce non-empty scene (child present)")
+ }
+}
+
+// --- Bug prevention: nil/empty handling ---
+
+// TestCompose_NilPicture prevents crash when PictureLayer has no scene.
+func TestCompose_NilPicture(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ pic := NewPictureLayer()
+ // Intentionally do NOT set a picture.
+ root.Append(pic)
+
+ result := c.Compose(root)
+ if result == nil {
+ t.Fatal("Compose should not return nil even with nil pictures")
+ }
+}
+
+// TestCompose_EmptyScene prevents composed scene from containing
+// garbage when picture's scene was Reset but not re-filled.
+func TestCompose_EmptyScene(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ pic := NewPictureLayer()
+ s := scene.NewScene()
+ s.Reset() // empty scene
+ pic.SetPicture(s)
+ root.Append(pic)
+
+ result := c.Compose(root)
+ if !result.IsEmpty() {
+ t.Error("composed scene should be empty when all pictures are empty")
+ }
+}
+
+// TestCompose_NilRoot prevents crash on nil root.
+func TestCompose_NilRoot(t *testing.T) {
+ c := New()
+ result := c.Compose(nil)
+ if result == nil {
+ t.Fatal("Compose(nil) must return empty scene, not nil")
+ }
+}
+
+// --- Bug prevention: offset accumulation ---
+
+// TestCompose_OffsetAccumulation verifies exact pixel positions through
+// 3 levels of nesting. A bug in offset accumulation shifts ALL content.
+func TestCompose_OffsetAccumulation(t *testing.T) {
+ c := New()
+
+ root := NewOffsetLayer(geometry.Pt(10, 20))
+ mid := NewOffsetLayer(geometry.Pt(30, 40))
+ root.Append(mid)
+
+ pic := NewPictureLayer()
+ pic.SetPicture(rectScene(red, 50, 50))
+ pic.SetOffset(geometry.Pt(5, 5))
+ mid.Append(pic)
+
+ result := c.Compose(root)
+ bounds := result.Bounds()
+
+ // Expected: (10+30+5, 20+40+5) = (45, 65)
+ wantX, wantY := float32(45), float32(65)
+
+ if bounds.MinX < wantX-1 || bounds.MinX > wantX+1 {
+ t.Errorf("bounds.MinX = %f, want ~%f (10+30+5)", bounds.MinX, wantX)
+ }
+ if bounds.MinY < wantY-1 || bounds.MinY > wantY+1 {
+ t.Errorf("bounds.MinY = %f, want ~%f (20+40+5)", bounds.MinY, wantY)
+ }
+}
+
+// TestCompose_ZeroOffset verifies that zero-offset layers don't shift content.
+func TestCompose_ZeroOffset(t *testing.T) {
+ c := New()
+
+ root := NewOffsetLayer(geometry.Point{})
+ pic := NewPictureLayer()
+ pic.SetPicture(rectScene(red, 100, 100))
+ root.Append(pic)
+
+ result := c.Compose(root)
+ bounds := result.Bounds()
+
+ if bounds.MinX > 1 || bounds.MinY > 1 {
+ t.Errorf("zero-offset should not shift content: bounds start at (%f, %f)",
+ bounds.MinX, bounds.MinY)
+ }
+}
+
+// --- Bug prevention: dirty tracking ---
+
+// TestCompose_ClearsNeedsCompositing ensures flags are cleared after
+// compose. Without this, compositor runs expensive composition every
+// frame even when nothing changed.
+func TestCompose_ClearsNeedsCompositing(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ pic := NewPictureLayer()
+ pic.SetPicture(rectScene(red, 10, 10))
+ root.Append(pic)
+
+ c.Compose(root)
+
+ if root.NeedsCompositing() {
+ t.Error("root NeedsCompositing should be false after Compose")
+ }
+ if pic.NeedsCompositing() {
+ t.Error("pic NeedsCompositing should be false after Compose")
+ }
+}
+
+// TestCompose_ChildDirtyMarksParent verifies that dirtying a child
+// marks all ancestors as needing compositing.
+func TestCompose_ChildDirtyMarksParent(t *testing.T) {
+ root := NewOffsetLayer(geometry.Point{})
+ root.ClearNeedsCompositing()
+
+ mid := NewOffsetLayer(geometry.Point{})
+ root.Append(mid)
+ // Append sets NeedsCompositing on root. Clear to test MarkDirty path.
+ root.ClearNeedsCompositing()
+ mid.ClearNeedsCompositing()
+
+ pic := NewPictureLayer()
+ mid.Append(pic)
+
+ // pic.Append marked mid and root as needing compositing.
+ if !mid.NeedsCompositing() {
+ t.Error("mid should need compositing after child added")
+ }
+}
+
+// --- Bug prevention: RemoveAll doesn't leak ---
+
+// TestRemoveAll_NoDanglingParent prevents memory leak where removed
+// children still reference the old parent.
+func TestRemoveAll_NoDanglingParent(t *testing.T) {
+ parent := NewOffsetLayer(geometry.Point{})
+ children := make([]*PictureLayerImpl, 5)
+ for i := range children {
+ children[i] = NewPictureLayer()
+ parent.Append(children[i])
+ }
+
+ parent.RemoveAll()
+
+ for i, ch := range children {
+ if ch.Parent() != nil {
+ t.Errorf("child[%d].Parent() != nil after RemoveAll (dangling reference)", i)
+ }
+ }
+}
+
+// --- Bug prevention: deep nesting ---
+
+// TestCompose_DeepNesting100 prevents stack overflow on deep layer trees.
+func TestCompose_DeepNesting100(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ current := root
+ for i := 0; i < 100; i++ {
+ child := NewOffsetLayer(geometry.Pt(1, 1))
+ current.Append(child)
+ current = child
+ }
+
+ pic := NewPictureLayer()
+ pic.SetPicture(rectScene(red, 10, 10))
+ current.Append(pic)
+
+ result := c.Compose(root)
+
+ if result.IsEmpty() {
+ t.Error("100-deep layer tree should produce non-empty scene")
+ }
+
+ bounds := result.Bounds()
+ // 100 levels × (1,1) offset = (100, 100)
+ if bounds.MinX < 99 || bounds.MinX > 101 {
+ t.Errorf("100-deep offset: bounds.MinX = %f, want ~100", bounds.MinX)
+ }
+}
+
+// --- Functional: basic operations ---
+
+func TestCompose_EmptyTree(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+ result := c.Compose(root)
+ if !result.IsEmpty() {
+ t.Error("empty tree should produce empty scene")
+ }
+}
+
+func TestCompose_SinglePicture(t *testing.T) {
+ c := New()
+ root := NewOffsetLayer(geometry.Point{})
+
+ pic := NewPictureLayer()
+ pic.SetPicture(rectScene(red, 100, 50))
+ root.Append(pic)
+
+ result := c.Compose(root)
+ if result.IsEmpty() {
+ t.Error("single picture should produce non-empty scene")
+ }
+}
diff --git a/compositor/doc.go b/compositor/doc.go
new file mode 100644
index 0000000..43db44a
--- /dev/null
+++ b/compositor/doc.go
@@ -0,0 +1,27 @@
+// Package compositor provides a Layer Tree for retained-mode rendering.
+//
+// STATUS: NOT IN PRODUCTION PIPELINE. This package is fully implemented
+// and tested but not connected to desktop.draw(). The production pipeline
+// uses per-boundary GPU textures (Phase 7) which bypasses the Layer Tree
+// for simpler direct texture caching + blit. This package is retained as
+// infrastructure for future optimizations: animated transforms on cached
+// textures, opacity blending layers, clip masking without re-recording.
+//
+// Each [RepaintBoundary] widget creates a [PictureLayer] that owns a
+// scene.Scene display list. The [Compositor] assembles all layers into
+// a composed scene by REFERENCE (not copy), so when a child layer is
+// re-recorded, the parent automatically sees fresh content.
+//
+// This is the Flutter rendering/layer.dart pattern:
+//
+// - [ContainerLayer]: has children, no own content
+// - [OffsetLayer]: ContainerLayer + translation offset
+// - [PictureLayer]: owns a scene.Scene, leaf node
+// - [ClipRectLayer]: ContainerLayer + clip rectangle
+// - [OpacityLayer]: ContainerLayer + alpha blending
+//
+// See: ADR-007 Phase 5 (docs/dev/architecture/ADR-007-RETAINED-MODE-COMPOSITOR.md)
+// Task: TASK-UI-OPT-005-compositor-integration (backlog — connect or remove)
+//
+// ADR-007 Phase 5 | Flutter rendering/layer.dart
+package compositor
diff --git a/compositor/layer.go b/compositor/layer.go
new file mode 100644
index 0000000..dc59e03
--- /dev/null
+++ b/compositor/layer.go
@@ -0,0 +1,227 @@
+package compositor
+
+import (
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/geometry"
+)
+
+// Layer is a node in the compositor layer tree.
+//
+// Flutter equivalent: rendering/layer.dart Layer class.
+// Each Layer has a parent and can be attached/detached from the tree.
+type Layer interface {
+ // Parent returns the parent layer, or nil for the root.
+ Parent() ContainerLayer
+
+ // SetParent sets the parent layer. Called by ContainerLayer.Append/Remove.
+ SetParent(parent ContainerLayer)
+
+ // Offset returns this layer's translation offset relative to parent.
+ Offset() geometry.Point
+
+ // SetOffset sets the translation offset. When offset changes on an
+ // OffsetLayer, no re-record is needed — the compositor applies the
+ // new offset during composition (Flutter animated transform).
+ SetOffset(offset geometry.Point)
+
+ // NeedsCompositing reports whether this layer or any descendant
+ // needs to be re-composited into the parent scene.
+ NeedsCompositing() bool
+
+ // MarkNeedsCompositing marks this layer as needing re-composition.
+ MarkNeedsCompositing()
+
+ // ClearNeedsCompositing resets the compositing flag after composition.
+ ClearNeedsCompositing()
+}
+
+// ContainerLayer is a layer that contains child layers.
+//
+// Flutter equivalent: ContainerLayer (rendering/layer.dart).
+// Used as base for OffsetLayer, ClipRectLayer, OpacityLayer.
+type ContainerLayer interface {
+ Layer
+
+ // Children returns the ordered list of child layers.
+ Children() []Layer
+
+ // Append adds a child layer to the end of the children list.
+ Append(child Layer)
+
+ // Remove removes a child layer from the children list.
+ Remove(child Layer)
+
+ // RemoveAll removes all child layers.
+ RemoveAll()
+}
+
+// PictureOwner is implemented by layers that own a scene.Scene (display list).
+//
+// Flutter equivalent: PictureLayer.picture.
+type PictureOwner interface {
+ // Picture returns the scene.Scene owned by this layer.
+ // Returns nil if the layer has not been recorded yet.
+ Picture() *scene.Scene
+
+ // SetPicture stores a recorded scene. Called after recording a
+ // RepaintBoundary's subtree via SceneCanvas.
+ SetPicture(s *scene.Scene)
+
+ // IsDirty reports whether the picture needs re-recording.
+ IsDirty() bool
+
+ // MarkDirty marks the picture as needing re-recording.
+ MarkDirty()
+
+ // ClearDirty resets the dirty flag after re-recording.
+ ClearDirty()
+}
+
+// --- Concrete layer types ---
+
+// layerBase provides the common fields for all layer types.
+type layerBase struct {
+ parent ContainerLayer
+ offset geometry.Point
+ needsCompositing bool
+}
+
+func (l *layerBase) Parent() ContainerLayer { return l.parent }
+func (l *layerBase) SetParent(p ContainerLayer) { l.parent = p }
+func (l *layerBase) Offset() geometry.Point { return l.offset }
+func (l *layerBase) SetOffset(o geometry.Point) { l.offset = o; l.MarkNeedsCompositing() }
+func (l *layerBase) NeedsCompositing() bool { return l.needsCompositing }
+func (l *layerBase) MarkNeedsCompositing() { l.needsCompositing = true }
+func (l *layerBase) ClearNeedsCompositing() { l.needsCompositing = false }
+
+// containerBase provides the children management for ContainerLayer types.
+type containerBase struct {
+ layerBase
+ children []Layer
+}
+
+func (c *containerBase) Children() []Layer { return c.children }
+
+func (c *containerBase) Append(child Layer) {
+ child.SetParent(c.asContainer())
+ c.children = append(c.children, child)
+ c.MarkNeedsCompositing()
+}
+
+func (c *containerBase) Remove(child Layer) {
+ for i, ch := range c.children {
+ if ch == child {
+ child.SetParent(nil)
+ c.children = append(c.children[:i], c.children[i+1:]...)
+ c.MarkNeedsCompositing()
+ return
+ }
+ }
+}
+
+func (c *containerBase) RemoveAll() {
+ for _, ch := range c.children {
+ ch.SetParent(nil)
+ }
+ c.children = c.children[:0]
+ c.MarkNeedsCompositing()
+}
+
+// asContainer returns this containerBase as a ContainerLayer interface.
+// Subclasses override this to return themselves.
+func (c *containerBase) asContainer() ContainerLayer { return nil }
+
+// OffsetLayerImpl is a container layer with a translation offset.
+//
+// Flutter equivalent: OffsetLayer. Each RepaintBoundary creates one.
+// The offset is the widget's screen position. When the widget moves
+// (e.g., scroll), only the offset changes — no re-record needed.
+type OffsetLayerImpl struct {
+ containerBase
+}
+
+// NewOffsetLayer creates a new OffsetLayer at the given offset.
+func NewOffsetLayer(offset geometry.Point) *OffsetLayerImpl {
+ l := &OffsetLayerImpl{}
+ l.offset = offset
+ l.needsCompositing = true
+ return l
+}
+
+func (l *OffsetLayerImpl) asContainer() ContainerLayer { return l } //nolint:unused // override for containerBase.Append polymorphism
+func (l *OffsetLayerImpl) Append(child Layer) {
+ child.SetParent(l)
+ l.children = append(l.children, child)
+ l.MarkNeedsCompositing()
+}
+
+// PictureLayerImpl owns a scene.Scene display list. Leaf node.
+//
+// Flutter equivalent: PictureLayer. Contains the recorded draw
+// commands from a RepaintBoundary's subtree.
+type PictureLayerImpl struct {
+ layerBase
+ picture *scene.Scene
+ dirty bool
+}
+
+// NewPictureLayer creates a new PictureLayer (initially dirty, no picture).
+func NewPictureLayer() *PictureLayerImpl {
+ return &PictureLayerImpl{dirty: true}
+}
+
+func (l *PictureLayerImpl) Picture() *scene.Scene { return l.picture }
+func (l *PictureLayerImpl) SetPicture(s *scene.Scene) { l.picture = s; l.MarkNeedsCompositing() }
+func (l *PictureLayerImpl) IsDirty() bool { return l.dirty }
+func (l *PictureLayerImpl) MarkDirty() { l.dirty = true; l.MarkNeedsCompositing() }
+func (l *PictureLayerImpl) ClearDirty() { l.dirty = false }
+
+// ClipRectLayerImpl is a container layer with a clip rectangle.
+//
+// Flutter equivalent: ClipRectLayer. Used by ScrollView to clip
+// content to the viewport bounds.
+type ClipRectLayerImpl struct {
+ containerBase
+ clipRect geometry.Rect
+}
+
+// NewClipRectLayer creates a new ClipRectLayer with the given clip bounds.
+func NewClipRectLayer(clip geometry.Rect) *ClipRectLayerImpl {
+ l := &ClipRectLayerImpl{clipRect: clip}
+ l.needsCompositing = true
+ return l
+}
+
+func (l *ClipRectLayerImpl) ClipRect() geometry.Rect { return l.clipRect }
+func (l *ClipRectLayerImpl) SetClipRect(r geometry.Rect) { l.clipRect = r; l.MarkNeedsCompositing() }
+func (l *ClipRectLayerImpl) asContainer() ContainerLayer { return l } //nolint:unused // override for containerBase.Append polymorphism (TASK-UI-OPT-005)
+func (l *ClipRectLayerImpl) Append(child Layer) {
+ child.SetParent(l)
+ l.children = append(l.children, child)
+ l.MarkNeedsCompositing()
+}
+
+// OpacityLayerImpl is a container layer with an opacity value.
+//
+// Flutter equivalent: OpacityLayer. Changing opacity does NOT
+// trigger re-record of children — compositor applies alpha.
+type OpacityLayerImpl struct {
+ containerBase
+ opacity float32
+}
+
+// NewOpacityLayer creates a new OpacityLayer with the given alpha (0-1).
+func NewOpacityLayer(opacity float32) *OpacityLayerImpl {
+ l := &OpacityLayerImpl{opacity: opacity}
+ l.needsCompositing = true
+ return l
+}
+
+func (l *OpacityLayerImpl) Opacity() float32 { return l.opacity }
+func (l *OpacityLayerImpl) SetOpacity(a float32) { l.opacity = a; l.MarkNeedsCompositing() }
+func (l *OpacityLayerImpl) asContainer() ContainerLayer { return l } //nolint:unused // override for containerBase.Append polymorphism (TASK-UI-OPT-005)
+func (l *OpacityLayerImpl) Append(child Layer) {
+ child.SetParent(l)
+ l.children = append(l.children, child)
+ l.MarkNeedsCompositing()
+}
diff --git a/compositor/layer_test.go b/compositor/layer_test.go
new file mode 100644
index 0000000..72c4d02
--- /dev/null
+++ b/compositor/layer_test.go
@@ -0,0 +1,172 @@
+package compositor
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/geometry"
+)
+
+func TestNewOffsetLayer(t *testing.T) {
+ l := NewOffsetLayer(geometry.Pt(10, 20))
+
+ if l.Offset() != (geometry.Point{X: 10, Y: 20}) {
+ t.Errorf("offset = %v, want (10, 20)", l.Offset())
+ }
+ if !l.NeedsCompositing() {
+ t.Error("new layer should need compositing")
+ }
+ if l.Parent() != nil {
+ t.Error("root layer should have nil parent")
+ }
+}
+
+func TestOffsetLayer_AppendRemove(t *testing.T) {
+ parent := NewOffsetLayer(geometry.Point{})
+ child := NewPictureLayer()
+
+ parent.Append(child)
+
+ if len(parent.Children()) != 1 {
+ t.Fatalf("children count = %d, want 1", len(parent.Children()))
+ }
+ if child.Parent() != parent {
+ t.Error("child.Parent() should be parent after Append")
+ }
+
+ parent.Remove(child)
+
+ if len(parent.Children()) != 0 {
+ t.Fatalf("children count = %d, want 0 after Remove", len(parent.Children()))
+ }
+ if child.Parent() != nil {
+ t.Error("child.Parent() should be nil after Remove")
+ }
+}
+
+func TestOffsetLayer_RemoveAll(t *testing.T) {
+ parent := NewOffsetLayer(geometry.Point{})
+ c1 := NewPictureLayer()
+ c2 := NewPictureLayer()
+
+ parent.Append(c1)
+ parent.Append(c2)
+ parent.RemoveAll()
+
+ if len(parent.Children()) != 0 {
+ t.Errorf("children count = %d after RemoveAll", len(parent.Children()))
+ }
+ if c1.Parent() != nil || c2.Parent() != nil {
+ t.Error("children should have nil parent after RemoveAll")
+ }
+}
+
+func TestPictureLayer_DirtyLifecycle(t *testing.T) {
+ l := NewPictureLayer()
+
+ if !l.IsDirty() {
+ t.Error("new PictureLayer should be dirty")
+ }
+ if l.Picture() != nil {
+ t.Error("new PictureLayer should have nil picture")
+ }
+
+ s := scene.NewScene()
+ l.SetPicture(s)
+ l.ClearDirty()
+
+ if l.IsDirty() {
+ t.Error("should be clean after ClearDirty")
+ }
+ if l.Picture() != s {
+ t.Error("Picture() should return set scene")
+ }
+
+ l.MarkDirty()
+
+ if !l.IsDirty() {
+ t.Error("should be dirty after MarkDirty")
+ }
+}
+
+func TestPictureLayer_SetPictureMarksCompositing(t *testing.T) {
+ l := NewPictureLayer()
+ l.ClearNeedsCompositing()
+
+ s := scene.NewScene()
+ l.SetPicture(s)
+
+ if !l.NeedsCompositing() {
+ t.Error("SetPicture should mark NeedsCompositing")
+ }
+}
+
+func TestClipRectLayer_Basic(t *testing.T) {
+ clip := geometry.NewRect(10, 10, 100, 100)
+ l := NewClipRectLayer(clip)
+
+ if l.ClipRect() != clip {
+ t.Errorf("clip = %v, want %v", l.ClipRect(), clip)
+ }
+
+ child := NewPictureLayer()
+ l.Append(child)
+
+ if len(l.Children()) != 1 {
+ t.Fatalf("children = %d, want 1", len(l.Children()))
+ }
+}
+
+func TestOpacityLayer_Basic(t *testing.T) {
+ l := NewOpacityLayer(0.5)
+
+ if l.Opacity() != 0.5 {
+ t.Errorf("opacity = %f, want 0.5", l.Opacity())
+ }
+
+ l.SetOpacity(0.8)
+
+ if l.Opacity() != 0.8 {
+ t.Errorf("opacity = %f, want 0.8", l.Opacity())
+ }
+ if !l.NeedsCompositing() {
+ t.Error("SetOpacity should mark NeedsCompositing")
+ }
+}
+
+func TestSetOffset_MarksNeedsCompositing(t *testing.T) {
+ l := NewOffsetLayer(geometry.Point{})
+ l.ClearNeedsCompositing()
+
+ l.SetOffset(geometry.Pt(50, 50))
+
+ if !l.NeedsCompositing() {
+ t.Error("SetOffset should mark NeedsCompositing")
+ }
+}
+
+func TestLayerTree_ThreeLevels(t *testing.T) {
+ root := NewOffsetLayer(geometry.Point{})
+
+ buttons := NewOffsetLayer(geometry.Pt(0, 100))
+ buttonsPic := NewPictureLayer()
+ buttons.Append(buttonsPic)
+ root.Append(buttons)
+
+ spinner := NewOffsetLayer(geometry.Pt(200, 400))
+ spinnerPic := NewPictureLayer()
+ spinner.Append(spinnerPic)
+ root.Append(spinner)
+
+ if len(root.Children()) != 2 {
+ t.Fatalf("root children = %d, want 2", len(root.Children()))
+ }
+
+ children := root.Children()
+ if children[0] != buttons {
+ t.Error("first child should be buttons layer")
+ }
+ if children[1] != spinner {
+ t.Error("second child should be spinner layer")
+ }
+}
diff --git a/core/button/button_test.go b/core/button/button_test.go
index 526103d..5e039c8 100644
--- a/core/button/button_test.go
+++ b/core/button/button_test.go
@@ -601,6 +601,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -634,6 +635,7 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/button/internal_test.go b/core/button/internal_test.go
index 6df7496..04e15ac 100644
--- a/core/button/internal_test.go
+++ b/core/button/internal_test.go
@@ -1150,6 +1150,7 @@ func (c *internalMockCanvas) PopClip() {}
func (c *internalMockCanvas) PushTransform(_ geometry.Point) {}
func (c *internalMockCanvas) PopTransform() {}
func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/checkbox/checkbox_test.go b/core/checkbox/checkbox_test.go
index b0d0145..fc4b1f6 100644
--- a/core/checkbox/checkbox_test.go
+++ b/core/checkbox/checkbox_test.go
@@ -608,6 +608,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -641,6 +642,7 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/checkbox/internal_test.go b/core/checkbox/internal_test.go
index f610d0b..b0f9d5d 100644
--- a/core/checkbox/internal_test.go
+++ b/core/checkbox/internal_test.go
@@ -1086,6 +1086,7 @@ func (c *internalMockCanvas) PopClip() {}
func (c *internalMockCanvas) PushTransform(_ geometry.Point) {}
func (c *internalMockCanvas) PopTransform() {}
func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/collapsible/collapsible.go b/core/collapsible/collapsible.go
index d62a4e3..c9bf7ac 100644
--- a/core/collapsible/collapsible.go
+++ b/core/collapsible/collapsible.go
@@ -41,6 +41,11 @@ type Widget struct {
// Cached content size from last layout.
contentSize geometry.Size
+
+ // headerTitle is an internal TextWidget for the header title text.
+ // It participates in Children() so dirty.Collector can track title
+ // changes independently (e.g., TitleSignal updates → cyan overlay).
+ headerTitle widget.Widget
}
// Default configuration values.
@@ -80,6 +85,9 @@ func New(opts ...Option) *Widget {
w.progress = 1.0
}
+ // Create internal header title widget for dirty tracking.
+ w.headerTitle = newHeaderTextWidget()
+
return w
}
@@ -164,6 +172,14 @@ func (w *Widget) Draw(ctx widget.Context, canvas widget.Canvas) {
bounds.Width(), w.cfg.headerHeight,
)
+ // Set bounds and stamp screen origin on header title widget for dirty tracking.
+ if w.headerTitle != nil {
+ if setter, ok := w.headerTitle.(interface{ SetBounds(geometry.Rect) }); ok {
+ setter.SetBounds(headerBounds)
+ }
+ widget.StampScreenOrigin(w.headerTitle, canvas)
+ }
+
// Paint header via the painter.
w.painter.PaintHeader(canvas, HeaderState{
Title: w.cfg.ResolvedTitle(),
@@ -228,10 +244,17 @@ func (w *Widget) Event(ctx widget.Context, e event.Event) bool {
// The content is always returned even when collapsed, to allow the framework
// to manage lifecycle and focus traversal.
func (w *Widget) Children() []widget.Widget {
+ children := make([]widget.Widget, 0, 2)
+ if w.headerTitle != nil {
+ children = append(children, w.headerTitle)
+ }
if w.cfg.content != nil {
- return []widget.Widget{w.cfg.content}
+ children = append(children, w.cfg.content)
}
- return nil
+ if len(children) == 0 {
+ return nil
+ }
+ return children
}
// Mount creates signal bindings for push-based invalidation.
@@ -241,6 +264,19 @@ func (w *Widget) Mount(ctx widget.Context) {
if sched == nil {
return
}
+ // Bind title signals to HEADER widget (not self) so dirty.Collector
+ // reports header bounds, not full collapsible bounds.
+ titleTarget := w.headerTitle
+ if titleTarget == nil {
+ titleTarget = w
+ }
+ if w.cfg.readonlyTitleSignal != nil {
+ b := state.BindToScheduler(w.cfg.readonlyTitleSignal, titleTarget, sched)
+ w.AddBinding(b)
+ } else if w.cfg.titleSignal != nil {
+ b := state.BindToScheduler(w.cfg.titleSignal, titleTarget, sched)
+ w.AddBinding(b)
+ }
if w.cfg.readonlyExpandedSignal != nil {
b := state.BindToScheduler(w.cfg.readonlyExpandedSignal, w, sched)
w.AddBinding(b)
diff --git a/core/collapsible/collapsible_test.go b/core/collapsible/collapsible_test.go
index f3fde13..7ad21c4 100644
--- a/core/collapsible/collapsible_test.go
+++ b/core/collapsible/collapsible_test.go
@@ -72,19 +72,21 @@ func TestNew_WithContent(t *testing.T) {
w := collapsible.New(collapsible.Content(content))
children := w.Children()
- if len(children) != 1 {
- t.Fatalf("Children() = %d, want 1", len(children))
+ // 2 children: headerTitle (internal) + content.
+ if len(children) != 2 {
+ t.Fatalf("Children() = %d, want 2 (header + content)", len(children))
}
- if children[0] != content {
- t.Error("child should be the content widget")
+ if children[1] != content {
+ t.Error("second child should be the content widget")
}
}
func TestNew_NoContent(t *testing.T) {
w := collapsible.New()
- if children := w.Children(); children != nil {
- t.Errorf("Children() should be nil without content, got %v", children)
+ children := w.Children()
+ if len(children) != 1 {
+ t.Errorf("Children() len = %d, want 1 (headerTitle widget)", len(children))
}
}
@@ -1070,6 +1072,7 @@ func (c *mockCanvas) PopClip() { c.popClipCo
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
@@ -1146,5 +1149,68 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
+
+// --- TitleSignal Tests ---
+
+func TestTitleSignal_Mount_CreatesBinding(t *testing.T) {
+ sig := state.NewSignal("Initial")
+ w := collapsible.New(
+ collapsible.TitleSignal(sig),
+ )
+
+ dirtyCount := 0
+ sched := state.NewScheduler(func(_ []widget.Widget) {})
+ sched.SetOnDirty(func() { dirtyCount++ })
+ ctx := widget.NewContext()
+ ctx.SetScheduler(sched)
+
+ w.Mount(ctx)
+
+ sig.Set("Updated")
+
+ if dirtyCount == 0 {
+ t.Error("TitleSignal change should mark widget dirty after Mount; " +
+ "binding not created → header updates invisible to dirty tracker")
+ }
+}
+
+func TestTitleSignal_ResolvesTitle(t *testing.T) {
+ sig := state.NewSignal("Signal Title")
+ w := collapsible.New(
+ collapsible.Title("Static"),
+ collapsible.TitleFn(func() string { return "Fn Title" }),
+ collapsible.TitleSignal(sig),
+ )
+
+ // Signal > Fn > Static
+ ctx := widget.NewContext()
+ constraints := geometry.Tight(geometry.Sz(400, 40))
+ w.Layout(ctx, constraints)
+ w.SetBounds(geometry.NewRect(0, 0, 400, 40))
+
+ // Draw and capture title from painter.
+ canvas := &recordingCanvas{}
+ w.Draw(ctx, canvas)
+
+ // Title should come from signal.
+ found := false
+ for _, call := range canvas.drawTexts {
+ if call.text == "Signal Title" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected 'Signal Title' from TitleSignal, drawn texts: %v",
+ func() []string {
+ texts := make([]string, 0, len(canvas.drawTexts))
+ for _, c := range canvas.drawTexts {
+ texts = append(texts, c.text)
+ }
+ return texts
+ }())
+ }
+}
diff --git a/core/collapsible/config.go b/core/collapsible/config.go
index d2c5b7a..1c45f5e 100644
--- a/core/collapsible/config.go
+++ b/core/collapsible/config.go
@@ -9,10 +9,12 @@ import (
// config holds the collapsible section's configuration, set at construction time via options.
type config struct {
- title string
- titleFn func() string
- content widget.Widget
- expanded bool
+ title string
+ titleFn func() string
+ titleSignal state.Signal[string]
+ readonlyTitleSignal state.ReadonlySignal[string]
+ content widget.Widget
+ expanded bool
expandedSignal state.Signal[bool]
readonlyExpandedSignal state.ReadonlySignal[bool]
@@ -30,8 +32,14 @@ type config struct {
}
// ResolvedTitle returns the current header title text.
-// Priority: Fn > Static.
+// Priority: ReadonlySignal > Signal > Fn > Static.
func (c *config) ResolvedTitle() string {
+ if c.readonlyTitleSignal != nil {
+ return c.readonlyTitleSignal.Get()
+ }
+ if c.titleSignal != nil {
+ return c.titleSignal.Get()
+ }
if c.titleFn != nil {
return c.titleFn()
}
diff --git a/core/collapsible/header_widget.go b/core/collapsible/header_widget.go
new file mode 100644
index 0000000..b01af5c
--- /dev/null
+++ b/core/collapsible/header_widget.go
@@ -0,0 +1,35 @@
+package collapsible
+
+import (
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+)
+
+// headerTextWidget is an internal widget that represents the header title
+// text as a proper widget in the Children() tree. This enables dirty.Collector
+// to track header title changes independently — when TitleSignal updates,
+// this widget gets dirty via signal binding, and the collector reports its
+// bounds as a dirty region for the cyan overlay.
+//
+// The widget does NOT draw itself — the Painter.PaintHeader handles all
+// header rendering (background, arrow, text). This widget exists solely
+// for dirty tracking and screen origin stamping.
+type headerTextWidget struct {
+ widget.WidgetBase
+}
+
+func newHeaderTextWidget() *headerTextWidget {
+ w := &headerTextWidget{}
+ w.SetVisible(true)
+ w.SetEnabled(true)
+ return w
+}
+
+func (w *headerTextWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(0, 0))
+}
+
+func (w *headerTextWidget) Draw(_ widget.Context, _ widget.Canvas) {}
+
+func (w *headerTextWidget) Event(_ widget.Context, _ event.Event) bool { return false }
diff --git a/core/collapsible/internal_test.go b/core/collapsible/internal_test.go
index 1d184a8..0a6f658 100644
--- a/core/collapsible/internal_test.go
+++ b/core/collapsible/internal_test.go
@@ -691,6 +691,7 @@ func (c *internalMockCanvas) PopClip() { c.p
func (c *internalMockCanvas) PushTransform(_ geometry.Point) {}
func (c *internalMockCanvas) PopTransform() {}
func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/collapsible/options.go b/core/collapsible/options.go
index aca595e..0b09166 100644
--- a/core/collapsible/options.go
+++ b/core/collapsible/options.go
@@ -25,6 +25,24 @@ func TitleFn(fn func() string) Option {
}
}
+// TitleSignal binds the header title to a reactive signal.
+// When the signal value changes, the collapsible header updates automatically
+// via push-based invalidation (signal scheduler → SetNeedsRedraw).
+// Priority: ReadonlySignal > Signal > Fn > Static.
+func TitleSignal(sig state.Signal[string]) Option {
+ return func(c *config) {
+ c.titleSignal = sig
+ }
+}
+
+// TitleReadonlySignal binds the header title to a read-only reactive signal.
+// Highest priority in the title resolution chain.
+func TitleReadonlySignal(sig state.ReadonlySignal[string]) Option {
+ return func(c *config) {
+ c.readonlyTitleSignal = sig
+ }
+}
+
// Content sets the child widget displayed when expanded.
func Content(w widget.Widget) Option {
return func(c *config) {
diff --git a/core/datatable/datatable.go b/core/datatable/datatable.go
index 9f2eafc..654b480 100644
--- a/core/datatable/datatable.go
+++ b/core/datatable/datatable.go
@@ -425,6 +425,7 @@ func (w *Widget) Draw(ctx widget.Context, canvas widget.Canvas) {
// Draw data rows via the scroll view.
w.updateScrollBounds()
+ widget.StampScreenOrigin(w.scroll, canvas)
w.scroll.Draw(ctx, canvas)
}
diff --git a/core/datatable/datatable_test.go b/core/datatable/datatable_test.go
index 72b0bfe..261863f 100644
--- a/core/datatable/datatable_test.go
+++ b/core/datatable/datatable_test.go
@@ -53,6 +53,7 @@ func (m *mockCanvas) PopClip() { m.clips-- }
func (m *mockCanvas) PushTransform(_ geometry.Point) { m.transforms++ }
func (m *mockCanvas) PopTransform() { m.transforms-- }
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) {}
diff --git a/core/dialog/dialog_test.go b/core/dialog/dialog_test.go
index 99a5e60..545012a 100644
--- a/core/dialog/dialog_test.go
+++ b/core/dialog/dialog_test.go
@@ -496,6 +496,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
diff --git a/core/dialog/internal_test.go b/core/dialog/internal_test.go
index 389ae99..9904731 100644
--- a/core/dialog/internal_test.go
+++ b/core/dialog/internal_test.go
@@ -996,6 +996,7 @@ func (c *internalRecordingCanvas) PopClip()
func (c *internalRecordingCanvas) PushTransform(_ geometry.Point) {}
func (c *internalRecordingCanvas) PopTransform() {}
func (c *internalRecordingCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalRecordingCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *internalRecordingCanvas) ClipBounds() geometry.Rect {
return geometry.NewRect(0, 0, 10000, 10000)
}
diff --git a/core/docking/docking_test.go b/core/docking/docking_test.go
index 12685df..818902c 100644
--- a/core/docking/docking_test.go
+++ b/core/docking/docking_test.go
@@ -1215,5 +1215,6 @@ func (c *mockCanvas) PopClip() { c.popClipCo
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/dropdown/dropdown_test.go b/core/dropdown/dropdown_test.go
index d569224..947d458 100644
--- a/core/dropdown/dropdown_test.go
+++ b/core/dropdown/dropdown_test.go
@@ -938,6 +938,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -971,6 +972,7 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/gridview/gridview.go b/core/gridview/gridview.go
index 7df4cc6..54e2c8d 100644
--- a/core/gridview/gridview.go
+++ b/core/gridview/gridview.go
@@ -501,6 +501,10 @@ func (w *Widget) Draw(ctx widget.Context, canvas widget.Canvas) {
// Set scroll view bounds to match our bounds.
w.scroll.SetBounds(bounds)
+ // Stamp screen origin on the internal scroll view so its ScreenBounds()
+ // returns correct window-space coordinates for dirty region collection.
+ widget.StampScreenOrigin(w.scroll, canvas)
+
// Delegate drawing to the internal scroll view.
w.scroll.Draw(ctx, canvas)
}
diff --git a/core/gridview/gridview_test.go b/core/gridview/gridview_test.go
index 239ba9e..4824e50 100644
--- a/core/gridview/gridview_test.go
+++ b/core/gridview/gridview_test.go
@@ -1254,6 +1254,7 @@ func (m *mockCanvas) PopClip() {}
func (m *mockCanvas) PushTransform(_ geometry.Point) {}
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) {}
diff --git a/core/gridview/internal_test.go b/core/gridview/internal_test.go
index 7aa574c..198505f 100644
--- a/core/gridview/internal_test.go
+++ b/core/gridview/internal_test.go
@@ -954,5 +954,6 @@ func (m *mockCanvas) PopClip() {}
func (m *mockCanvas) PushTransform(_ geometry.Point) {}
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) {}
diff --git a/core/linechart/linechart.go b/core/linechart/linechart.go
index 191de22..a7f6a78 100644
--- a/core/linechart/linechart.go
+++ b/core/linechart/linechart.go
@@ -330,11 +330,12 @@ func (w *Widget) Padding(v float32) *Widget {
// Layout calculates the chart's preferred size within the given constraints.
func (w *Widget) Layout(_ widget.Context, constraints geometry.Constraints) geometry.Size {
- preferred := geometry.Sz(
- defaultWidth+w.padding*2,
- defaultHeight+w.padding*2,
- )
- return constraints.Constrain(preferred)
+ width := constraints.MaxWidth
+ if width <= 0 || width == geometry.Infinity {
+ width = defaultWidth + w.padding*2
+ }
+ height := defaultHeight + w.padding*2
+ return constraints.Constrain(geometry.Sz(width, height))
}
// Draw renders the chart to the canvas.
diff --git a/core/linechart/linechart_test.go b/core/linechart/linechart_test.go
index d1f6abb..a3e7560 100644
--- a/core/linechart/linechart_test.go
+++ b/core/linechart/linechart_test.go
@@ -140,12 +140,11 @@ func TestLayout_PreferredSize(t *testing.T) {
w := New()
size := w.Layout(ctx, constraints)
- expectedW := defaultWidth + defaultPadding*2
- expectedH := defaultHeight + defaultPadding*2
-
- if size.Width != expectedW {
- t.Errorf("width = %v, want %v", size.Width, expectedW)
+ // LineChart fills available width (MaxWidth from constraints).
+ if size.Width != 800 {
+ t.Errorf("width = %v, want 800 (fills available width)", size.Width)
}
+ expectedH := defaultHeight + defaultPadding*2
if size.Height != expectedH {
t.Errorf("height = %v, want %v", size.Height, expectedH)
}
@@ -749,12 +748,13 @@ func (c *recordingCanvas) PushClip(_ geometry.Rect) { c.clipCou
func (c *recordingCanvas) PushClipRoundRect(_ geometry.Rect, _ float32) {
c.clipCount++
}
-func (c *recordingCanvas) PopClip() { c.clipCount++ }
-func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
-func (c *recordingCanvas) PopTransform() {}
-func (c *recordingCanvas) TransformOffset() 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) PopClip() { c.clipCount++ }
+func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
+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) {}
type mockPainter struct {
called bool
diff --git a/core/listview/cache.go b/core/listview/cache.go
index ea6103e..e354893 100644
--- a/core/listview/cache.go
+++ b/core/listview/cache.go
@@ -2,7 +2,6 @@ package listview
import (
"github.com/gogpu/ui/cdk"
- "github.com/gogpu/ui/primitives"
"github.com/gogpu/ui/widget"
)
@@ -18,46 +17,58 @@ import (
// Item background, selection, and dividers are painted OUTSIDE the boundary
// by the painter on the main canvas.
type widgetCache struct {
- startIndex int
- endIndex int
- widgets []widget.Widget
- boundaries []*primitives.RepaintBoundary
- valid bool
+ startIndex int
+ endIndex int
+ selectedIndex int
+ hoveredIndex int
+ widgets []widget.Widget
+ valid bool
}
-// update ensures the cache contains widgets for the range [start, end).
-// If the range matches and the cache is valid, this is a no-op.
-// Otherwise, it calls the content's Render method for each index in the range
-// and wraps each widget in a RepaintBoundary.
-func (wc *widgetCache) update(start, end int, content cdk.Content[ItemContext], selectedIndex, hoveredIndex int) {
- count := end - start
- if count <= 0 {
- wc.clear()
- return
+// rebuildAffected rebuilds only items whose selection or hover state changed.
+// Android RecyclerView pattern: only affected ViewHolders are rebound.
+func (wc *widgetCache) rebuildAffected(start int, content cdk.Content[ItemContext], selectedIndex, hoveredIndex int) {
+ affectedIndices := make(map[int]bool)
+ if wc.selectedIndex != selectedIndex {
+ affectedIndices[wc.selectedIndex] = true
+ affectedIndices[selectedIndex] = true
+ }
+ if wc.hoveredIndex != hoveredIndex {
+ affectedIndices[wc.hoveredIndex] = true
+ affectedIndices[hoveredIndex] = true
}
- // Check if cache can be reused.
- if wc.valid && wc.startIndex == start && wc.endIndex == end {
- return
+ for idx := range affectedIndices {
+ offset := idx - start
+ if offset < 0 || offset >= len(wc.widgets) {
+ continue
+ }
+ w := content.Render(ItemContext{
+ Index: idx,
+ Selected: idx == selectedIndex,
+ Focused: idx == selectedIndex,
+ Hovered: idx == hoveredIndex,
+ })
+ if w != nil {
+ if setter, ok := w.(interface{ SetRepaintBoundary(bool) }); ok {
+ setter.SetRepaintBoundary(true)
+ }
+ }
+ wc.widgets[offset] = w
}
+}
- // Rebuild cache.
+// fullRebuild recreates all items in the range (scroll or first build).
+func (wc *widgetCache) fullRebuild(start, _, count int, content cdk.Content[ItemContext], selectedIndex, hoveredIndex int) {
if cap(wc.widgets) >= count {
wc.widgets = wc.widgets[:count]
} else {
wc.widgets = make([]widget.Widget, count)
}
- if cap(wc.boundaries) >= count {
- wc.boundaries = wc.boundaries[:count]
- } else {
- wc.boundaries = make([]*primitives.RepaintBoundary, count)
- }
-
- if content == nil {
+ if content == nil { //nolint:nestif // cache miss path with lazy initialization and RepaintBoundary wrapping
for i := range wc.widgets {
wc.widgets[i] = nil
- wc.boundaries[i] = nil
}
} else {
for i := range count {
@@ -68,17 +79,45 @@ func (wc *widgetCache) update(start, end int, content cdk.Content[ItemContext],
Focused: idx == selectedIndex,
Hovered: idx == hoveredIndex,
})
- wc.widgets[i] = w
if w != nil {
- wc.boundaries[i] = primitives.NewRepaintBoundary(w)
- } else {
- wc.boundaries[i] = nil
+ if setter, ok := w.(interface{ SetRepaintBoundary(bool) }); ok {
+ setter.SetRepaintBoundary(true)
+ }
}
+ wc.widgets[i] = w
+ }
+ }
+}
+
+// update ensures the cache contains widgets for the range [start, end).
+// If the range matches and the cache is valid, this is a no-op.
+// Otherwise, it calls the content's Render method for each index in the range
+// and wraps each widget in a RepaintBoundary.
+func (wc *widgetCache) update(start, end int, content cdk.Content[ItemContext], selectedIndex, hoveredIndex int) {
+ count := end - start
+ if count <= 0 {
+ wc.clear()
+ return
+ }
+
+ // Fast path: same range, only selection/hover changed → rebuild only affected items.
+ // Android RecyclerView pattern: notifyItemChanged(pos) rebinds single ViewHolder.
+ if wc.valid && wc.startIndex == start && wc.endIndex == end && content != nil {
+ if wc.selectedIndex != selectedIndex || wc.hoveredIndex != hoveredIndex {
+ wc.rebuildAffected(start, content, selectedIndex, hoveredIndex)
+ wc.selectedIndex = selectedIndex
+ wc.hoveredIndex = hoveredIndex
+ return
}
+ return // nothing changed
}
+ // Full rebuild: range changed or first build.
+ wc.fullRebuild(start, end, count, content, selectedIndex, hoveredIndex)
wc.startIndex = start
wc.endIndex = end
+ wc.selectedIndex = selectedIndex
+ wc.hoveredIndex = hoveredIndex
wc.valid = true
}
@@ -92,16 +131,6 @@ func (wc *widgetCache) widgetAt(offset int) widget.Widget {
return wc.widgets[offset]
}
-// boundaryAt returns the RepaintBoundary wrapper for the widget at the given
-// offset from startIndex. Returns nil if the offset is out of range or the
-// widget at that offset is nil.
-func (wc *widgetCache) boundaryAt(offset int) *primitives.RepaintBoundary {
- if offset < 0 || offset >= len(wc.boundaries) {
- return nil
- }
- return wc.boundaries[offset]
-}
-
// invalidate marks the cache as needing a rebuild.
func (wc *widgetCache) invalidate() {
wc.valid = false
@@ -109,16 +138,6 @@ func (wc *widgetCache) invalidate() {
// clear resets the cache entirely and unmounts boundaries to free pixel caches.
func (wc *widgetCache) clear() {
- // Unmount boundaries to release pixel caches.
- for i := range wc.boundaries {
- if wc.boundaries[i] != nil {
- wc.boundaries[i].Unmount()
- }
- wc.boundaries[i] = nil
- }
- wc.boundaries = wc.boundaries[:0]
-
- // Clear widget references for GC.
for i := range wc.widgets {
wc.widgets[i] = nil
}
diff --git a/core/listview/event.go b/core/listview/event.go
index d88cbba..d2d53f0 100644
--- a/core/listview/event.go
+++ b/core/listview/event.go
@@ -32,7 +32,7 @@ func handleContentMouseEvent(lv *Widget, ctx widget.Context, e *event.MouseEvent
old := lv.hoveredIndex
lv.hoveredIndex = noHoveredIndex
lv.markItemDirty(old)
- ctx.InvalidateRect(lv.Bounds())
+ lv.invalidateItemRect(ctx, old)
}
return false
default:
@@ -58,11 +58,12 @@ func handleContentMouseMove(lv *Widget, ctx widget.Context, e *event.MouseEvent)
lv.hoveredIndex = idx
if old >= 0 {
lv.markItemDirty(old)
+ lv.invalidateItemRect(ctx, old)
}
if idx >= 0 {
lv.markItemDirty(idx)
+ lv.invalidateItemRect(ctx, idx)
}
- ctx.InvalidateRect(lv.Bounds())
}
return false // Don't consume move events.
}
@@ -199,14 +200,17 @@ func (w *Widget) setSelectedIndex(ctx widget.Context, index int) {
w.cfg.selectedIndex = index
}
- // Invalidate cache so item widgets rebuild with new selection state.
- w.cache.invalidate()
+ // Mark old and new selected items dirty (not entire ListView).
+ // No cache.invalidate() — cache.update detects selectedIndex change
+ // and rebuilds only when needed (not the entire visible range).
+ w.markItemDirty(current)
+ w.markItemDirty(index)
if w.cfg.onSelectionChange != nil {
w.cfg.onSelectionChange(index)
}
- ctx.Invalidate()
+ ctx.InvalidateRect(w.Bounds())
}
// noHoveredIndex indicates no item is currently hovered.
diff --git a/core/listview/internal_test.go b/core/listview/internal_test.go
index f5bdd9e..7b4d3d4 100644
--- a/core/listview/internal_test.go
+++ b/core/listview/internal_test.go
@@ -876,6 +876,7 @@ func (m *mockCanvas) PopClip() {}
func (m *mockCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -891,17 +892,22 @@ func TestWidgetCache_BoundariesCreated(t *testing.T) {
wc.update(0, 3, builder, -1, -1)
- if len(wc.boundaries) != 3 {
- t.Fatalf("len(boundaries) = %d, want 3", len(wc.boundaries))
+ if len(wc.widgets) != 3 {
+ t.Fatalf("len(widgets) = %d, want 3", len(wc.widgets))
}
for i := 0; i < 3; i++ {
- if wc.boundaries[i] == nil {
- t.Errorf("boundary[%d] is nil, want non-nil", i)
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Errorf("widget[%d] is nil, want non-nil", i)
+ continue
+ }
+ if !w.(*mockWidget).IsRepaintBoundary() {
+ t.Errorf("widget[%d].IsRepaintBoundary() = false, want true", i)
}
}
}
-func TestWidgetCache_BoundaryAt(t *testing.T) {
+func TestWidgetCache_WidgetAtWithBoundary(t *testing.T) {
var wc widgetCache
builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
w := &mockWidget{}
@@ -911,26 +917,30 @@ func TestWidgetCache_BoundaryAt(t *testing.T) {
wc.update(0, 3, builder, -1, -1)
- // Valid offsets.
+ // Valid offsets — widget should exist and be a repaint boundary.
for i := 0; i < 3; i++ {
- rb := wc.boundaryAt(i)
- if rb == nil {
- t.Errorf("boundaryAt(%d) = nil, want non-nil", i)
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Errorf("widgetAt(%d) = nil, want non-nil", i)
+ continue
+ }
+ if !w.(*mockWidget).IsRepaintBoundary() {
+ t.Errorf("widget[%d].IsRepaintBoundary() = false, want true", i)
}
}
// Out of range.
- if rb := wc.boundaryAt(-1); rb != nil {
- t.Error("boundaryAt(-1) should return nil")
+ if w := wc.widgetAt(-1); w != nil {
+ t.Error("widgetAt(-1) should return nil")
}
- if rb := wc.boundaryAt(3); rb != nil {
- t.Error("boundaryAt(3) should return nil")
+ if w := wc.widgetAt(3); w != nil {
+ t.Error("widgetAt(3) should return nil")
}
// Empty cache.
var empty widgetCache
- if rb := empty.boundaryAt(0); rb != nil {
- t.Error("boundaryAt on empty cache should return nil")
+ if w := empty.widgetAt(0); w != nil {
+ t.Error("widgetAt on empty cache should return nil")
}
}
@@ -943,8 +953,8 @@ func TestWidgetCache_BoundaryNilForNilWidget(t *testing.T) {
wc.update(0, 3, builder, -1, -1)
for i := 0; i < 3; i++ {
- if rb := wc.boundaryAt(i); rb != nil {
- t.Errorf("boundaryAt(%d) should be nil for nil widget", i)
+ if w := wc.widgetAt(i); w != nil {
+ t.Errorf("widgetAt(%d) should be nil for nil widget", i)
}
}
}
@@ -954,8 +964,8 @@ func TestWidgetCache_BoundaryNilBuilder(t *testing.T) {
wc.update(0, 3, nil, -1, -1)
for i := 0; i < 3; i++ {
- if rb := wc.boundaryAt(i); rb != nil {
- t.Errorf("boundaryAt(%d) should be nil with nil builder", i)
+ if w := wc.widgetAt(i); w != nil {
+ t.Errorf("widgetAt(%d) should be nil with nil builder", i)
}
}
}
@@ -972,14 +982,19 @@ func TestWidgetCache_BoundaryWrapsCorrectChild(t *testing.T) {
wc.update(0, 3, builder, -1, -1)
+ // With ADR-024, items are direct widgets with SetRepaintBoundary(true),
+ // not wrapped in a primitives.RepaintBoundary. Verify widgetAt returns
+ // the same widget the builder created and that it is a repaint boundary.
for i := 0; i < 3; i++ {
- rb := wc.boundaryAt(i)
- if rb == nil {
- t.Fatalf("boundary[%d] is nil", i)
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Fatalf("widget[%d] is nil", i)
+ }
+ if w != widgets[i] {
+ t.Errorf("widgetAt(%d) != original widget[%d]", i, i)
}
- child := rb.Child()
- if child != widgets[i] {
- t.Errorf("boundary[%d].Child() != widget[%d]", i, i)
+ if !w.(*mockWidget).IsRepaintBoundary() {
+ t.Errorf("widget[%d].IsRepaintBoundary() = false, want true", i)
}
}
}
@@ -994,17 +1009,21 @@ func TestWidgetCache_ClearUnmountsBoundaries(t *testing.T) {
wc.update(0, 3, builder, -1, -1)
- // Verify boundaries exist before clear.
+ // Verify widgets with boundary property exist before clear.
for i := 0; i < 3; i++ {
- if wc.boundaryAt(i) == nil {
- t.Fatalf("boundary[%d] nil before clear", i)
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Fatalf("widget[%d] nil before clear", i)
+ }
+ if !w.(*mockWidget).IsRepaintBoundary() {
+ t.Fatalf("widget[%d].IsRepaintBoundary() = false before clear", i)
}
}
wc.clear()
- if len(wc.boundaries) != 0 {
- t.Errorf("len(boundaries) = %d, want 0 after clear", len(wc.boundaries))
+ if len(wc.widgets) != 0 {
+ t.Errorf("len(widgets) = %d, want 0 after clear", len(wc.widgets))
}
}
@@ -1019,23 +1038,28 @@ func TestWidgetCache_InvalidateRebuildsBoundaries(t *testing.T) {
}}
wc.update(0, 3, builder, -1, -1)
- rb1 := wc.boundaryAt(0)
- if rb1 == nil {
- t.Fatal("boundary[0] nil before invalidate")
+ w1 := wc.widgetAt(0)
+ if w1 == nil {
+ t.Fatal("widget[0] nil before invalidate")
+ }
+ if !w1.(*mockWidget).IsRepaintBoundary() {
+ t.Fatal("widget[0].IsRepaintBoundary() = false before invalidate")
}
wc.invalidate()
wc.update(0, 3, builder, -1, -1)
- rb2 := wc.boundaryAt(0)
- if rb2 == nil {
- t.Fatal("boundary[0] nil after rebuild")
+ w2 := wc.widgetAt(0)
+ if w2 == nil {
+ t.Fatal("widget[0] nil after rebuild")
+ }
+ if !w2.(*mockWidget).IsRepaintBoundary() {
+ t.Fatal("widget[0].IsRepaintBoundary() = false after rebuild")
}
- // After invalidate + rebuild, the boundary should be a new instance
- // because the widget was rebuilt.
- if rb1 == rb2 {
- t.Error("boundary should be a new instance after invalidate + rebuild")
+ // After invalidate + rebuild, the widget should be a new instance.
+ if w1 == w2 {
+ t.Error("widget should be a new instance after invalidate + rebuild")
}
if callCount != 6 {
@@ -1057,13 +1081,17 @@ func TestWidgetCache_RangeShiftCreatesBoundaries(t *testing.T) {
// Shift range to [5, 8).
wc.update(5, 8, builder, -1, -1)
- if len(wc.boundaries) != 3 {
- t.Fatalf("len(boundaries) = %d, want 3", len(wc.boundaries))
+ if len(wc.widgets) != 3 {
+ t.Fatalf("len(widgets) = %d, want 3", len(wc.widgets))
}
for i := 0; i < 3; i++ {
- rb := wc.boundaryAt(i)
- if rb == nil {
- t.Errorf("boundary[%d] nil after range shift", i)
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Errorf("widget[%d] nil after range shift", i)
+ continue
+ }
+ if !w.(*mockWidget).IsRepaintBoundary() {
+ t.Errorf("widget[%d].IsRepaintBoundary() = false after range shift", i)
}
}
}
@@ -1103,18 +1131,16 @@ func TestMarkItemDirty_InRange(t *testing.T) {
}
}
- // The boundary at index 2 should have its cache invalidated.
- rb := lv.cache.boundaryAt(2)
- if rb == nil {
- t.Fatal("boundary at index 2 should not be nil")
- }
- if rb.CacheValid() {
- t.Error("boundary cache should be invalidated")
+ // The WidgetBase boundary at index 2 should have its scene invalidated.
+ if !item.(*mockWidget).IsSceneDirty() {
+ t.Error("item boundary scene should be dirty after markItemDirty")
}
- // ListView itself should need redraw.
+ // ListView IS marked dirty because hover/selection backgrounds are
+ // drawn in PaintItemBackground during root boundary recording.
+ // With DrawChild skip, root re-recording is cheap (items skipped).
if !lv.NeedsRedraw() {
- t.Error("ListView should be marked for redraw")
+ t.Error("ListView should be marked for redraw (hover background drawn in root scene)")
}
}
@@ -1218,3 +1244,661 @@ func TestSelectionMode_String(t *testing.T) {
}
}
}
+
+// --- Granular invalidation regression tests (2026-05-07) ---
+
+// TestSelectionChangeDirtiesOnlyTwoItems verifies that changing the selected
+// index only rebuilds the old and new selected items, not ALL items in cache.
+// Before the fix, setSelectedIndex called cache.invalidate() which rebuilt
+// every visible item, causing unnecessary widget allocation and layout.
+// Regression: setSelectedIndex called cache.invalidate() -> ALL items recreated (2026-05-07)
+func TestSelectionChangeDirtiesOnlyTwoItems(t *testing.T) {
+ var wc widgetCache
+ callCount := 0
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ callCount++
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+
+ // Initial build: 10 items, item 3 selected.
+ wc.update(0, 10, builder, 3, -1)
+ initialCount := callCount
+
+ if initialCount != 10 {
+ t.Fatalf("initial build: callCount = %d, want 10", initialCount)
+ }
+
+ // Save references to all widgets.
+ originalWidgets := make([]widget.Widget, 10)
+ for i := 0; i < 10; i++ {
+ originalWidgets[i] = wc.widgetAt(i)
+ }
+
+ // Change selection from 3 to 5 — should only rebuild items 3 and 5.
+ wc.rebuildAffected(0, builder, 5, -1)
+
+ rebuiltCount := callCount - initialCount
+ if rebuiltCount != 2 {
+ t.Errorf("rebuild count = %d, want 2 (only old+new selection); "+
+ "rebuildAffected should not recreate all items", rebuiltCount)
+ }
+
+ // Verify only items 3 and 5 were replaced.
+ for i := 0; i < 10; i++ {
+ current := wc.widgetAt(i)
+ if i == 3 || i == 5 {
+ if current == originalWidgets[i] {
+ t.Errorf("item %d should have been rebuilt (selection changed)", i)
+ }
+ } else {
+ if current != originalWidgets[i] {
+ t.Errorf("item %d should NOT have been rebuilt (selection did not affect it)", i)
+ }
+ }
+ }
+}
+
+// TestListViewNotDirtyOnItemClick verifies that markItemDirty marks the
+// ListView itself as needing redraw. This is required because hover/selection
+// backgrounds are drawn by PaintItemBackground during root boundary recording.
+// With DrawChild skip pattern, root re-recording is cheap (items are skipped).
+func TestListViewNotDirtyOnItemClick(t *testing.T) {
+ var wc widgetCache
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ w.SetEnabled(true)
+ return w
+ }}
+
+ wc.update(0, 10, builder, -1, -1)
+
+ lv := &Widget{
+ hoveredIndex: noHoveredIndex,
+ }
+ lv.SetVisible(true)
+ lv.SetEnabled(true)
+ lv.cache = wc
+
+ // Clear any initial redraw state.
+ lv.ClearRedraw()
+
+ // Mark a single item dirty (simulates click/hover on item 3).
+ lv.markItemDirty(3)
+
+ // The ListView itself SHOULD be marked dirty — hover/selection backgrounds
+ // are painted by PaintItemBackground during root boundary recording.
+ if !lv.NeedsRedraw() {
+ t.Error("markItemDirty should mark the ListView for redraw; " +
+ "PaintItemBackground runs during root re-recording")
+ }
+
+ // But the item at index 3 should be marked.
+ item := lv.cache.widgetAt(3)
+ if item == nil {
+ t.Fatal("item at index 3 should not be nil")
+ }
+ if base, ok := item.(interface{ NeedsRedraw() bool }); ok {
+ if !base.NeedsRedraw() {
+ t.Error("item at index 3 should need redraw")
+ }
+ }
+}
+
+// TestVirtualContentExposesChildrenForDirtyCollector verifies that
+// virtualContent.Children() returns the cached RepaintBoundary wrappers.
+// Before the fix, Children() returned nil, so the dirty.Collector could
+// not see individual items and could not report per-item dirty regions.
+// Regression: virtualContent.Children() returned nil -> Collector missed individual items (2026-05-07)
+func TestVirtualContentExposesChildrenForDirtyCollector(t *testing.T) {
+ lv := New(
+ ItemCount(5),
+ FixedItemHeight(48),
+ )
+
+ var wc widgetCache
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+ wc.update(0, 5, builder, -1, -1)
+ lv.cache = wc
+
+ vc := &virtualContent{list: lv}
+ children := vc.Children()
+
+ if children == nil {
+ t.Fatal("virtualContent.Children() must not return nil when cache has items; " +
+ "dirty.Collector needs children to collect per-item dirty regions")
+ }
+
+ if len(children) != 5 {
+ t.Errorf("len(Children()) = %d, want 5 (one per visible item)", len(children))
+ }
+}
+
+// --- ADR-024 ListView + RepaintBoundary Regression Tests ---
+//
+// These verify that ListView items wrapped in RepaintBoundary correctly
+// propagate dirty state and interact with parent boundary/ScrollView clip.
+
+// TestListView_ItemBoundaryDirtyPropagation verifies that invalidating an item's
+// scene only affects that item, not the whole ListView.
+func TestListView_ItemBoundaryDirtyPropagation(t *testing.T) {
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+
+ var wc widgetCache
+ wc.update(0, 5, builder, -1, -1)
+
+ for i := 0; i < 5; i++ {
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Fatalf("widget[%d] is nil", i)
+ }
+ w.(*mockWidget).ClearSceneDirty()
+ }
+
+ wc.widgetAt(2).(*mockWidget).InvalidateScene()
+
+ for i := 0; i < 5; i++ {
+ w := wc.widgetAt(i).(*mockWidget)
+ if i == 2 {
+ if !w.IsSceneDirty() {
+ t.Errorf("widget[%d] scene should be dirty (was explicitly invalidated)", i)
+ }
+ } else {
+ if w.IsSceneDirty() {
+ t.Errorf("widget[%d] scene should be clean (only item 2 was invalidated)", i)
+ }
+ }
+ }
+}
+
+// TestListView_ScrollChangesVisibleRange verifies that scrolling changes
+// the visible item range returned by visibleRange with overscan.
+func TestListView_ScrollChangesVisibleRange(t *testing.T) {
+ cfg := &config{
+ itemCount: 100,
+ itemHeightFn: func(_ int) float32 { return 36 },
+ overscan: 3,
+ }
+ hm := newHeightManager(cfg)
+
+ start, end := hm.visibleRange(0, 200, 3)
+ if start != 0 {
+ t.Errorf("start = %d at scroll=0, want 0", start)
+ }
+ if end < 5 {
+ t.Errorf("end = %d at scroll=0, want >= 5 (visible + overscan)", end)
+ }
+
+ start2, end2 := hm.visibleRange(1000, 200, 3)
+ if start2 <= 0 {
+ t.Errorf("start = %d at scroll=1000, want > 0", start2)
+ }
+ if end2 <= end {
+ t.Errorf("end = %d at scroll=1000, should be > %d (previous end)", end2, end)
+ }
+}
+
+// TestListView_MarkItemDirtyPropagatesUpward verifies that markItemDirty
+// uses SetNeedsRedraw (not MarkRedrawLocal) on the item widget
+// so dirty state propagates to the root WidgetBase boundary.
+// Items are boundaries (ADR-024). SetNeedsRedraw on a boundary widget
+// invalidates its OWN scene and does NOT propagate to parent.
+// This is the Flutter markNeedsPaint pattern: dirty stops at nearest boundary.
+func TestListView_MarkItemDirtyStopsAtItemBoundary(t *testing.T) {
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+
+ lv := &Widget{}
+ lv.SetVisible(true)
+ lv.SetEnabled(true)
+ lv.cache.update(0, 5, builder, -1, -1)
+
+ // Create a parent boundary to verify propagation STOPS at item.
+ parent := &boundaryTracker{}
+ parent.SetVisible(true)
+ parent.SetRepaintBoundary(true)
+
+ // Wire parent chain on each item widget (ADR-024 WidgetBase boundary).
+ for i := 0; i < 5; i++ {
+ w := lv.cache.widgetAt(i)
+ if w == nil {
+ t.Fatalf("widget[%d] nil", i)
+ }
+ w.(*mockWidget).SetParent(parent)
+ w.(*mockWidget).ClearRedraw()
+ }
+ parent.ClearSceneDirty()
+ parent.sceneDirtied = false
+
+ // markItemDirty on item 2.
+ lv.markItemDirty(2)
+
+ // The item widget should be marked as needing redraw.
+ w2 := lv.cache.widgetAt(2).(*mockWidget)
+ if !w2.NeedsRedraw() {
+ t.Error("widget[2].NeedsRedraw() = false after markItemDirty")
+ }
+
+ // Item IS a boundary → SetNeedsRedraw calls InvalidateScene on SELF,
+ // does NOT propagate to parent. Parent boundary stays clean.
+ // This is critical: only the 48px item re-records, not the entire tree.
+ if !w2.IsSceneDirty() {
+ t.Error("item boundary should be scene-dirty (self-invalidated)")
+ }
+ if parent.sceneDirtied {
+ t.Error("parent boundary should NOT be dirty; " +
+ "item IS a boundary, propagation must stop at item level " +
+ "(Flutter markNeedsPaint pattern)")
+ }
+}
+
+// boundaryTracker is a test widget that tracks InvalidateScene calls.
+type boundaryTracker struct {
+ widget.WidgetBase
+ sceneDirtied bool
+}
+
+func (w *boundaryTracker) InvalidateScene() {
+ w.WidgetBase.InvalidateScene()
+ w.sceneDirtied = true
+}
+func (w *boundaryTracker) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(400, 300))
+}
+func (w *boundaryTracker) Draw(_ widget.Context, _ widget.Canvas) {}
+func (w *boundaryTracker) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *boundaryTracker) Children() []widget.Widget { return nil }
+
+// TestListView_MarkItemDirtyIsolatedFromRoot verifies the 3-level chain:
+// root(boundary) → lv → item(boundary). markItemDirty dirties BOTH the item
+// AND the ListView (which propagates to root). Root re-recording is cheap
+// because items are skipped via DrawChild boundary check (Flutter paintChild).
+func TestListView_MarkItemDirtyIsolatedFromRoot(t *testing.T) {
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+
+ lv := &Widget{}
+ lv.SetVisible(true)
+ lv.SetEnabled(true)
+ lv.cache.update(0, 5, builder, -1, -1)
+
+ // Build 3-level chain: root(boundary) → lv → item(boundary)
+ root := &boundaryTracker{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+
+ // Wire parent chain: item → lv → root
+ lv.SetParent(root)
+ for i := 0; i < 5; i++ {
+ w := lv.cache.widgetAt(i)
+ if w == nil {
+ t.Fatalf("widget[%d] nil", i)
+ }
+ w.(*mockWidget).SetParent(lv)
+ w.(*mockWidget).ClearRedraw()
+ }
+ root.ClearSceneDirty()
+ root.sceneDirtied = false
+ lv.ClearRedraw()
+
+ // markItemDirty on item 2.
+ lv.markItemDirty(2)
+
+ // Item should be dirty.
+ w2 := lv.cache.widgetAt(2).(*mockWidget)
+ if !w2.NeedsRedraw() {
+ t.Error("widget[2] should need redraw after markItemDirty")
+ }
+
+ // Item IS boundary → item scene is dirty.
+ if !w2.IsSceneDirty() {
+ t.Error("item boundary should be scene-dirty (self-invalidated)")
+ }
+ // Root SHOULD also be dirty — markItemDirty calls SetNeedsRedraw on
+ // the ListView, which propagates to root. Root re-recording is cheap
+ // because DrawChild skips item boundaries (Flutter paintChild pattern).
+ if !root.sceneDirtied {
+ t.Error("root boundary should be dirty; " +
+ "markItemDirty sets SetNeedsRedraw on ListView for PaintItemBackground")
+ }
+}
+
+// TestListView_HoverChangesVisibleOnRedraw verifies the full hover cycle:
+// mouse move → hoveredIndex changes → markItemDirty → dirty propagates to root
+// boundary → scene re-recorded → new hover background visible.
+func TestListView_HoverChangesVisibleOnRedraw(t *testing.T) {
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+
+ lv := &Widget{}
+ lv.SetVisible(true)
+ lv.SetEnabled(true)
+ lv.cfg = config{
+ itemCount: 10,
+ itemHeightFn: func(_ int) float32 { return 48 },
+ overscan: defaultOverscan,
+ itemContent: builder,
+ }
+ lv.painter = DefaultPainter{}
+ lv.heights = newHeightManager(&lv.cfg)
+
+ ctx := widget.NewContext()
+ invalidateRectCalled := false
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {
+ invalidateRectCalled = true
+ })
+
+ // Set initial state: no hover.
+ lv.hoveredIndex = noHoveredIndex
+
+ // Simulate mouse move at Y=100 → should hit item 2 (48px each, item2 = 96-144).
+ me := &event.MouseEvent{
+ MouseType: event.MouseMove,
+ Position: geometry.Pt(200, 100),
+ }
+ handleContentMouseMove(lv, ctx, me)
+
+ if lv.hoveredIndex != 2 {
+ t.Errorf("hoveredIndex = %d after mouse at Y=100, want 2", lv.hoveredIndex)
+ }
+
+ if !invalidateRectCalled {
+ t.Error("InvalidateRect not called after hover change")
+ }
+
+ // Move to item 4 (Y=200, item4 = 192-240).
+ invalidateRectCalled = false
+ me2 := &event.MouseEvent{
+ MouseType: event.MouseMove,
+ Position: geometry.Pt(200, 200),
+ }
+ handleContentMouseMove(lv, ctx, me2)
+
+ if lv.hoveredIndex != 4 {
+ t.Errorf("hoveredIndex = %d after mouse at Y=200, want 4", lv.hoveredIndex)
+ }
+
+ if !invalidateRectCalled {
+ t.Error("InvalidateRect not called after hover change to item 4")
+ }
+}
+
+// TestListView_WheelEventDispatch verifies that mouse wheel events reach
+// the ScrollView inside ListView and trigger scroll + redraw.
+func TestListView_WheelEventDispatch(t *testing.T) {
+ lv := New(
+ ItemCount(20),
+ FixedItemHeight(48),
+ BuildItem(func(_ ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }),
+ )
+
+ ctx := widget.NewContext()
+ invalidateCalled := false
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {
+ invalidateCalled = true
+ })
+
+ constraints := geometry.Constraints{
+ MinWidth: 400, MaxWidth: 400,
+ MinHeight: 200, MaxHeight: 200,
+ }
+ lv.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 200))
+
+ widget.MountTree(lv, ctx)
+
+ // Simulate wheel event inside viewport.
+ wheel := &event.WheelEvent{
+ Position: geometry.Pt(200, 100),
+ Delta: geometry.Pt(0, 3),
+ }
+ consumed := lv.Event(ctx, wheel)
+
+ // ListView should forward wheel to ScrollView.
+ if !consumed && !invalidateCalled {
+ t.Error("wheel event not consumed and no InvalidateRect; " +
+ "event may not reach ScrollView inside ListView")
+ }
+}
+
+// TestListView_MouseMoveDispatchToContent verifies that MouseMove events
+// reach the virtualContent and update hoveredIndex.
+func TestListView_MouseMoveDispatchToContent(t *testing.T) {
+ lv := New(
+ ItemCount(20),
+ FixedItemHeight(48),
+ BuildItem(func(_ ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }),
+ )
+
+ ctx := widget.NewContext()
+ constraints := geometry.Constraints{
+ MinWidth: 400, MaxWidth: 400,
+ MinHeight: 200, MaxHeight: 200,
+ }
+ lv.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 200))
+
+ widget.MountTree(lv, ctx)
+
+ // Draw first frame to populate cache.
+ canvas := &mockCanvas{}
+ lv.Draw(ctx, canvas)
+
+ // Mouse move at Y=100 → should hover item 2 (48px items).
+ me := &event.MouseEvent{
+ MouseType: event.MouseMove,
+ Position: geometry.Pt(200, 100),
+ }
+ lv.Event(ctx, me)
+
+ if lv.hoveredIndex < 0 {
+ t.Errorf("hoveredIndex = %d after MouseMove at Y=100, want >= 0", lv.hoveredIndex)
+ }
+}
+
+// TestListView_HoverPaintCalledOnDraw verifies that after hover changes,
+// a subsequent Draw() calls PaintItemBackground with Hovered=true for the
+// hovered item. This ensures the painter receives the correct hover state.
+func TestListView_HoverPaintCalledOnDraw(t *testing.T) {
+ lv := New(
+ ItemCount(10),
+ FixedItemHeight(48),
+ BuildItem(func(_ ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }),
+ )
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ constraints := geometry.Constraints{
+ MinWidth: 400, MaxWidth: 400,
+ MinHeight: 200, MaxHeight: 200,
+ }
+ lv.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 200))
+ widget.MountTree(lv, ctx)
+
+ // First draw to populate cache.
+ canvas := &mockCanvas{}
+ lv.Draw(ctx, canvas)
+
+ // Simulate hover on item 2.
+ me := &event.MouseEvent{
+ MouseType: event.MouseMove,
+ Position: geometry.Pt(200, 100), // Y=100 → item 2 (48px items)
+ }
+ lv.Event(ctx, me)
+
+ if lv.hoveredIndex != 2 {
+ t.Fatalf("hoveredIndex = %d, want 2", lv.hoveredIndex)
+ }
+
+ // Track painter calls.
+ tp := &trackingPainter{}
+ lv.painter = tp
+
+ // Draw again — should paint item 2 with Hovered=true.
+ lv.Draw(ctx, canvas)
+
+ foundHover := false
+ for _, ps := range tp.bgCalls {
+ if ps.Index == 2 && ps.Hovered {
+ foundHover = true
+ break
+ }
+ }
+
+ if !foundHover {
+ t.Error("PaintItemBackground not called with Hovered=true for item 2; " +
+ "hover state not reaching painter on redraw")
+ }
+}
+
+// trackingPainter records PaintItemBackground calls for testing.
+type trackingPainter struct {
+ DefaultPainter
+ bgCalls []ItemPaintState
+}
+
+func (p *trackingPainter) PaintItemBackground(_ widget.Canvas, ps ItemPaintState) {
+ p.bgCalls = append(p.bgCalls, ps)
+}
+
+// TestListView_RootBoundaryCacheInvalidatedOnHover verifies that hover changes
+// invalidate the root WidgetBase boundary cache, forcing scene re-record.
+func TestListView_RootBoundaryCacheInvalidatedOnHover(t *testing.T) {
+ lv := New(
+ ItemCount(10),
+ FixedItemHeight(48),
+ BuildItem(func(_ ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }),
+ )
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ constraints := geometry.Constraints{
+ MinWidth: 400, MaxWidth: 400,
+ MinHeight: 200, MaxHeight: 200,
+ }
+ lv.Layout(ctx, constraints)
+ lv.SetBounds(geometry.NewRect(0, 0, 400, 200))
+ widget.MountTree(lv, ctx)
+
+ // Create root boundary tracker.
+ root := &boundaryTracker{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ lv.SetParent(root)
+
+ // Wire item widgets to lv as parent.
+ for i := 0; i < len(lv.cache.widgets); i++ {
+ if w := lv.cache.widgetAt(i); w != nil {
+ if setter, ok := w.(interface{ SetParent(widget.Widget) }); ok {
+ setter.SetParent(lv)
+ }
+ }
+ }
+
+ // First draw.
+ canvas := &mockCanvas{}
+ lv.Draw(ctx, canvas)
+
+ // Clear all dirty state.
+ root.ClearSceneDirty()
+ root.sceneDirtied = false
+ lv.ClearRedraw()
+ for i := 0; i < len(lv.cache.widgets); i++ {
+ if w := lv.cache.widgetAt(i); w != nil {
+ if clearer, ok := w.(interface {
+ ClearSceneDirty()
+ ClearRedraw()
+ }); ok {
+ clearer.ClearSceneDirty()
+ clearer.ClearRedraw()
+ }
+ }
+ }
+
+ // Simulate hover change on item 2.
+ me := &event.MouseEvent{
+ MouseType: event.MouseMove,
+ Position: geometry.Pt(200, 100),
+ }
+ lv.Event(ctx, me)
+
+ if lv.hoveredIndex != 2 {
+ t.Fatalf("hoveredIndex = %d, want 2", lv.hoveredIndex)
+ }
+
+ // Root SHOULD be dirty — hover triggers markItemDirty which calls
+ // SetNeedsRedraw on the ListView. PaintItemBackground draws hover
+ // backgrounds during root boundary recording. Root re-recording is
+ // cheap because DrawChild skips item boundaries (Flutter paintChild).
+ if !root.sceneDirtied {
+ t.Error("root boundary should be dirty on hover; " +
+ "PaintItemBackground draws hover background during root re-recording")
+ }
+}
+
+// TestListView_BoundaryItemBoundsInContentSpace verifies that item widgets
+// (with WidgetBase boundary) have bounds set in content space (Y = cumulative
+// item offset from content start), NOT in viewport/screen space.
+func TestListView_BoundaryItemBoundsInContentSpace(t *testing.T) {
+ builder := cdk.FuncContent[ItemContext]{Fn: func(ctx ItemContext) widget.Widget {
+ w := &mockWidget{}
+ w.SetVisible(true)
+ return w
+ }}
+
+ var wc widgetCache
+ wc.update(0, 5, builder, -1, -1)
+
+ for i := 0; i < 5; i++ {
+ w := wc.widgetAt(i)
+ if w == nil {
+ t.Fatalf("widget[%d] nil", i)
+ }
+ bounds := geometry.NewRect(0, float32(i*48), 400, 48)
+ w.(*mockWidget).SetBounds(bounds)
+
+ got := w.(*mockWidget).Bounds()
+ if got.Min.Y != float32(i*48) {
+ t.Errorf("widget[%d] bounds.Min.Y = %v, want %v",
+ i, got.Min.Y, float32(i*48))
+ }
+ }
+}
diff --git a/core/listview/listview_test.go b/core/listview/listview_test.go
index 0960f0b..4f79f57 100644
--- a/core/listview/listview_test.go
+++ b/core/listview/listview_test.go
@@ -1610,6 +1610,7 @@ func (m *mockCanvas) PopClip() {}
func (m *mockCanvas) PushTransform(_ geometry.Point) {}
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) {}
diff --git a/core/listview/virtual_content.go b/core/listview/virtual_content.go
index 3ebd9f4..af85e8a 100644
--- a/core/listview/virtual_content.go
+++ b/core/listview/virtual_content.go
@@ -64,6 +64,17 @@ func (vc *virtualContent) Draw(ctx widget.Context, canvas widget.Canvas) {
// Update the widget cache for the visible range.
lv.cache.update(start, end, lv.cfg.itemContent, selectedIdx, lv.hoveredIndex)
+ // Wire parent chain on item widgets so dirty propagation
+ // (SetNeedsRedraw → propagateDirtyUpward) can reach the root WidgetBase
+ // boundary. Flutter adoptChild pattern.
+ for i := 0; i < end-start; i++ {
+ if w := lv.cache.widgetAt(i); w != nil {
+ if setter, ok := w.(interface{ SetParent(widget.Widget) }); ok {
+ setter.SetParent(vc)
+ }
+ }
+ }
+
// Content width excludes scrollbar inset so items don't render under it.
contentWidth := lv.viewportWidth - lv.scroll.ScrollbarInset()
@@ -114,15 +125,8 @@ func (vc *virtualContent) Draw(ctx widget.Context, canvas widget.Canvas) {
lv.painter.PaintSelection(canvas, ips)
}
- // Draw item via RepaintBoundary wrapper (Phase 2, ADR-004).
- rb := lv.cache.boundaryAt(offset)
- if rb != nil {
- rb.Layout(ctx, itemConstraints)
- rb.SetBounds(itemBounds)
- rb.Draw(ctx, canvas)
- } else {
- w.Draw(ctx, canvas)
- }
+ widget.StampScreenOrigin(w, canvas)
+ widget.DrawChild(w, ctx, canvas)
// Draw divider between items (not after the last visible item).
if lv.cfg.divider && i < end-1 {
@@ -136,6 +140,11 @@ func (vc *virtualContent) Draw(ctx widget.Context, canvas widget.Canvas) {
// Check end-reached callback.
lv.checkEndReached(end, itemCount)
+
+ // Clear dirty — individual items track their own dirty state.
+ // Without this, virtualContent (bounds=full content height) stays
+ // permanently dirty, causing huge dirty regions in the overlay.
+ vc.ClearRedraw()
}
// Event delegates events back to the parent list for item interaction.
@@ -146,7 +155,22 @@ func (vc *virtualContent) Event(ctx widget.Context, e event.Event) bool {
return handleContentEvent(vc.list, ctx, e)
}
-// Children returns nil; visible item widgets are ephemeral and managed by the cache.
+// Children returns the cached item widgets for dirty-region collection.
+// Their ScreenBounds (set during the previous Draw) allow the dirty.Collector to
+// report item-level dirty rects clipped to the viewport.
func (vc *virtualContent) Children() []widget.Widget {
- return nil
+ if vc.list == nil {
+ return nil
+ }
+ widgets := vc.list.cache.widgets
+ if len(widgets) == 0 {
+ return nil
+ }
+ children := make([]widget.Widget, 0, len(widgets))
+ for _, w := range widgets {
+ if w != nil {
+ children = append(children, w)
+ }
+ }
+ return children
}
diff --git a/core/listview/widget.go b/core/listview/widget.go
index 82c9c2b..28b8293 100644
--- a/core/listview/widget.go
+++ b/core/listview/widget.go
@@ -168,6 +168,13 @@ func (w *Widget) Draw(ctx widget.Context, canvas widget.Canvas) {
// Set scroll view bounds to match our bounds.
w.scroll.SetBounds(bounds)
+ // Stamp screen origin on the internal scroll view so its ScreenBounds()
+ // returns correct window-space coordinates for dirty region collection.
+ // Without this, the scroll view's screenOrigin stays at (0,0) and its
+ // dirty region covers the wrong part of the window (top-left corner
+ // instead of the actual list view position).
+ widget.StampScreenOrigin(w.scroll, canvas)
+
// Delegate drawing to the internal scroll view.
// The scroll view clips, translates, and draws our virtual content.
w.scroll.Draw(ctx, canvas)
@@ -357,29 +364,57 @@ func (w *Widget) AccessibilityActions() []a11y.Action {
//
// The hover state is passed to the Painter at paint-time via the
// virtualContent.Draw method, so the widget tree does NOT need rebuilding.
+//
+// The RepaintBoundary wrapper is marked dirty so the dirty.Collector
+// (via collectViewportChildren) reports only the affected items' bounds
+// clipped to the viewport — not the entire ListView area.
func (w *Widget) markItemDirty(index int) {
offset := index - w.cache.startIndex
if offset < 0 || offset >= len(w.cache.widgets) {
- return // Item is not in the visible/cached range.
+ return
}
- // Mark the cached widget as needing redraw.
if item := w.cache.widgetAt(offset); item != nil {
if setter, ok := item.(interface{ SetNeedsRedraw(bool) }); ok {
setter.SetNeedsRedraw(true)
}
}
- // Also mark the RepaintBoundary wrapper so its cache is invalidated.
- if rb := w.cache.boundaryAt(offset); rb != nil {
- rb.InvalidateCache()
- rb.SetNeedsRedraw(true)
- }
-
- // Mark self as needing redraw (paint-only, no layout).
+ // Hover/selection background is drawn by PaintItemBackground in
+ // updateVirtualContent (root boundary recording). Dirty the root
+ // so it re-records with the updated hoveredIndex. With DrawChild
+ // skip, root recording is cheap — items are SKIPPED, only structure
+ // (title, checkboxes, ScrollView frame, hover backgrounds) re-records.
w.SetNeedsRedraw(true)
}
+// invalidateItemRect requests redraw for a single item's bounds.
+// Uses item screen bounds (clipped to viewport) instead of entire ListView
+// bounds — produces small dirty rects for overlay and damage tracking.
+func (w *Widget) invalidateItemRect(ctx widget.Context, index int) {
+ offset := index - w.cache.startIndex
+ if offset < 0 || offset >= len(w.cache.widgets) {
+ ctx.InvalidateRect(w.Bounds())
+ return
+ }
+ if item := w.cache.widgetAt(offset); item != nil { //nolint:nestif // item recycling with type assertion chain for screen bounds fallback
+ type screenBounder interface{ ScreenBounds() geometry.Rect }
+ if sb, ok := item.(screenBounder); ok {
+ bounds := sb.ScreenBounds()
+ if !bounds.IsEmpty() {
+ ctx.InvalidateRect(bounds)
+ return
+ }
+ }
+ type bounder interface{ Bounds() geometry.Rect }
+ if b, ok := item.(bounder); ok {
+ ctx.InvalidateRect(b.Bounds())
+ return
+ }
+ }
+ ctx.InvalidateRect(w.Bounds())
+}
+
// currentScrollY returns the current vertical scroll offset.
func (w *Widget) currentScrollY() float32 {
_, y := w.scroll.ScrollOffset()
diff --git a/core/menu/menu_test.go b/core/menu/menu_test.go
index 3c4d718..37f0760 100644
--- a/core/menu/menu_test.go
+++ b/core/menu/menu_test.go
@@ -2002,6 +2002,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -2035,5 +2036,6 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/popover/popover_test.go b/core/popover/popover_test.go
index fc048b2..f8c6e36 100644
--- a/core/popover/popover_test.go
+++ b/core/popover/popover_test.go
@@ -2048,6 +2048,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -2080,5 +2081,6 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/progress/boundary_recording_test.go b/core/progress/boundary_recording_test.go
new file mode 100644
index 0000000..fd6356b
--- /dev/null
+++ b/core/progress/boundary_recording_test.go
@@ -0,0 +1,66 @@
+package progress
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/geometry"
+ internalRender "github.com/gogpu/ui/internal/render"
+ "github.com/gogpu/ui/widget"
+)
+
+// TestSpinner_SceneRecordingProducesContent verifies that recording
+// an indeterminate spinner into a SceneCanvas produces a non-empty scene.
+// If this fails, the spinner is invisible in compositor pipeline because
+// its PictureLayer has an empty scene.
+func TestSpinner_SceneRecordingProducesContent(t *testing.T) {
+ w := New(Indeterminate(true), Size(48))
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ constraints := geometry.Constraints{
+ MinWidth: 48, MaxWidth: 48,
+ MinHeight: 48, MaxHeight: 48,
+ }
+ w.Layout(ctx, constraints)
+ w.SetBounds(geometry.NewRect(0, 0, 48, 48))
+
+ // Record into SceneCanvas (same path as compositor PaintBoundaryLayers).
+ sc := scene.NewScene()
+ recorder := internalRender.NewSceneCanvas(sc, 48, 48)
+
+ w.Draw(ctx, recorder)
+ recorder.Close()
+
+ if sc.IsEmpty() {
+ t.Error("spinner scene is EMPTY after recording into SceneCanvas; " +
+ "spinner will be invisible in compositor pipeline. " +
+ "Check if SceneCanvas supports StrokeArc (used by spinner painter)")
+ }
+}
+
+// TestDeterminate_SceneRecordingProducesContent verifies determinate
+// progress also records into SceneCanvas.
+func TestDeterminate_SceneRecordingProducesContent(t *testing.T) {
+ w := New(Value(0.42), Size(48), ShowLabel(true))
+
+ ctx := widget.NewContext()
+
+ constraints := geometry.Constraints{
+ MinWidth: 48, MaxWidth: 48,
+ MinHeight: 48, MaxHeight: 48,
+ }
+ w.Layout(ctx, constraints)
+ w.SetBounds(geometry.NewRect(0, 0, 48, 48))
+
+ sc := scene.NewScene()
+ recorder := internalRender.NewSceneCanvas(sc, 48, 48)
+
+ w.Draw(ctx, recorder)
+ recorder.Close()
+
+ if sc.IsEmpty() {
+ t.Error("determinate progress scene is EMPTY after SceneCanvas recording")
+ }
+}
diff --git a/core/progress/progress.go b/core/progress/progress.go
index 0538d2b..0370149 100644
--- a/core/progress/progress.go
+++ b/core/progress/progress.go
@@ -50,6 +50,14 @@ func New(opts ...Option) *Widget {
w.painter = w.cfg.painter
}
+ // Indeterminate spinners animate every frame (SetNeedsRedraw in Draw).
+ // Mark as RepaintBoundary so dirty propagation stops here — only the
+ // spinner's 48×48 scene re-records, not the entire parent tree.
+ // Flutter: CircularProgressIndicator is always at its own boundary.
+ if w.cfg.indeterminate {
+ w.SetRepaintBoundary(true)
+ }
+
return w
}
@@ -94,8 +102,22 @@ func (w *Widget) Layout(_ widget.Context, constraints geometry.Constraints) geom
diameter = defaultDiameter
}
- preferred := geometry.Sz(diameter, diameter)
- return constraints.Constrain(preferred)
+ // Circular progress prefers diameter×diameter but must not expand
+ // beyond that when parent gives wide constraints (VBox MinWidth=parent).
+ // Tighten MaxWidth/MaxHeight to diameter so Constrain doesn't expand.
+ // Flutter: CircularProgressIndicator wrapped in SizedBox(diameter).
+ // Circular indicator is intrinsically sized: always diameter×diameter.
+ // Ignore parent MinWidth/MinHeight (VBox gives MinWidth=parent width).
+ // Respect parent MaxWidth/MaxHeight only if smaller than diameter (Tight).
+ sw := diameter
+ sh := diameter
+ if constraints.MaxWidth < sw {
+ sw = constraints.MaxWidth
+ }
+ if constraints.MaxHeight < sh {
+ sh = constraints.MaxHeight
+ }
+ return geometry.Sz(sw, sh)
}
// Draw renders the circular progress indicator to the canvas.
@@ -164,8 +186,17 @@ func (w *Widget) drawIndeterminate(ctx widget.Context, canvas widget.Canvas, bou
}
w.painter.PaintProgress(canvas, ps)
- w.MarkRedrawLocal()
- ctx.InvalidateRect(w.Bounds())
+ w.SetNeedsRedraw(true)
+
+ // Request next animation frame via deferred scheduling (Flutter scheduleFrame
+ // pattern). Does NOT trigger immediate RequestRedraw — the animation pumper
+ // controls actual frame rate. Falls back to immediate InvalidateRect if
+ // AnimationScheduler not available (headless tests, legacy contexts).
+ if sched, ok := ctx.(widget.AnimationScheduler); ok {
+ sched.ScheduleAnimationFrame()
+ } else {
+ ctx.InvalidateRect(w.Bounds())
+ }
}
// elapsedSeconds returns seconds since the spinner started.
diff --git a/core/progress/progress_test.go b/core/progress/progress_test.go
index b6e39fc..72a5617 100644
--- a/core/progress/progress_test.go
+++ b/core/progress/progress_test.go
@@ -152,18 +152,37 @@ func TestSetValue_SameValueNoRedraw(t *testing.T) {
// --- Layout Tests ---
-func TestLayout_RespectsConstraints(t *testing.T) {
+func TestLayout_TightConstraintsLargerThanDiameter(t *testing.T) {
ctx := widget.NewContext()
+ // Tight(64,64) = Min=Max=64. Spinner diameter=48.
+ // Spinner is intrinsically sized — returns diameter, not parent's tight.
+ // Flutter: CircularProgressIndicator inside SizedBox(48) ignores parent.
constraints := geometry.Tight(geometry.Sz(64, 64))
- w := progress.New()
+ w := progress.New() // default diameter=48
+ size := w.Layout(ctx, constraints)
+
+ if size.Width != 48 {
+ t.Errorf("width = %v, want 48 (diameter, not parent tight 64)", size.Width)
+ }
+ if size.Height != 48 {
+ t.Errorf("height = %v, want 48 (diameter, not parent tight 64)", size.Height)
+ }
+}
+
+func TestLayout_TightConstraintsSmallerThanDiameter(t *testing.T) {
+ ctx := widget.NewContext()
+ // Tight(32,32) = constrained smaller than diameter. Respect it.
+ constraints := geometry.Tight(geometry.Sz(32, 32))
+
+ w := progress.New() // default diameter=48
size := w.Layout(ctx, constraints)
- if size.Width != 64 {
- t.Errorf("width = %v, want 64 (tight)", size.Width)
+ if size.Width != 32 {
+ t.Errorf("width = %v, want 32 (tight < diameter)", size.Width)
}
- if size.Height != 64 {
- t.Errorf("height = %v, want 64 (tight)", size.Height)
+ if size.Height != 32 {
+ t.Errorf("height = %v, want 32 (tight < diameter)", size.Height)
}
}
@@ -204,6 +223,27 @@ func TestLayout_CustomSize(t *testing.T) {
}
}
+// TestLayout_DoesNotExpandInVBox verifies that spinner returns EXACT
+// diameter×diameter even when parent VBox gives wide MinWidth constraints.
+// Without this, spinner bounds = 800×48 → cyan dirty overlay shows full width.
+func TestLayout_DoesNotExpandInVBox(t *testing.T) {
+ ctx := widget.NewContext()
+ // VBox gives: MinWidth=800, MaxWidth=800, MinHeight=0, MaxHeight=600
+ constraints := geometry.BoxConstraints(800, 800, 0, 600)
+
+ w := progress.New(progress.Size(48))
+ size := w.Layout(ctx, constraints)
+
+ if size.Width != 48 {
+ t.Errorf("spinner width = %v, want 48; spinner should NOT expand "+
+ "to parent width (VBox MinWidth=800). Current: spinner occupies "+
+ "800px wide dirty region instead of 48px", size.Width)
+ }
+ if size.Height != 48 {
+ t.Errorf("spinner height = %v, want 48", size.Height)
+ }
+}
+
// --- Draw Tests ---
func TestDraw_EmptyBounds(t *testing.T) {
@@ -364,13 +404,23 @@ func TestDraw_IndeterminateRequestsRedraw(t *testing.T) {
w.SetBounds(geometry.NewRect(0, 0, 48, 48))
ctx := widget.NewContext()
ctx.SetNow(time.Now())
+
+ // Track ScheduleAnimationFrame calls (enterprise animation scheduling).
+ animFrameScheduled := false
+ ctx.SetOnScheduleAnimation(func() {
+ animFrameScheduled = true
+ })
+
canvas := &recordingCanvas{}
w.ClearRedraw()
w.Draw(ctx, canvas)
- if ctx.InvalidatedRect().IsEmpty() {
- t.Error("indeterminate should call InvalidateRect after draw")
+ if !animFrameScheduled {
+ t.Error("indeterminate should call ScheduleAnimationFrame after draw")
+ }
+ if !w.NeedsRedraw() {
+ t.Error("indeterminate should set NeedsRedraw for next frame")
}
}
@@ -902,6 +952,12 @@ func TestDraw_IndeterminateMultipleFrames(t *testing.T) {
ctx := widget.NewContext()
now := time.Now()
+ // Track ScheduleAnimationFrame calls per frame.
+ animFrameCount := 0
+ ctx.SetOnScheduleAnimation(func() {
+ animFrameCount++
+ })
+
// Draw 5 frames, each advancing time.
for i := range 5 {
ctx.SetNow(now.Add(time.Duration(i) * 100 * time.Millisecond))
@@ -911,9 +967,10 @@ func TestDraw_IndeterminateMultipleFrames(t *testing.T) {
if canvas.strokeArcCount == 0 {
t.Errorf("frame %d: should draw rotating arc", i)
}
- if ctx.InvalidatedRect().IsEmpty() {
- t.Errorf("frame %d: should request redraw via InvalidateRect", i)
- }
+ }
+
+ if animFrameCount != 5 {
+ t.Errorf("ScheduleAnimationFrame called %d times, want 5 (once per frame)", animFrameCount)
}
}
@@ -988,5 +1045,124 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
+
+// --- ADR-024 RepaintBoundary Propagation Tests ---
+
+// TestSpinner_DrawInvalidatesOwnScene verifies that the indeterminate
+// spinner's continuous animation invalidates its OWN scene (not parent).
+// Spinner is its own RepaintBoundary — SetNeedsRedraw stops at self.
+func TestSpinner_DrawInvalidatesOwnScene(t *testing.T) {
+ w := progress.New(progress.Indeterminate(true))
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ constraints := geometry.Constraints{
+ MinWidth: 48, MaxWidth: 48,
+ MinHeight: 48, MaxHeight: 48,
+ }
+ w.Layout(ctx, constraints)
+ w.SetBounds(geometry.NewRect(0, 0, 48, 48))
+
+ w.ClearRedraw()
+ w.ClearSceneDirty()
+
+ canvas := &recordingCanvas{}
+ w.Draw(ctx, canvas)
+
+ if !w.IsSceneDirty() {
+ t.Error("spinner.IsSceneDirty() = false after Draw; " +
+ "spinner must invalidate own scene for animation continuity")
+ }
+ if !w.NeedsRedraw() {
+ t.Error("spinner.NeedsRedraw() = false after Draw; " +
+ "continuous animation must request next frame")
+ }
+}
+
+// TestSpinner_IsRepaintBoundaryByDefault verifies that indeterminate spinner
+// sets itself as RepaintBoundary so animation dirty propagation stops at
+// the spinner, not at the root boundary.
+func TestSpinner_IsRepaintBoundaryByDefault(t *testing.T) {
+ w := progress.New(progress.Indeterminate(true))
+
+ if !w.IsRepaintBoundary() {
+ t.Error("indeterminate spinner should be RepaintBoundary by default; " +
+ "without this, spinner invalidates root boundary every frame → " +
+ "full tree re-record at 30fps, defeating RepaintBoundary caching")
+ }
+}
+
+// TestSpinner_DeterminateIsNotBoundary verifies that determinate (static)
+// progress indicator is NOT a RepaintBoundary — no animation, no need.
+func TestSpinner_DeterminateIsNotBoundary(t *testing.T) {
+ w := progress.New(progress.Value(0.5))
+
+ if w.IsRepaintBoundary() {
+ t.Error("determinate progress should NOT be RepaintBoundary (no animation)")
+ }
+}
+
+// TestSpinner_DrawDoesNotInvalidateParentBoundary verifies that spinner
+// animation stays within its own boundary — parent root boundary is NOT
+// invalidated. This is critical for performance: spinner at 30fps must
+// NOT cause full tree re-record.
+func TestSpinner_DrawDoesNotInvalidateParentBoundary(t *testing.T) {
+ w := progress.New(progress.Indeterminate(true))
+
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ constraints := geometry.Constraints{
+ MinWidth: 48, MaxWidth: 48,
+ MinHeight: 48, MaxHeight: 48,
+ }
+ w.Layout(ctx, constraints)
+ w.SetBounds(geometry.NewRect(0, 0, 48, 48))
+
+ // Parent = root boundary.
+ parent := &progressBoundaryParent{}
+ parent.SetVisible(true)
+ parent.SetRepaintBoundary(true)
+ w.SetParent(parent)
+
+ // Clear state.
+ w.ClearRedraw()
+ parent.ClearSceneDirty()
+ parent.sceneDirtied = false
+
+ // Draw spinner frame.
+ canvas := &recordingCanvas{}
+ w.Draw(ctx, canvas)
+
+ // Spinner's OWN boundary should be dirty (it IS the boundary).
+ // w.IsSceneDirty() == true is expected — spinner invalidates its own scene.
+
+ // Parent root boundary must NOT be invalidated.
+ if parent.sceneDirtied {
+ t.Error("parent root boundary invalidated by spinner Draw; " +
+ "spinner must be its own RepaintBoundary so propagation stops " +
+ "at spinner level, not root. Without this fix, full tree " +
+ "re-records every frame (30fps) → performance killed")
+ }
+}
+
+// progressBoundaryParent tracks InvalidateScene for spinner tests.
+type progressBoundaryParent struct {
+ widget.WidgetBase
+ sceneDirtied bool
+}
+
+func (w *progressBoundaryParent) InvalidateScene() {
+ w.WidgetBase.InvalidateScene()
+ w.sceneDirtied = true
+}
+func (w *progressBoundaryParent) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(200, 200))
+}
+func (w *progressBoundaryParent) Draw(_ widget.Context, _ widget.Canvas) {}
+func (w *progressBoundaryParent) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *progressBoundaryParent) Children() []widget.Widget { return nil }
diff --git a/core/progressbar/progressbar_test.go b/core/progressbar/progressbar_test.go
index fe5c843..39dd598 100644
--- a/core/progressbar/progressbar_test.go
+++ b/core/progressbar/progressbar_test.go
@@ -744,9 +744,10 @@ func (c *recordingCanvas) PushClip(_ geometry.Rect) { c.clipCou
func (c *recordingCanvas) PushClipRoundRect(_ geometry.Rect, _ float32) {
c.clipCount++
}
-func (c *recordingCanvas) PopClip() { c.clipCount-- }
-func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
-func (c *recordingCanvas) PopTransform() {}
-func (c *recordingCanvas) TransformOffset() 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) PopClip() { c.clipCount-- }
+func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
+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) {}
diff --git a/core/radio/group.go b/core/radio/group.go
index 5edfd02..8b0a3f9 100644
--- a/core/radio/group.go
+++ b/core/radio/group.go
@@ -290,14 +290,19 @@ func (g *Group) Event(ctx widget.Context, e event.Event) bool {
// For mouse events, translate to Group-local coordinates and hit-test.
// This mirrors PushTransform(g.Bounds().Min) used in Draw.
+ // MouseEnter/MouseLeave are about the Group container, not items —
+ // items get their own Enter/Leave from updateHover. Do NOT forward
+ // these to children, otherwise items set Pointer cursor on the
+ // entire container area.
if me, ok := e.(*event.MouseEvent); ok {
+ if me.MouseType == event.MouseEnter || me.MouseType == event.MouseLeave {
+ return false
+ }
local := *me
local.Position = me.Position.Sub(g.Bounds().Min)
for _, it := range items {
- if it.Bounds().Contains(local.Position) {
- if it.Event(ctx, &local) {
- return true
- }
+ if it.Bounds().Contains(local.Position) && it.Event(ctx, &local) {
+ return true
}
}
return false
diff --git a/core/radio/internal_test.go b/core/radio/internal_test.go
index 08f9926..5af9857 100644
--- a/core/radio/internal_test.go
+++ b/core/radio/internal_test.go
@@ -1468,6 +1468,7 @@ func (c *internalMockCanvas) PopClip() {}
func (c *internalMockCanvas) PushTransform(_ geometry.Point) {}
func (c *internalMockCanvas) PopTransform() {}
func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
@@ -1876,3 +1877,52 @@ func TestGranularInvalidation_Radio_KeyActivation(t *testing.T) {
t.Error("radio key release should use granular invalidation")
}
}
+
+// --- Cursor regression tests (2026-05-07) ---
+
+// TestRadioGroupDoesNotForwardMouseEnterToItems verifies that Group.Event
+// does NOT forward MouseEnter/MouseLeave to children. Before the fix,
+// Group.Event forwarded all mouse events including MouseEnter to items,
+// causing items to set CursorPointer on the entire container area (since
+// the Group container received the MouseEnter, not the individual item).
+// Regression: Group.Event forwarded MouseEnter to children -> Item set Pointer cursor on container (2026-05-07)
+func TestRadioGroupDoesNotForwardMouseEnterToItems(t *testing.T) {
+ g := NewGroup(
+ Items(
+ ItemDef{Value: "a", Label: "Alpha"},
+ ItemDef{Value: "b", Label: "Beta"},
+ ItemDef{Value: "c", Label: "Gamma"},
+ ),
+ )
+ g.SetBounds(geometry.NewRect(0, 0, 200, 120))
+ for i := range g.items {
+ g.items[i].SetBounds(geometry.NewRect(0, float32(i*40), 200, 40))
+ }
+
+ ctx := widget.NewContext()
+
+ // Send MouseEnter to the Group.
+ enterEvt := event.NewMouseEvent(event.MouseEnter, event.ButtonNone, 0,
+ geometry.Pt(100, 60), geometry.Pt(100, 60), event.ModNone)
+ consumed := g.Event(ctx, enterEvt)
+
+ // Group should NOT consume MouseEnter (it filters it out).
+ if consumed {
+ t.Error("Group.Event should not consume MouseEnter (filtered before item dispatch)")
+ }
+
+ // Cursor should remain Default — items should not have been triggered.
+ if ctx.Cursor() != widget.CursorDefault {
+ t.Errorf("cursor = %v, want CursorDefault; MouseEnter must not "+
+ "be forwarded to items", ctx.Cursor())
+ }
+
+ // Verify MouseLeave is also filtered.
+ leaveEvt := event.NewMouseEvent(event.MouseLeave, event.ButtonNone, 0,
+ geometry.Pt(300, 300), geometry.Pt(300, 300), event.ModNone)
+ consumed = g.Event(ctx, leaveEvt)
+
+ if consumed {
+ t.Error("Group.Event should not consume MouseLeave")
+ }
+}
diff --git a/core/radio/radio_test.go b/core/radio/radio_test.go
index abab4d0..3f341a6 100644
--- a/core/radio/radio_test.go
+++ b/core/radio/radio_test.go
@@ -987,6 +987,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -1020,6 +1021,7 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/scrollview/event.go b/core/scrollview/event.go
index be9d1bf..099c993 100644
--- a/core/scrollview/event.go
+++ b/core/scrollview/event.go
@@ -19,9 +19,9 @@ type trackRepeatState struct {
count int // number of repeats fired (0 = initial click)
}
-// Track repeat timing constants (Windows convention).
+// Track repeat timing (Qt6 QScrollBar pattern: 500ms initial, 50ms repeat).
const (
- trackRepeatInitialDelay = 300 * time.Millisecond
+ trackRepeatInitialDelay = 500 * time.Millisecond
trackRepeatInterval = 50 * time.Millisecond
)
@@ -74,14 +74,20 @@ func handleWheelEvent(w *Widget, ctx widget.Context, e *event.WheelEvent) bool {
func handleMouseEvent(w *Widget, ctx widget.Context, e *event.MouseEvent) bool {
switch e.MouseType {
case event.MouseEnter:
- w.hovered = true
- ctx.Invalidate()
+ if !w.hovered {
+ w.hovered = true
+ w.MarkRedrawLocal()
+ ctx.InvalidateRect(w.Bounds())
+ }
return false // Don't consume enter events -- let children handle them too.
case event.MouseLeave:
- w.hovered = false
- if w.dragging == dragNone {
- ctx.Invalidate()
+ if w.hovered {
+ w.hovered = false
+ if w.dragging == dragNone {
+ w.MarkRedrawLocal()
+ ctx.InvalidateRect(w.Bounds())
+ }
}
return false
@@ -113,7 +119,8 @@ func handleMousePress(w *Widget, ctx widget.Context, e *event.MouseEvent) bool {
w.dragStart = e.Position
w.dragScrollStart = w.cfg.ResolvedScrollY()
ctx.RequestFocus(w)
- ctx.Invalidate()
+ w.MarkRedrawLocal()
+ ctx.InvalidateRect(w.Bounds())
return true
}
@@ -122,7 +129,8 @@ func handleMousePress(w *Widget, ctx widget.Context, e *event.MouseEvent) bool {
w.dragStart = e.Position
w.dragScrollStart = w.cfg.ResolvedScrollX()
ctx.RequestFocus(w)
- ctx.Invalidate()
+ w.MarkRedrawLocal()
+ ctx.InvalidateRect(w.Bounds())
return true
}
@@ -188,7 +196,8 @@ func handleMouseRelease(w *Widget, ctx widget.Context, e *event.MouseEvent) bool
w.dragging = dragNone
w.trackRepeat.active = false
if wasDragging || wasRepeating {
- ctx.Invalidate()
+ w.MarkRedrawLocal()
+ ctx.InvalidateRect(w.Bounds())
}
return wasDragging || wasRepeating
}
@@ -204,7 +213,8 @@ func handleMouseMove(w *Widget, ctx widget.Context, e *event.MouseEvent) bool {
if !e.Buttons.IsLeftPressed() {
w.dragging = dragNone
w.trackRepeat.active = false
- ctx.Invalidate()
+ w.MarkRedrawLocal()
+ ctx.InvalidateRect(w.Bounds())
return false
}
@@ -318,7 +328,8 @@ func setScroll(w *Widget, ctx widget.Context, rawX, rawY float32) {
w.cfg.onScroll(newX, newY)
}
- ctx.Invalidate()
+ w.SetNeedsRedraw(true)
+ ctx.InvalidateRect(w.Bounds())
}
// clampScroll clamps a scroll offset to [0, maxScroll].
diff --git a/core/scrollview/internal_test.go b/core/scrollview/internal_test.go
index 057343e..45722f9 100644
--- a/core/scrollview/internal_test.go
+++ b/core/scrollview/internal_test.go
@@ -1887,6 +1887,82 @@ func TestBuildContentConstraints_Default(t *testing.T) {
}
}
+// --- Hover guard regression tests (2026-05-07) ---
+
+// TestScrollViewHoverGuard verifies that repeated MouseEnter events do NOT
+// re-mark the ScrollView as dirty. Before the fix, every MouseEnter set
+// hovered=true and called SetNeedsRedraw unconditionally, causing the entire
+// viewport to appear in the dirty overlay on every hover event.
+// Regression: each MouseEnter marked ScrollView dirty -> full viewport in overlay (2026-05-07)
+func TestScrollViewHoverGuard(t *testing.T) {
+ content := &mockWidget{preferredSize: geometry.Sz(200, 1000)}
+ sv := New(content)
+ ctx := widget.NewContext()
+ sv.Layout(ctx, geometry.Loose(geometry.Sz(200, 300)))
+ sv.SetBounds(geometry.NewRect(0, 0, 200, 300))
+
+ // First MouseEnter — should mark dirty (state changes from not-hovered to hovered).
+ me1 := &event.MouseEvent{
+ MouseType: event.MouseEnter,
+ Position: geometry.Pt(100, 150),
+ }
+ sv.Event(ctx, me1)
+
+ if !sv.hovered {
+ t.Fatal("precondition: should be hovered after first MouseEnter")
+ }
+
+ // Clear redraw state.
+ sv.ClearRedraw()
+
+ // Second MouseEnter — should NOT mark dirty (already hovered).
+ me2 := &event.MouseEvent{
+ MouseType: event.MouseEnter,
+ Position: geometry.Pt(100, 150),
+ }
+ sv.Event(ctx, me2)
+
+ if sv.NeedsRedraw() {
+ t.Error("repeated MouseEnter must NOT set needsRedraw; " +
+ "hovered guard should prevent redundant dirty marking")
+ }
+}
+
+// TestScrollViewGranularInvalidation verifies that scrolling uses
+// InvalidateRect (partial invalidation) instead of ctx.Invalidate()
+// (full layout + redraw of entire tree). Before the fix, scroll called
+// ctx.Invalidate() which triggered full layout recalculation.
+// Regression: scroll called ctx.Invalidate() -> full layout + redraw of entire tree (2026-05-07)
+func TestScrollViewGranularInvalidation(t *testing.T) {
+ content := &mockWidget{preferredSize: geometry.Sz(200, 1000)}
+ sv := New(content)
+ ctx := widget.NewContext()
+ constraints := geometry.Loose(geometry.Sz(200, 300))
+
+ sv.Layout(ctx, constraints)
+ sv.SetBounds(geometry.NewRect(0, 0, 200, 300))
+
+ // Scroll down.
+ e := event.NewWheelEvent(
+ geometry.Pt(0, 1),
+ geometry.Pt(100, 150),
+ geometry.Pt(100, 150),
+ 0,
+ )
+ sv.Event(ctx, e)
+
+ // Full invalidation (ctx.Invalidate) should NOT have been called.
+ if ctx.IsInvalidated() {
+ t.Error("scroll should use granular invalidation (InvalidateRect), " +
+ "not ctx.Invalidate() which triggers full layout recalculation")
+ }
+
+ // The widget itself should be marked for redraw (partial).
+ if !sv.NeedsRedraw() {
+ t.Error("scroll should mark the scrollview for redraw (granular)")
+ }
+}
+
// --- Test Helpers ---
// mockWidget is a minimal widget.Widget implementation for testing.
@@ -1991,6 +2067,264 @@ func (c *internalMockCanvas) PopTransform() {
c.popTransformCount++
}
-func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
-func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
-func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
+func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
+func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
+
+// --- ScrollView MarkRedrawLocal vs SetNeedsRedraw Tests (ADR-024 regression) ---
+//
+// All visual changes in ScrollView must use SetNeedsRedraw(true) so dirty state
+// propagates to parent RepaintBoundary. MarkRedrawLocal only sets local flag
+// and is invisible when root boundary replays cached scene.
+
+// TestScrollView_HoverDoesNotPropagateToParentBoundary verifies that
+// scrollbar hover is visual-only (MarkRedrawLocal) and does NOT propagate
+// to parent boundary. Propagation would cause full-window dirty on every
+// mouse move over ScrollView.
+func TestScrollView_HoverDoesNotPropagateToParentBoundary(t *testing.T) {
+ content := &mockWidget{} // scrollview internal mockWidget
+ content.SetVisible(true)
+ sv := New(content, DirectionOpt(Vertical))
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ size := sv.Layout(ctx, geometry.Loose(geometry.Sz(400, 300)))
+ sv.SetBounds(geometry.FromPointSize(geometry.Pt(0, 0), size))
+
+ parent := &scrollbarBoundaryParent{}
+ sv.SetParent(parent)
+ sv.ClearRedraw()
+ parent.invalidated = false
+
+ // Simulate hover enter.
+ me := &event.MouseEvent{
+ MouseType: event.MouseEnter,
+ Position: geometry.Pt(390, 150), // scrollbar area
+ }
+ handleEvent(sv, ctx, me)
+
+ if !sv.hovered {
+ t.Fatal("ScrollView should be hovered after MouseEnter")
+ }
+
+ if parent.invalidated {
+ t.Error("parent boundary should NOT be invalidated by hover; " +
+ "hover is visual-only (MarkRedrawLocal), not structural")
+ }
+}
+
+// TestScrollView_DragDoesNotPropagateToParentBoundary verifies that
+// scrollbar drag is visual-only and does NOT propagate to parent boundary.
+func TestScrollView_DragDoesNotPropagateToParentBoundary(t *testing.T) {
+ content := &mockWidget{}
+ content.preferredSize = geometry.Sz(400, 2000)
+ content.SetVisible(true)
+ sv := New(content, DirectionOpt(Vertical))
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ size := sv.Layout(ctx, geometry.Loose(geometry.Sz(400, 300)))
+ sv.SetBounds(geometry.FromPointSize(geometry.Pt(0, 0), size))
+
+ parent := &scrollbarBoundaryParent{}
+ sv.SetParent(parent)
+
+ // Directly set drag state (simulates what handleMousePress does
+ // when press is on scrollbar thumb). This avoids needing exact
+ // scrollbar hit-testing coordinates in unit tests.
+ sv.dragging = dragVertical
+ sv.ClearRedraw()
+ parent.invalidated = false
+
+ // Now simulate drag move — this calls SetNeedsRedraw internally.
+ move := &event.MouseEvent{
+ MouseType: event.MouseMove,
+ Position: geometry.Pt(395, 100),
+ }
+ handleEvent(sv, ctx, move)
+
+ if parent.invalidated {
+ t.Error("parent boundary should NOT be invalidated by drag visual; " +
+ "drag is visual-only (MarkRedrawLocal)")
+ }
+}
+
+// TestScrollView_MouseReleaseDoesNotPropagateToParentBoundary verifies that
+// ending a drag is visual-only and does NOT propagate.
+func TestScrollView_MouseReleaseDoesNotPropagateToParentBoundary(t *testing.T) {
+ content := &mockWidget{}
+ content.preferredSize = geometry.Sz(400, 2000)
+ content.SetVisible(true)
+ sv := New(content, DirectionOpt(Vertical))
+ ctx := widget.NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ size := sv.Layout(ctx, geometry.Loose(geometry.Sz(400, 300)))
+ sv.SetBounds(geometry.FromPointSize(geometry.Pt(0, 0), size))
+
+ // Set drag state directly (simulates active drag).
+ sv.dragging = dragVertical
+
+ parent := &scrollbarBoundaryParent{}
+ sv.SetParent(parent)
+ sv.ClearRedraw()
+ parent.invalidated = false
+
+ // Mouse release ends drag. Button = ButtonLeft required by handleMouseRelease.
+ release := &event.MouseEvent{
+ MouseType: event.MouseRelease,
+ Position: geometry.Pt(395, 100),
+ Button: event.ButtonLeft,
+ }
+ handleEvent(sv, ctx, release)
+
+ if sv.dragging != dragNone {
+ t.Error("drag should be cleared after mouse release")
+ }
+
+ if parent.invalidated {
+ t.Error("parent boundary should NOT be invalidated by release; " +
+ "visual-only (MarkRedrawLocal)")
+ }
+}
+
+// scrollbarBoundaryParent tracks InvalidateScene for scrollbar visual tests.
+type scrollbarBoundaryParent struct {
+ widget.WidgetBase
+ invalidated bool
+}
+
+func (w *scrollbarBoundaryParent) IsRepaintBoundary() bool { return true }
+func (w *scrollbarBoundaryParent) InvalidateScene() {
+ w.WidgetBase.InvalidateScene()
+ w.invalidated = true
+}
+func (w *scrollbarBoundaryParent) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(400, 300))
+}
+func (w *scrollbarBoundaryParent) Draw(_ widget.Context, _ widget.Canvas) {}
+func (w *scrollbarBoundaryParent) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *scrollbarBoundaryParent) Children() []widget.Widget { return nil }
+
+// --- Scroll Dirty Propagation Tests (ADR-024 regression) ---
+//
+// These tests verify that scroll position changes propagate dirty state
+// upward through the parent chain to the nearest RepaintBoundary.
+// Without this, root RepaintBoundary replays stale cached scene after scroll.
+
+// TestScroll_WheelPropagatesDirtyToParentBoundary verifies that mouse wheel
+// scroll calls SetNeedsRedraw (not MarkRedrawLocal) so dirty state propagates
+// upward to the parent RepaintBoundary, invalidating its scene cache.
+func TestScroll_WheelPropagatesDirtyToParentBoundary(t *testing.T) {
+ content := &mockWidget{preferredSize: geometry.Sz(400, 2000)}
+ sv := New(content, DirectionOpt(Vertical))
+ ctx := widget.NewContext()
+
+ size := sv.Layout(ctx, geometry.Loose(geometry.Sz(400, 300)))
+ sv.SetBounds(geometry.FromPointSize(geometry.Pt(0, 0), size))
+
+ // Create a parent box and set it as RepaintBoundary.
+ // Wire parent chain so propagateDirtyUpward can walk up.
+ sv.SetParent(nil) // root-level for now
+
+ // Clear any initial dirty state.
+ sv.ClearRedraw()
+
+ // Simulate mouse wheel scroll inside viewport.
+ wheelEvent := &event.WheelEvent{
+ Position: geometry.Pt(200, 150),
+ Delta: geometry.Pt(0, 3),
+ }
+ sv.Event(ctx, wheelEvent)
+
+ // After scroll, ScrollView MUST have needsRedraw=true.
+ if !sv.NeedsRedraw() {
+ t.Error("ScrollView.NeedsRedraw() = false after wheel scroll, want true")
+ }
+}
+
+// TestScroll_SetScrollPropagatesDirty verifies that setScroll uses
+// SetNeedsRedraw(true) instead of MarkRedrawLocal() so dirty state
+// propagates to parent RepaintBoundary.
+func TestScroll_SetScrollPropagatesDirty(t *testing.T) {
+ content := &mockWidget{preferredSize: geometry.Sz(400, 2000)}
+ sv := New(content, DirectionOpt(Vertical))
+ ctx := widget.NewContext()
+
+ size := sv.Layout(ctx, geometry.Loose(geometry.Sz(400, 300)))
+ sv.SetBounds(geometry.FromPointSize(geometry.Pt(0, 0), size))
+
+ // Setup: track whether InvalidateRect was called.
+ invalidateRectCalled := false
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {
+ invalidateRectCalled = true
+ })
+
+ // Clear initial state.
+ sv.ClearRedraw()
+ invalidateRectCalled = false
+
+ // Scroll programmatically.
+ setScroll(sv, ctx, 0, 100)
+
+ if !sv.NeedsRedraw() {
+ t.Error("ScrollView.NeedsRedraw() = false after setScroll, want true")
+ }
+ if !invalidateRectCalled {
+ t.Error("InvalidateRect not called after setScroll")
+ }
+}
+
+// TestScroll_WheelInvalidatesParentBoundaryScene verifies the full propagation
+// chain: wheel scroll → SetNeedsRedraw → propagateDirtyUpward → parent
+// boundary InvalidateScene. This is the critical path for ADR-024 correctness.
+func TestScroll_WheelInvalidatesParentBoundaryScene(t *testing.T) {
+ content := &mockWidget{preferredSize: geometry.Sz(400, 2000)}
+ sv := New(content, DirectionOpt(Vertical))
+ ctx := widget.NewContext()
+
+ size := sv.Layout(ctx, geometry.Loose(geometry.Sz(400, 300)))
+ sv.SetBounds(geometry.FromPointSize(geometry.Pt(0, 0), size))
+
+ // Create a parent that acts as RepaintBoundary (WidgetBase with boundary flag).
+ // We simulate this by checking if SetNeedsRedraw propagates upward.
+ parentDirtied := false
+ sv.SetParent(&boundaryParentWidget{
+ onInvalidateScene: func() { parentDirtied = true },
+ })
+
+ sv.ClearRedraw()
+
+ // Scroll.
+ wheelEvent := &event.WheelEvent{
+ Position: geometry.Pt(200, 150),
+ Delta: geometry.Pt(0, 3),
+ }
+ sv.Event(ctx, wheelEvent)
+
+ if !parentDirtied {
+ t.Error("parent RepaintBoundary.InvalidateScene() not called after scroll; " +
+ "setScroll likely uses MarkRedrawLocal() instead of SetNeedsRedraw(true)")
+ }
+}
+
+// boundaryParentWidget simulates a parent RepaintBoundary for testing
+// dirty propagation. Implements IsRepaintBoundary + InvalidateScene.
+type boundaryParentWidget struct {
+ widget.WidgetBase
+ onInvalidateScene func()
+}
+
+func (w *boundaryParentWidget) IsRepaintBoundary() bool { return true }
+func (w *boundaryParentWidget) InvalidateScene() {
+ if w.onInvalidateScene != nil {
+ w.onInvalidateScene()
+ }
+}
+func (w *boundaryParentWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(400, 300))
+}
+func (w *boundaryParentWidget) Draw(_ widget.Context, _ widget.Canvas) {}
+func (w *boundaryParentWidget) Event(_ widget.Context, _ event.Event) bool { return false }
+func (w *boundaryParentWidget) Children() []widget.Widget { return nil }
diff --git a/core/scrollview/scrollview_test.go b/core/scrollview/scrollview_test.go
index 648905b..144e683 100644
--- a/core/scrollview/scrollview_test.go
+++ b/core/scrollview/scrollview_test.go
@@ -80,9 +80,10 @@ func (c *stubCanvas) PushTransform(offset geometry.Point) {
func (c *stubCanvas) PopTransform() {
c.transformsPopped++
}
-func (c *stubCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
-func (c *stubCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
-func (c *stubCanvas) ReplayScene(_ *scene.Scene) {}
+func (c *stubCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *stubCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+func (c *stubCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
+func (c *stubCanvas) ReplayScene(_ *scene.Scene) {}
// --- Construction Tests ---
diff --git a/core/scrollview/widget.go b/core/scrollview/widget.go
index e136a70..67803b1 100644
--- a/core/scrollview/widget.go
+++ b/core/scrollview/widget.go
@@ -48,6 +48,14 @@ func New(content widget.Widget, opts ...Option) *Widget {
w.SetVisible(true)
w.SetEnabled(true)
+ // Set parent so dirty propagation and viewport clipping work correctly.
+ // Android pattern: invalidateChildInParent() clips dirty rect to parent bounds.
+ // Without this, content.Parent()=nil and clipToParentViewport cannot clip
+ // content bounds (e.g. 36000px) to viewport bounds.
+ if setter, ok := content.(interface{ SetParent(widget.Widget) }); ok {
+ setter.SetParent(w)
+ }
+
for _, opt := range opts {
opt(&w.cfg)
}
@@ -189,8 +197,10 @@ func (w *Widget) tickTrackRepeat(ctx widget.Context) {
}
if elapsed < delay {
- // Not yet time — request another frame to check again.
- ctx.Invalidate()
+ // Keep widget dirty so NeedsRedrawInTree triggers root re-recording
+ // on the next frame. InvalidateRect requests the frame.
+ w.SetNeedsRedraw(true)
+ ctx.InvalidateRect(w.Bounds())
return
}
@@ -212,7 +222,7 @@ func (w *Widget) tickTrackRepeat(ctx widget.Context) {
w.trackRepeat.count++
// Request next frame for continuous repeat.
- ctx.Invalidate()
+ ctx.InvalidateRect(w.Bounds())
}
// trackRepeatReached returns true if the scrollbar thumb has reached
@@ -337,6 +347,12 @@ func (w *Widget) transformToContentSpace(e event.Event) event.Event {
}
}
+// IsViewportClip tells the dirty Collector that this widget acts as a
+// viewport boundary (Flutter RenderViewport pattern). The Collector adds
+// this widget's own bounds as the dirty region and does NOT recurse into
+// children — scroll content may have bounds exceeding the viewport.
+func (w *Widget) IsViewportClip() bool { return true }
+
// Children returns the content widget as the single child.
func (w *Widget) Children() []widget.Widget {
if w.content == nil {
diff --git a/core/slider/internal_test.go b/core/slider/internal_test.go
index 1a46ce7..7032445 100644
--- a/core/slider/internal_test.go
+++ b/core/slider/internal_test.go
@@ -1870,6 +1870,7 @@ func (c *internalMockCanvas) PopClip() {}
func (c *internalMockCanvas) PushTransform(_ geometry.Point) {}
func (c *internalMockCanvas) PopTransform() {}
func (c *internalMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *internalMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *internalMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *internalMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/slider/slider_test.go b/core/slider/slider_test.go
index 003282f..6c16be9 100644
--- a/core/slider/slider_test.go
+++ b/core/slider/slider_test.go
@@ -266,5 +266,6 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
diff --git a/core/slider/widget.go b/core/slider/widget.go
index 5dc2aa4..e54dbb4 100644
--- a/core/slider/widget.go
+++ b/core/slider/widget.go
@@ -81,21 +81,20 @@ func (w *Widget) IsFocusable() bool {
// Layout calculates the slider's preferred size within the given constraints.
func (w *Widget) Layout(_ widget.Context, constraints geometry.Constraints) geometry.Size {
- var preferred geometry.Size
-
if w.cfg.orientation == Vertical {
- preferred = geometry.Sz(
- thumbRadius*2+w.padding*2,
- verticalDefaultHeight+w.padding*2,
- )
- } else {
- preferred = geometry.Sz(
- horizontalDefaultWidth+w.padding*2,
- thumbRadius*2+w.padding*2,
- )
+ height := constraints.MaxHeight
+ if height <= 0 || height == geometry.Infinity {
+ height = verticalDefaultHeight + w.padding*2
+ }
+ return constraints.Constrain(geometry.Sz(
+ thumbRadius*2+w.padding*2, height))
}
-
- return constraints.Constrain(preferred)
+ width := constraints.MaxWidth
+ if width <= 0 || width == geometry.Infinity {
+ width = horizontalDefaultWidth + w.padding*2
+ }
+ return constraints.Constrain(geometry.Sz(
+ width, thumbRadius*2+w.padding*2))
}
// Layout dimension constants.
diff --git a/core/splitview/splitview_test.go b/core/splitview/splitview_test.go
index 478c6bf..6da4aa8 100644
--- a/core/splitview/splitview_test.go
+++ b/core/splitview/splitview_test.go
@@ -75,6 +75,7 @@ func (c *canvasRecorder) PopClip() {}
func (c *canvasRecorder) PushTransform(_ geometry.Point) {}
func (c *canvasRecorder) PopTransform() {}
func (c *canvasRecorder) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *canvasRecorder) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *canvasRecorder) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *canvasRecorder) ReplayScene(_ *scene.Scene) {}
diff --git a/core/tabview/tabview_test.go b/core/tabview/tabview_test.go
index bafe8ab..57e5efc 100644
--- a/core/tabview/tabview_test.go
+++ b/core/tabview/tabview_test.go
@@ -1093,6 +1093,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -1126,5 +1127,6 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/textfield/textfield_test.go b/core/textfield/textfield_test.go
index a7ac3a5..3838bcf 100644
--- a/core/textfield/textfield_test.go
+++ b/core/textfield/textfield_test.go
@@ -1073,6 +1073,7 @@ func (c *recordingCanvas) PopClip() {}
func (c *recordingCanvas) PushTransform(_ geometry.Point) {}
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) {}
@@ -1106,6 +1107,7 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/textfield/widget.go b/core/textfield/widget.go
index 5640d86..d8f11b1 100644
--- a/core/textfield/widget.go
+++ b/core/textfield/widget.go
@@ -94,8 +94,11 @@ func (w *Widget) IsFocusable() bool {
// Layout calculates the text field's preferred size within the given constraints.
func (w *Widget) Layout(_ widget.Context, constraints geometry.Constraints) geometry.Size {
- preferred := geometry.Sz(minFieldWidth, defaultFieldHeight)
- return constraints.Constrain(preferred)
+ width := constraints.MaxWidth
+ if width <= 0 || width == geometry.Infinity {
+ width = minFieldWidth
+ }
+ return constraints.Constrain(geometry.Sz(width, defaultFieldHeight))
}
// Draw renders the text field to the canvas.
diff --git a/core/titlebar/titlebar_test.go b/core/titlebar/titlebar_test.go
index 3199215..ee38967 100644
--- a/core/titlebar/titlebar_test.go
+++ b/core/titlebar/titlebar_test.go
@@ -1367,5 +1367,6 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/toolbar/toolbar_test.go b/core/toolbar/toolbar_test.go
index 0b3f1a6..9e5f6c1 100644
--- a/core/toolbar/toolbar_test.go
+++ b/core/toolbar/toolbar_test.go
@@ -1429,5 +1429,6 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/core/treeview/treeview_test.go b/core/treeview/treeview_test.go
index 809ff34..10243f6 100644
--- a/core/treeview/treeview_test.go
+++ b/core/treeview/treeview_test.go
@@ -1995,6 +1995,7 @@ func (c *mockCanvas) PopClip() { c.popClipCalls
func (c *mockCanvas) PushTransform(geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/desktop/compositor_clip_test.go b/desktop/compositor_clip_test.go
new file mode 100644
index 0000000..0c170df
--- /dev/null
+++ b/desktop/compositor_clip_test.go
@@ -0,0 +1,211 @@
+package desktop
+
+import (
+ "testing"
+
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+)
+
+// --- Compositor Clip Tests ---
+//
+// These tests verify that walkBoundaries and compositeTextures respect
+// CompositorClip — skipping boundary textures outside the viewport.
+// This implements ScrollView clipping at compositor level.
+
+// TestCompositorClip_SkipsItemsOutsideClip verifies that walkBoundaries
+// skips items whose screen rect doesn't intersect their CompositorClip.
+func TestCompositorClip_SkipsItemsOutsideClip(t *testing.T) {
+ // Phase 1: Verify test setup.
+ viewportClip := geometry.NewRect(0, 200, 800, 300)
+ t.Logf("viewport clip: %v (Min=%v Max=%v)", viewportClip, viewportClip.Min, viewportClip.Max)
+
+ if viewportClip.Height() != 300 {
+ t.Fatalf("viewport clip height = %v, want 300", viewportClip.Height())
+ }
+ if viewportClip.Max.Y != 500 {
+ t.Fatalf("viewport clip Max.Y = %v, want 500", viewportClip.Max.Y)
+ }
+
+ root := &ccTestContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ rootKey := root.BoundaryCacheKey()
+
+ // Create items at specific screen positions relative to viewport.
+ type itemSpec struct {
+ screenY float32
+ wantVis bool // should be visited by walkBoundaries?
+ }
+ specs := []itemSpec{
+ {screenY: 100, wantVis: false}, // item 0: fully above (y:100-140 vs clip y:200-500)
+ {screenY: 190, wantVis: true}, // item 1: partially above (y:190-230 ∩ y:200-500)
+ {screenY: 300, wantVis: true}, // item 2: fully inside (y:300-340 ∈ y:200-500)
+ {screenY: 480, wantVis: true}, // item 3: partially below (y:480-520 ∩ y:200-500)
+ {screenY: 510, wantVis: false}, // item 4: fully below (y:510-550 vs clip y:200-500)
+ }
+
+ items := make([]*ccTestItem, len(specs))
+ for i, s := range specs {
+ items[i] = &ccTestItem{index: i}
+ items[i].SetVisible(true)
+ items[i].SetRepaintBoundary(true)
+ items[i].SetBounds(geometry.NewRect(0, 0, 200, 40))
+ items[i].SetScreenOrigin(geometry.Pt(10, s.screenY))
+ items[i].SetCompositorClip(viewportClip)
+ items[i].SetParent(root)
+ }
+
+ // Phase 2: Verify each item's stored clip is correct.
+ for i, item := range items {
+ clip := item.CompositorClip()
+ if clip.Max.Y != 500 {
+ t.Errorf("item[%d] stored clip Max.Y = %v, want 500 (clip=%v)", i, clip.Max.Y, clip)
+ }
+ }
+
+ // Phase 3: Verify intersection logic independently.
+ for i, s := range specs {
+ origin := geometry.Pt(10, s.screenY)
+ screenRect := geometry.Rect{
+ Min: origin,
+ Max: geometry.Pt(origin.X+200, origin.Y+40),
+ }
+ intersects := screenRect.Intersects(viewportClip)
+ if intersects != s.wantVis {
+ t.Errorf("item[%d] intersection: got %v, want %v (screen=%v clip=%v)",
+ i, intersects, s.wantVis, screenRect, viewportClip)
+ }
+ }
+
+ // Phase 4: Wire up widget tree.
+ children := make([]widget.Widget, len(items))
+ for i, item := range items {
+ children[i] = item
+ }
+ root.children = children
+
+ // Phase 5: Walk boundaries and verify clip filtering.
+ itemKeys := make(map[uint64]int)
+ for i, item := range items {
+ itemKeys[item.BoundaryCacheKey()] = i
+ }
+
+ rl := &renderLoop{}
+ var visited []int
+ rl.walkBoundaries(root, func(key uint64, _ geometry.Point, _, _ int) {
+ if key == rootKey {
+ return
+ }
+ if idx, ok := itemKeys[key]; ok {
+ visited = append(visited, idx)
+ }
+ })
+
+ // Expected: items 1, 2, 3 visible; items 0, 4 clipped away.
+ want := []int{1, 2, 3}
+ if len(visited) != len(want) {
+ t.Fatalf("visited %v, want %v", visited, want)
+ }
+ for i, idx := range visited {
+ if idx != want[i] {
+ t.Errorf("visited[%d] = %d, want %d", i, idx, want[i])
+ }
+ }
+}
+
+// TestCompositorClip_NoClipShowsAll verifies backward compatibility:
+// boundaries without CompositorClip are always composited.
+func TestCompositorClip_NoClipShowsAll(t *testing.T) {
+ root := &ccTestContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ rootKey := root.BoundaryCacheKey()
+
+ item0 := &ccTestItem{index: 0}
+ item0.SetVisible(true)
+ item0.SetRepaintBoundary(true)
+ item0.SetBounds(geometry.NewRect(0, 0, 200, 40))
+ item0.SetScreenOrigin(geometry.Pt(10, 100))
+ // No SetCompositorClip — should always be visible.
+ item0.SetParent(root)
+
+ item1 := &ccTestItem{index: 1}
+ item1.SetVisible(true)
+ item1.SetRepaintBoundary(true)
+ item1.SetBounds(geometry.NewRect(0, 0, 200, 40))
+ item1.SetScreenOrigin(geometry.Pt(10, 700))
+ // No SetCompositorClip — should always be visible.
+ item1.SetParent(root)
+
+ root.children = []widget.Widget{item0, item1}
+
+ rl := &renderLoop{}
+ var count int
+ rl.walkBoundaries(root, func(key uint64, _ geometry.Point, _, _ int) {
+ if key != rootKey {
+ count++
+ }
+ })
+
+ if count != 2 {
+ t.Errorf("without CompositorClip, all items should be visible: got %d, want 2", count)
+ }
+}
+
+// TestCompositorClip_RootNeverClipped verifies that the root boundary
+// (depth=0) is never affected by compositor clip.
+func TestCompositorClip_RootNeverClipped(t *testing.T) {
+ root := &ccTestContainer{}
+ root.SetVisible(true)
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ root.SetCompositorClip(geometry.NewRect(0, 0, 1, 1)) // tiny clip
+
+ rl := &renderLoop{}
+ var rootVisited bool
+ rl.walkBoundaries(root, func(key uint64, _ geometry.Point, _, _ int) {
+ if key == root.BoundaryCacheKey() {
+ rootVisited = true
+ }
+ })
+
+ if !rootVisited {
+ t.Error("root boundary should never be clipped (depth=0)")
+ }
+}
+
+// --- test helpers ---
+
+type ccTestItem struct {
+ widget.WidgetBase
+ index int
+}
+
+func (w *ccTestItem) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(200, 40))
+}
+
+func (w *ccTestItem) Draw(_ widget.Context, _ widget.Canvas) {}
+
+func (w *ccTestItem) Event(_ widget.Context, _ event.Event) bool { return false }
+
+func (w *ccTestItem) Children() []widget.Widget { return nil }
+
+type ccTestContainer struct {
+ widget.WidgetBase
+ children []widget.Widget
+}
+
+func (w *ccTestContainer) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 600))
+}
+
+func (w *ccTestContainer) Draw(_ widget.Context, _ widget.Canvas) {}
+
+func (w *ccTestContainer) Event(_ widget.Context, _ event.Event) bool { return false }
+
+func (w *ccTestContainer) Children() []widget.Widget { return w.children }
diff --git a/desktop/debug_dirty.go b/desktop/debug_dirty.go
new file mode 100644
index 0000000..d202ca4
--- /dev/null
+++ b/desktop/debug_dirty.go
@@ -0,0 +1,98 @@
+package desktop
+
+import (
+ "os"
+ "sync"
+ "time"
+
+ "github.com/gogpu/gg"
+ "github.com/gogpu/ui/geometry"
+)
+
+var (
+ debugDirtyOnce sync.Once
+ debugDirtyEnabled bool
+)
+
+func isDebugDirtyEnabled() bool {
+ debugDirtyOnce.Do(func() {
+ debugDirtyEnabled = os.Getenv("GOGPU_DEBUG_DIRTY") == "1"
+ })
+ return debugDirtyEnabled
+}
+
+const dirtyFlashDuration = 400 * time.Millisecond
+
+type dirtyFlash struct {
+ rect geometry.Rect
+ time time.Time
+}
+
+// dirtyOverlay tracks dirty regions with flash-and-fade effect.
+// Android SurfaceFlinger pattern: flash on dirty, fade over duration.
+// In debug mode, extra frames are requested for the fade animation.
+type dirtyOverlay struct {
+ flashes []dirtyFlash
+}
+
+func (o *dirtyOverlay) update(regions []geometry.Rect) {
+ now := time.Now()
+
+ // Prune expired.
+ alive := o.flashes[:0]
+ for _, f := range o.flashes {
+ if now.Sub(f.time) < dirtyFlashDuration {
+ alive = append(alive, f)
+ }
+ }
+ o.flashes = alive
+
+ // Add new.
+ for _, r := range regions {
+ if r.Width() <= 0 || r.Height() <= 0 {
+ continue
+ }
+ o.flashes = append(o.flashes, dirtyFlash{rect: r, time: now})
+ }
+}
+
+func (o *dirtyOverlay) draw(cc *gg.Context, scale float64) {
+ now := time.Now()
+ for _, f := range o.flashes {
+ age := now.Sub(f.time)
+ if age >= dirtyFlashDuration {
+ continue
+ }
+ fade := 1.0 - float64(age)/float64(dirtyFlashDuration)
+
+ x := float64(f.rect.Min.X) * scale
+ y := float64(f.rect.Min.Y) * scale
+ w := float64(f.rect.Max.X-f.rect.Min.X) * scale
+ h := float64(f.rect.Max.Y-f.rect.Min.Y) * scale
+ if w <= 0 || h <= 0 {
+ continue
+ }
+
+ cc.SetRGBA(0, 0.7, 0.9, 0.12*fade)
+ cc.DrawRectangle(x, y, w, h)
+ _ = cc.Fill()
+
+ cc.SetRGBA(0, 0.7, 0.9, 0.7*fade)
+ cc.SetLineWidth(2)
+ cc.DrawRectangle(x+1, y+1, w-2, h-2)
+ _ = cc.Stroke()
+ }
+}
+
+func (o *dirtyOverlay) needsAnimationFrame() bool {
+ if len(o.flashes) == 0 {
+ return false
+ }
+ now := time.Now()
+ for _, f := range o.flashes {
+ if now.Sub(f.time) < dirtyFlashDuration {
+ return true
+ }
+ }
+ return false
+}
diff --git a/desktop/desktop.go b/desktop/desktop.go
index d6421fa..96278f2 100644
--- a/desktop/desktop.go
+++ b/desktop/desktop.go
@@ -2,13 +2,19 @@ package desktop
import (
"fmt"
+ "image"
"log"
"github.com/gogpu/gg"
"github.com/gogpu/gg/integration/ggcanvas"
+ "github.com/gogpu/gg/scene"
"github.com/gogpu/gogpu"
+ "github.com/gogpu/gpucontext"
"github.com/gogpu/ui/app"
+ "github.com/gogpu/ui/compositor"
+ "github.com/gogpu/ui/geometry"
"github.com/gogpu/ui/render"
+ "github.com/gogpu/ui/widget"
)
// Run starts a desktop application with a scene-composition render loop.
@@ -45,6 +51,7 @@ func Run(gogpuApp *gogpu.App, uiApp *app.App) error {
gogpuApp.OnDraw(rl.draw)
gogpuApp.OnClose(func() {
+ rl.releaseBoundaryTextures()
gg.CloseAccelerator()
if rl.canvas != nil {
_ = rl.canvas.Close()
@@ -55,10 +62,34 @@ func Run(gogpuApp *gogpu.App, uiApp *app.App) error {
}
// renderLoop holds the state for the scene-composition render loop.
+//
+// ADR-007 Phase 7: per-boundary GPU textures. Each RepaintBoundary owns an
+// offscreen GPU texture. Dirty boundaries re-render into their texture.
+// Clean boundaries reuse previous texture (0 GPU work). Compositor blits
+// all textures via non-MSAA path instead of replaying all scenes through
+// MSAA SDF pipeline.
type renderLoop struct {
- gogpuApp *gogpu.App
- uiApp *app.App
- canvas *ggcanvas.Canvas
+ gogpuApp *gogpu.App
+ uiApp *app.App
+ canvas *ggcanvas.Canvas
+ debugOverlay dirtyOverlay
+
+ // Per-boundary GPU texture cache. Key = boundary cache key (uint64).
+ // Each boundary rendered into its own offscreen texture.
+ // Clean boundaries: texture reused. Dirty: re-rendered.
+ boundaryTextures map[uint64]*boundaryTexEntry
+ fullRedrawNeeded bool // First frame, resize, theme change
+}
+
+// boundaryTexEntry holds an offscreen GPU texture for a RepaintBoundary.
+type boundaryTexEntry struct {
+ texture gpucontext.TextureView
+ release func()
+ width int
+ height int
+ sceneVersion uint64 // tracks which scene version was last rendered into texture
+ clipRect geometry.Rect // screen-space clip for compositor scissoring
+ hasClip bool // whether clipRect is set
}
// draw is the OnDraw callback registered with gogpu.App.
@@ -75,7 +106,7 @@ type renderLoop struct {
// No persistent pixmap. No partial redraw. No RasterizerAnalytic hack.
// GPU SDF shapes are re-queued every frame via scene replay — no ephemeral
// shape loss. RepaintBoundary cache ensures O(dirty) re-recording cost.
-func (rl *renderLoop) draw(dc *gogpu.Context) {
+func (rl *renderLoop) draw(dc *gogpu.Context) { //nolint:gocyclo,cyclop // render loop orchestrates multiple pipeline stages (frame, layout, boundary textures, composite, overlays, present)
w, h := dc.Width(), dc.Height()
if w <= 0 || h <= 0 {
return
@@ -95,40 +126,446 @@ func (rl *renderLoop) draw(dc *gogpu.Context) {
log.Printf("desktop: canvas.Resize: %v", err)
}
cw, ch = w, h
+ rl.releaseBoundaryTextures()
+ rl.fullRedrawNeeded = true
}
win := rl.uiApp.Window()
- cc := rl.canvas.Context()
- // Surface dimensions may differ from canvas by 1-2px (integer rounding).
- sw, sh := dc.SurfaceSize()
+ // ADR-007 D2: skip GPU work when nothing changed. Frame() already ran
+ // (signals, layout, animations). If no boundary is dirty and no widget
+ // needs redraw, the previous frame's GPU output is still valid — reuse it.
+ // This is the retained-mode "0% GPU on idle" optimization.
+ //
+ // See: ADR-007 Phase 7, TASK-UI-OPT-001 (done: frame skip)
+ // Next: TASK-UI-OPT-003 (LoadOpLoad for <3% spinner GPU)
+ if !rl.fullRedrawNeeded && !win.HasDirtyBoundariesOrNeedsRedraw() &&
+ !widget.NeedsRedrawInTree(win.Root()) {
+ return
+ }
+
+ cc := rl.canvas.Context()
gg.BeginAcceleratorFrame()
cc.BeginGPUFrame()
+ cc.ResetFrameDamage()
+
+ // ADR-007 Phase 7: Per-boundary GPU textures.
+ //
+ // Each RepaintBoundary rendered into its own offscreen GPU texture.
+ // Dirty boundaries: re-render scene into texture (MSAA, LoadOpClear).
+ // Clean boundaries: reuse previous texture (0 GPU work).
+ // Compositor: blit all textures via non-MSAA path.
+ //
+ // Enterprise references: Flutter RasterCache, Chrome TileManager.
+ // Research: docs/dev/research/PER-BOUNDARY-GPU-TEXTURES-RESEARCH.md
+ root := win.Root()
+ winCtx := win.Context()
+
+ // Root boundary is always at window origin (0,0).
+ type originSetter interface{ SetScreenOrigin(geometry.Point) }
+ if os, ok := root.(originSetter); ok {
+ os.SetScreenOrigin(geometry.Point{})
+ }
+
+ // If any NON-BOUNDARY widget needs redraw (e.g., ScrollView after
+ // setScroll without parent chain), force root re-recording.
+ // Boundary widgets manage their own dirty state — they don't need
+ // to trigger root re-recording. This prevents offscreen animated
+ // boundaries (spinner) from forcing 60fps root re-recording.
+ if widget.NeedsRedrawInTreeNonBoundary(root) { //nolint:nestif // forced root invalidation with callback suppression requires nested type assertions
+ type sceneDirtier interface {
+ IsRepaintBoundary() bool
+ InvalidateScene()
+ }
+ if sd, ok := root.(sceneDirtier); ok && sd.IsRepaintBoundary() {
+ // Suppress onBoundaryDirty callback: we're already inside the
+ // render loop — no external notification needed. Without this,
+ // InvalidateScene fires ctx.InvalidateRect which restarts the
+ // animation pumper at 30fps for data tickers that only need 1fps.
+ type dirtySuppressor interface{ SetSuppressDirtyCallback(bool) }
+ if ds, ok2 := root.(dirtySuppressor); ok2 {
+ ds.SetSuppressDirtyCallback(true)
+ sd.InvalidateScene()
+ ds.SetSuppressDirtyCallback(false)
+ } else {
+ sd.InvalidateScene()
+ }
+ }
+ }
- // Clear background covering the full surface area.
- // GPU render pass LoadOpClear uses transparent black — we must cover
- // every pixel with the theme background to avoid black edges.
- bg := win.ThemeBackground()
- cc.SetRGBA(float64(bg.R), float64(bg.G), float64(bg.B), float64(bg.A))
- cc.DrawRectangle(0, 0, float64(sw), float64(sh))
- _ = cc.Fill()
-
- // Full tree draw. RepaintBoundary cache hits replay cached scene.Scene
- // via render.Canvas.ReplayScene (Push/Translate/GPUSceneRenderer/Pop).
- // All GPU shapes (SDF, text, paths) are queued into gg.Context pipeline.
+ app.PaintBoundaryLayersWithContext(root, nil, winCtx)
+
+ // CollectDirtyRegions AFTER PaintBoundaryLayers: root recording stamps
+ // fresh ScreenOrigin on child boundaries via StampScreenOrigin/DrawChild.
+ // Before this fix, CollectDirtyRegions ran before recording → spinner
+ // ScreenOrigin was stale (0,0) → damage rect at top-left corner.
+ win.CollectDirtyRegions()
+
+ // Render dirty boundaries into offscreen textures.
+ if rl.boundaryTextures == nil {
+ rl.boundaryTextures = make(map[uint64]*boundaryTexEntry)
+ rl.fullRedrawNeeded = true
+ }
+ rl.renderBoundaryTextures(root, cc)
+
+ // Compositor: blit all boundary textures onto surface.
+ rl.compositeTextures(root, cc, cw, ch)
+
+ // Overlays drawn on top (dropdowns, dialogs).
widgetCanvas := render.NewCanvas(cc, cw, ch)
- win.DrawTo(widgetCanvas)
- win.PaintDirtyBoundaries()
+ win.DrawOverlays(widgetCanvas)
+ win.ClearAfterPaint()
+ win.ClearDirtyBoundaries()
+
+ // Debug overlay: cyan flash-and-fade on dirty widget regions (ADR-023).
+ if isDebugDirtyEnabled() {
+ rl.debugOverlay.update(win.DirtyRegions())
+ rl.debugOverlay.draw(cc, rl.canvas.DeviceScale())
+ if rl.debugOverlay.needsAnimationFrame() {
+ rl.gogpuApp.RequestRedraw()
+ }
+ }
+
+ // ADR-021 Phase 7: Pass damage rects to gg for partial present.
+ // ui knows which boundaries are dirty → their screen bounds = damage rects.
+ // Chain: ui → gg SetPresentDamage → gogpu SetDamageRects → wgpu PresentWithDamage → OS.
+ if dirtyRegions := win.DirtyRegions(); len(dirtyRegions) > 0 {
+ rects := make([]image.Rectangle, len(dirtyRegions))
+ for i, r := range dirtyRegions {
+ rects[i] = image.Rect(
+ int(r.Min.X), int(r.Min.Y),
+ int(r.Max.X+0.5), int(r.Max.Y+0.5),
+ )
+ }
+ rl.canvas.SetPresentDamage(rects)
+ }
+
+ // Present via canvas.Render — single entry point for ALL backends (ADR-022).
+ // GPU direct path used when available, CPU fallback on software adapter.
+ // MarkDirty required because desktop.go draws directly to Context
+ // (not via canvas.Draw(fn) which sets dirty automatically).
+ rl.canvas.MarkDirty()
+ if err := rl.canvas.Render(dc.RenderTarget()); err != nil {
+ log.Printf("desktop: canvas.Render: %v", err)
+ }
+
+ // Request extra frames for gg-level damage overlay fade (GOGPU_DEBUG_DAMAGE=1).
+ if rl.canvas.NeedsAnimationFrame() {
+ rl.gogpuApp.RequestRedraw()
+ }
+}
- // Single render pass → surface.
- sv := dc.RenderTarget().SurfaceView()
- if sv.IsNil() {
+// replayLayerTree walks the layer tree and replays each PictureLayer
+// individually with per-layer damage tracking.
+//
+// Dirty layers replay WITH damage tracking → green overlay shows them.
+// Clean layers replay with damage SUPPRESSED → green overlay skips them.
+//
+// This is the Flutter compositeFrame pattern: addRetained for clean
+// layers (no engine work), addToScene for dirty layers (rebuild).
+// Our equivalent: SetDamageTracking(false) for clean layers.
+func replayLayerTree(layer compositor.Layer, canvas widget.Canvas) { //nolint:unused // retained for future Layer Tree integration (TASK-UI-OPT-005)
+ if layer == nil {
return
}
- if err := cc.FlushGPUWithView(sv, sw, sh); err != nil {
- log.Printf("desktop: FlushGPUWithView: %v", err)
+
+ offset := layer.Offset()
+ hasOffset := offset.X != 0 || offset.Y != 0
+
+ if hasOffset {
+ canvas.PushTransform(offset)
+ }
+
+ if po, ok := layer.(compositor.PictureOwner); ok {
+ pic := po.Picture()
+ if pic != nil && !pic.IsEmpty() {
+ canvas.ReplayScene(pic)
+ }
+ }
+
+ if cl, ok := layer.(compositor.ContainerLayer); ok {
+ for _, child := range cl.Children() {
+ replayLayerTree(child, canvas)
+ }
+ }
+
+ if hasOffset {
+ canvas.PopTransform()
+ }
+}
+
+// renderBoundaryTextures walks the widget tree and renders dirty RepaintBoundary
+// widgets into their own offscreen GPU textures. Clean boundaries keep their
+// previous texture (0 GPU work).
+//
+// This replaces the old replayLayerTree approach which replayed ALL scenes
+// through MSAA SDF pipeline every frame. Now only dirty boundaries render
+// (into small offscreen textures), clean boundaries are just texture blits.
+func (rl *renderLoop) renderBoundaryTextures(w widget.Widget, cc *gg.Context) {
+ rl.renderBoundaryTexturesRecursive(w, cc, 0)
+}
+
+func (rl *renderLoop) renderBoundaryTexturesRecursive(w widget.Widget, cc *gg.Context, depth int) { //nolint:gocognit // boundary tree walk requires type assertion nesting for interface extension pattern
+ if w == nil {
+ return
+ }
+
+ type boundaryInfo interface {
+ widget.Widget
+ IsRepaintBoundary() bool
+ IsSceneDirty() bool
+ CachedScene() *scene.Scene
+ BoundaryCacheKey() uint64
+ Bounds() geometry.Rect
+ Parent() widget.Widget
+ }
+
+ if bi, ok := w.(boundaryInfo); ok && bi.IsRepaintBoundary() { //nolint:nestif // boundary rendering with depth guards, visibility culling, and clip storage
+ if depth > 1 {
+ return
+ }
+
+ // Skip non-root boundaries with uninitialized ScreenOrigin.
+ if depth > 0 {
+ type originValidator interface{ IsScreenOriginValid() bool }
+ if ov, ok2 := w.(originValidator); ok2 && !ov.IsScreenOriginValid() {
+ return
+ }
+ }
+
+ // Skip rendering textures for items outside parent viewport.
+ if depth > 0 {
+ type compositorClipper interface {
+ HasCompositorClip() bool
+ CompositorClip() geometry.Rect
+ ScreenOrigin() geometry.Point
+ }
+ if cc2, ok2 := w.(compositorClipper); ok2 && cc2.HasCompositorClip() {
+ clip := cc2.CompositorClip()
+ origin := cc2.ScreenOrigin()
+ bounds := bi.Bounds()
+ screenRect := geometry.Rect{
+ Min: origin,
+ Max: geometry.Pt(origin.X+bounds.Width(), origin.Y+bounds.Height()),
+ }
+ if !screenRect.Intersects(clip) {
+ return
+ }
+ }
+ }
+
+ rl.renderSingleBoundary(bi, cc)
+
+ // Store clip rect in texture entry for compositor scissoring.
+ if depth > 0 {
+ type compositorClipper interface {
+ HasCompositorClip() bool
+ CompositorClip() geometry.Rect
+ }
+ if cc2, ok2 := w.(compositorClipper); ok2 && cc2.HasCompositorClip() {
+ key := bi.BoundaryCacheKey()
+ if entry := rl.boundaryTextures[key]; entry != nil {
+ entry.clipRect = cc2.CompositorClip()
+ entry.hasClip = true
+ }
+ }
+ }
+
+ for _, child := range w.Children() {
+ rl.renderBoundaryTexturesRecursive(child, cc, depth+1)
+ }
+ return
+ }
+
+ for _, child := range w.Children() {
+ rl.renderBoundaryTexturesRecursive(child, cc, depth)
+ }
+}
+
+// compositeTextures blits all boundary textures onto the surface.
+// Root boundary = DrawGPUTextureBase (background), others = DrawGPUTexture (overlays).
+// This uses the non-MSAA blit-only path (encodeBlitOnlyPass) — no MSAA overhead.
+//
+// See: ADR-007 Phase 7 (per-boundary GPU textures)
+// Task: TASK-UI-ADR007-PHASE7 (done)
+// Next: TASK-UI-OPT-003 (LoadOpLoad + damage rect scissor for <3% GPU)
+func (rl *renderLoop) compositeTextures(w widget.Widget, cc *gg.Context, _, _ int) {
+ isFirst := true
+ rl.walkBoundaries(w, func(key uint64, screenPos geometry.Point, bw, bh int) {
+ entry := rl.boundaryTextures[key]
+ if entry == nil || entry.texture.IsNil() {
+ return
+ }
+
+ // Use ScreenOrigin (window-space) for positioning, NOT Bounds().Min (local).
+ // ListView items have Bounds (0, y) in content-space but ScreenOrigin
+ // reflects accumulated transforms from parent Draw passes.
+ x, y := float64(screenPos.X), float64(screenPos.Y)
+
+ switch {
+ case isFirst:
+ cc.DrawGPUTextureBase(entry.texture, x, y, bw, bh)
+ isFirst = false
+ case entry.hasClip:
+ clip := entry.clipRect
+ cc.Push()
+ cc.ClipRect(float64(clip.Min.X), float64(clip.Min.Y),
+ float64(clip.Width()), float64(clip.Height()))
+ cc.DrawGPUTexture(entry.texture, x, y, bw, bh)
+ cc.Pop()
+ default:
+ cc.DrawGPUTexture(entry.texture, x, y, bw, bh)
+ }
+ })
+
+ rl.fullRedrawNeeded = false
+}
+
+// walkBoundaries walks the widget tree depth-first, calling fn for each RepaintBoundary.
+func (rl *renderLoop) walkBoundaries(w widget.Widget, fn func(key uint64, screenPos geometry.Point, width, height int)) {
+ rl.walkBoundariesRecursive(w, fn, 0)
+}
+
+func (rl *renderLoop) walkBoundariesRecursive(w widget.Widget, fn func(key uint64, screenPos geometry.Point, width, height int), depth int) {
+ if w == nil {
+ return
+ }
+
+ type boundaryChecker interface {
+ IsRepaintBoundary() bool
+ BoundaryCacheKey() uint64
+ Bounds() geometry.Rect
+ ScreenOrigin() geometry.Point
+ }
+
+ if bi, ok := w.(boundaryChecker); ok && bi.IsRepaintBoundary() { //nolint:nestif // boundary walk with type assertion chain for depth guard, origin validation, and viewport culling
+ if depth > 1 {
+ return
+ }
+
+ bounds := bi.Bounds()
+ screenPos := bi.ScreenOrigin()
+ bw, bh := int(bounds.Width()), int(bounds.Height())
+
+ // Skip non-root boundaries that were never drawn (viewport-culled).
+ // Their ScreenOrigin is uninitialized (0,0) — compositing would
+ // place the texture at the wrong position.
+ if depth > 0 {
+ type originValidator interface{ IsScreenOriginValid() bool }
+ if ov, ok2 := w.(originValidator); ok2 && !ov.IsScreenOriginValid() {
+ return
+ }
+ }
+
+ // Compositor clip (separate concern from boundary checking):
+ // skip items fully outside their parent's viewport.
+ // Uses interface extension via type assertion — same pattern as
+ // Focusable, DeviceScaler, DrawStatsProvider in codebase.
+ if depth > 0 {
+ type compositorClipper interface {
+ HasCompositorClip() bool
+ CompositorClip() geometry.Rect
+ }
+ if cc, ok2 := w.(compositorClipper); ok2 && cc.HasCompositorClip() {
+ clip := cc.CompositorClip()
+ screenRect := geometry.Rect{
+ Min: screenPos,
+ Max: geometry.Pt(screenPos.X+float32(bw), screenPos.Y+float32(bh)),
+ }
+ if !screenRect.Intersects(clip) {
+ return
+ }
+ }
+ }
+
+ fn(bi.BoundaryCacheKey(), screenPos, bw, bh)
+ for _, child := range w.Children() {
+ rl.walkBoundariesRecursive(child, fn, depth+1)
+ }
+ return
+ }
+
+ for _, child := range w.Children() {
+ rl.walkBoundariesRecursive(child, fn, depth)
+ }
+}
+
+// renderSingleBoundary renders one boundary's scene into its offscreen texture.
+func (rl *renderLoop) renderSingleBoundary(bi interface {
+ widget.Widget
+ IsRepaintBoundary() bool
+ IsSceneDirty() bool
+ CachedScene() *scene.Scene
+ BoundaryCacheKey() uint64
+ Bounds() geometry.Rect
+ Parent() widget.Widget
+}, cc *gg.Context) {
+ key := bi.BoundaryCacheKey()
+ bounds := bi.Bounds()
+ bw, bh := int(bounds.Width()), int(bounds.Height())
+ if bw <= 0 || bh <= 0 {
+ return
+ }
+
+ entry := rl.boundaryTextures[key]
+
+ if entry == nil || entry.width != bw || entry.height != bh {
+ if entry != nil && entry.release != nil {
+ entry.release()
+ }
+ tex, release := cc.CreateOffscreenTexture(bw, bh)
+ entry = &boundaryTexEntry{texture: tex, release: release, width: bw, height: bh}
+ rl.boundaryTextures[key] = entry
+ rl.fullRedrawNeeded = true
+ }
+
+ cachedScene := bi.CachedScene()
+
+ // Check if scene was freshly recorded by PaintBoundaryLayers.
+ // PaintBoundaryLayers clears sceneDirty BEFORE recording, so IsSceneDirty()
+ // returns false even for just-recorded scenes. Use SceneCacheVersion to detect
+ // fresh recordings — version increments on each re-record.
+ type versioner interface{ SceneCacheVersion() uint64 }
+ currentVersion := uint64(0)
+ if v, ok := bi.(versioner); ok {
+ currentVersion = v.SceneCacheVersion()
+ }
+ sceneChanged := entry.sceneVersion != currentVersion
+
+ if !sceneChanged && !bi.IsSceneDirty() && !rl.fullRedrawNeeded && cachedScene != nil {
+ return
+ }
+ if cachedScene == nil || cachedScene.IsEmpty() {
+ return
+ }
+
+ // Root boundary: draw theme background before scene content.
+ if bi.Parent() == nil {
+ win := rl.uiApp.Window()
+ bg := win.ThemeBackground()
+ cc.SetRGBA(float64(bg.R), float64(bg.G), float64(bg.B), float64(bg.A))
+ cc.DrawRectangle(0, 0, float64(bw), float64(bh))
+ _ = cc.Fill()
+ }
+
+ renderer := scene.NewGPUSceneRenderer(cc)
+ _ = renderer.RenderScene(cachedScene)
+ w, h := uint32(max(bw, 0)), uint32(max(bh, 0)) //nolint:gosec // bw/bh checked > 0 above
+ if err := cc.FlushGPUWithView(entry.texture, w, h); err != nil {
+ log.Printf("desktop: FlushGPUWithView boundary %d: %v", key, err)
+ }
+ entry.sceneVersion = currentVersion
+}
+
+// releaseBoundaryTextures frees all offscreen GPU textures.
+func (rl *renderLoop) releaseBoundaryTextures() {
+ for _, entry := range rl.boundaryTextures {
+ if entry.release != nil {
+ entry.release()
+ }
}
+ rl.boundaryTextures = nil
}
// initCanvas creates the ggcanvas lazily on the first draw call.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 88d412a..c6927ae 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -724,38 +724,102 @@ via `FrameStats.DrawStats` for performance monitoring and validation.
**Level 3: Per-widget pixel caching (implemented, Sub-Phase 2)**
Clean subtrees are composited from cached pixel buffers instead of re-drawn.
-`RepaintBoundary` wraps a widget subtree and caches it as `image.RGBA`.
-On cache hit, the cached image is blitted directly via `canvas.DrawImage()`.
+**RepaintBoundary** (ADR-024) is a WidgetBase property (`SetRepaintBoundary(true)`).
+Each boundary has its own `scene.Scene` for display list caching.
-**Level 4: Tile-parallel rendering (implemented, Sub-Phase 3)**
-Large RepaintBoundary subtrees (>= 128x128 pixels) use `scene.Scene` +
-`scene.Renderer` for tile-parallel rendering. `SceneCanvas` adapts
-`widget.Canvas` to record drawing commands into a `scene.Scene`, which is
-then rasterized via parallel tile workers. Text is rendered via gg.Context
-pass-through to preserve MSDF quality. Small RepaintBoundaries use the
-traditional `gg.Context` path to avoid scene setup overhead.
+**Level 4: Per-Boundary GPU Textures (ADR-007 Phase 7, v0.1.19)**
+
+Retained-mode compositor with per-boundary GPU textures and frame skip:
+
+```
+desktop.draw()
+ → Frame() signals, layout, animations
+ → [EARLY RETURN if nothing dirty → 0% GPU idle]
+ → PaintBoundaryLayers() re-record ONLY dirty+visible boundaries
+ → CollectDirtyRegions() dirty tracker (AFTER recording for fresh ScreenOrigin)
+ → renderBoundaryTextures() scene → GPU offscreen texture per boundary
+ → compositeTextures() blit all textures to surface (non-MSAA)
+ → DrawOverlays() dropdowns/dialogs on top
+```
+
+**GPU performance:** 0% idle (frame skip), 8% with visible spinner (30fps).
+
+Each RepaintBoundary rendered into its own GPU offscreen texture. Child boundaries
+(depth > 0) are **skipped** during parent recording (DrawChild skip pattern --
+Flutter `paintChild`). Each child boundary gets its own GPU texture, composed
+separately during `compositeTextures`. When a child boundary is dirty, the root
+re-records cheaply (child content skipped), and the child re-renders its own
+texture independently.
+
+**Frame skip (0% GPU idle):**
+`desktop.draw` returns early when `!HasDirtyBoundariesOrNeedsRedraw()` and
+`!NeedsRedrawInTree()`. Previous frame's GPU output is valid. No GPU work.
+
+**Offscreen boundary culling:**
+`isBoundaryVisible()` checks CompositorClip intersection before recording.
+Offscreen animated widgets (spinner scrolled out of view) are not recorded →
+`ScheduleAnimationFrame` not called → animation pumper stops → 0% GPU.
+
+**DrawChild skip pattern (Flutter paintChild):**
+During `recordBoundary`, the `BoundaryRecorder` checks each child: if the child
+has `IsRepaintBoundary() == true`, it is skipped (not drawn into the parent
+scene). Instead, the child's GPU texture is composed at the correct position
+during `compositeTextures` with GPU scissor clipping applied per viewport
+(ScrollView). This means parent re-recording is cheap -- it only draws
+non-boundary children (text, backgrounds, dividers) while boundary children
+retain their cached textures.
+
+**Force root re-recording:**
+`desktop.draw` checks `NeedsRedrawInTreeNonBoundary` on the root widget.
+If any non-boundary descendant is dirty, root re-records. Boundary descendants
+manage their own dirty state independently. The `onBoundaryDirty` callback is
+suppressed during this forced invalidation to prevent restarting the animation
+pumper from data tickers.
+
+**Compositor scissor clipping:**
+Items inside ScrollView viewports are clipped via GPU scissor rect during
+texture composition (`compositeTextures`), not during scene recording. Each
+boundary group in the blit pass has per-group scissor applied.
+
+**ScreenOriginBase:**
+`recordBoundary` sets `ScreenOriginBase` from the boundary widget's screen
+position before recording child content. This ensures nested boundaries get
+correct screen-space origins for compositor texture placement (fixes nested
+boundary positioning in ScrollView).
+
+**Scrollbar track repeat (Qt6 timing):**
+Track repeat uses Qt6 `QScrollBar` timing: 500ms initial delay, 50ms repeat
+interval. Event-driven (no polling goroutine) to prevent root re-recording
+flood.
+
+**SVG icon rendering** uses CPU rasterization (`RasterizerAnalytic`) into
+scene.Image, with a 2-level LRU IconCache (Level 1: parsed docs, Level 2:
+rasterized bitmaps by ptr+size+color). DPI-aware: renders at physical pixel
+size (`ceil(logical × deviceScale)`).
The dirty-tracking flow:
```
-Signal.Set(value)
- -> BindToScheduler -> Scheduler.MarkDirty(widget)
- -> Scheduler.SetOnDirty callback -> RequestRedraw()
- -> Frame()
- -> scheduler.Flush() -> flushFn sets needsRedraw on dirty widgets
- -> Layout pass (if needed, also marks all widgets dirty)
- -> Draw pass: DrawTree(root, ctx, canvas) -> DrawStats
- - Draws root widget (which draws children)
- - Collects dirty/clean/skipped counts
- - ClearRedrawInTree() clears all flags after draw
+Widget state change (hover, click, signal)
+ → SetNeedsRedraw(true)
+ → propagateDirtyUpward(parent) → root boundary → InvalidateScene()
+ → onBoundaryDirty callback → ctx.InvalidateRect() → RequestRedraw()
+ → desktop.draw: NeedsRedrawInTree check → force root re-record
+ → PaintBoundaryLayers: recordBoundary() with DrawChild skip
+ → SceneCanvas records non-boundary widgets into scene.Scene
+ → renderSingleBoundary: GPUSceneRenderer → FlushGPUWithView(texture)
+ → compositeTextures: DrawGPUTextureBase + scissor clip → surface
```
Key functions:
-- `widget.DrawTree(w, ctx, canvas)` -- draws root, returns `DrawStats`
-- `widget.CollectDrawStats(w)` -- walks tree without drawing, returns stats
-- `widget.NeedsRedrawInTree(w)` -- short-circuit check for any dirty widget
+- `PaintBoundaryLayersWithContext(root, _, ctx)` — re-records dirty boundaries
+- `renderBoundaryTextures(root, cc)` — renders scenes into GPU textures
+- `compositeTextures(root, cc, w, h)` — blits textures with scissor clip to surface
+- `paintBoundaryWithDepth(w, ctx, depth)` — depth-aware dirty propagation
+- `recordBoundary(w, ctx)` — records scene with DrawChild skip for child boundaries
- `widget.ClearRedrawInTree(w)` -- clears all flags recursively
- `widget.MarkRedrawInTree(w)` -- marks all widgets dirty (used by resize, theme change)
+- `widget.NeedsRedrawInTree(w)` -- checks if any descendant needs redraw
### Canvas Implementation
@@ -1298,13 +1362,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.37.1 |
-| `github.com/gogpu/gpucontext` | Window/Platform provider interfaces | v0.10.0 |
-| `github.com/gogpu/gogpu` | Application framework, windowing (examples only) | v0.24.2 |
+| `github.com/gogpu/gg` | 2D graphics + scene.Scene tile-parallel rendering | v0.46.3 |
+| `github.com/gogpu/gpucontext` | Window/Platform provider interfaces | v0.18.0 |
+| `github.com/gogpu/gogpu` | Application framework, windowing (examples only) | v0.34.0 |
| `github.com/coregx/signals` | Reactive state management | v0.1.0 |
-| `golang.org/x/image` | Font rendering infrastructure | v0.37.0 |
+| `golang.org/x/image` | Font rendering infrastructure | v0.39.0 |
-**Indirect:** gogpu/wgpu v0.21.1, gogpu/naga v0.14.7, gogpu/gputypes v0.3.0, go-text/typesetting v0.3.4, golang.org/x/text v0.35.0
+**Indirect:** gogpu/wgpu v0.27.1, gogpu/naga v0.17.13, gogpu/gputypes v0.5.0, go-text/typesetting v0.3.4, golang.org/x/text v0.35.0
Go version: **1.25.0**
@@ -1382,4 +1446,4 @@ All types in `geometry/` are small structs passed by value. Operations return ne
---
-*This document reflects the actual codebase as of March 15, 2026 (61 commits on feat/ui-058-hbox-direction).*
+*This document reflects the actual codebase as of May 10, 2026 (v0.1.19 — per-boundary GPU textures, 0% GPU idle, offscreen culling, 34 integration tests).*
diff --git a/examples/taskmanager/main.go b/examples/taskmanager/main.go
index 524df2e..9d1baf6 100644
--- a/examples/taskmanager/main.go
+++ b/examples/taskmanager/main.go
@@ -174,7 +174,7 @@ func buildCPUSection(sim *simState) *collapsible.Widget {
).Gap(8).Padding(12).Background(colorSurface).Rounded(6)
return collapsible.New(
- collapsible.TitleFn(func() string { return sim.cpuTitle.Get() }),
+ collapsible.TitleSignal(sim.cpuTitle),
collapsible.Content(content),
collapsible.Expanded(true),
collapsible.Animated(true),
@@ -216,7 +216,7 @@ func buildMemorySection(sim *simState) *collapsible.Widget {
).Gap(8).Padding(12).Background(colorSurface).Rounded(6)
return collapsible.New(
- collapsible.TitleFn(func() string { return sim.memTitle.Get() }),
+ collapsible.TitleSignal(sim.memTitle),
collapsible.Content(content),
collapsible.Expanded(true),
collapsible.Animated(true),
@@ -252,7 +252,7 @@ func buildDiskSection(sim *simState) *collapsible.Widget {
).Gap(8).Padding(12).Background(colorSurface).Rounded(6)
return collapsible.New(
- collapsible.TitleFn(func() string { return sim.diskTitle.Get() }),
+ collapsible.TitleSignal(sim.diskTitle),
collapsible.Content(content),
collapsible.Expanded(true),
collapsible.Animated(true),
diff --git a/focus/focus_test.go b/focus/focus_test.go
index bf0ed18..ce5291c 100644
--- a/focus/focus_test.go
+++ b/focus/focus_test.go
@@ -96,6 +96,7 @@ func (c *mockCanvas) PopClip() {}
func (c *mockCanvas) PushTransform(_ geometry.Point) {}
func (c *mockCanvas) PopTransform() {}
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/go.mod b/go.mod
index 5fc510d..5ab3902 100644
--- a/go.mod
+++ b/go.mod
@@ -4,9 +4,9 @@ go 1.25.0
require (
github.com/coregx/signals v0.1.0
- github.com/gogpu/gg v0.44.1
- github.com/gogpu/gogpu v0.31.0
- github.com/gogpu/gpucontext v0.16.0
+ github.com/gogpu/gg v0.46.4
+ github.com/gogpu/gogpu v0.34.0
+ github.com/gogpu/gpucontext v0.18.0
golang.org/x/image v0.39.0
)
@@ -15,8 +15,8 @@ require (
github.com/go-webgpu/goffi v0.5.0 // indirect
github.com/go-webgpu/webgpu v0.4.3 // indirect
github.com/gogpu/gputypes v0.5.0 // indirect
- github.com/gogpu/naga v0.17.10 // indirect
- github.com/gogpu/wgpu v0.26.12 // indirect
- golang.org/x/sys v0.43.0 // indirect
+ github.com/gogpu/naga v0.17.13 // indirect
+ github.com/gogpu/wgpu v0.27.1 // indirect
+ golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.36.0 // indirect
)
diff --git a/go.sum b/go.sum
index 3bd6a47..6079f57 100644
--- a/go.sum
+++ b/go.sum
@@ -8,25 +8,23 @@ 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/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.43.7 h1:0+0NRc/w84CKDdYkH1ope2EIk+aJnVHpPwAevbDnqe8=
-github.com/gogpu/gg v0.43.7/go.mod h1:fMFOLpxJHXFn8K7GdfmOIkK7NUiPdJ+yHjcAIQbq2Qo=
-github.com/gogpu/gg v0.44.0 h1:cv+zi+aQ2qyVhEaMFoU+giG+ss77ecQNxctJEdJomzI=
-github.com/gogpu/gg v0.44.0/go.mod h1:fMFOLpxJHXFn8K7GdfmOIkK7NUiPdJ+yHjcAIQbq2Qo=
-github.com/gogpu/gg v0.44.1 h1:g5QPLnX1xDx0JPdwYqKYnpmEhz1/GRaPC7OnMMIPhlQ=
-github.com/gogpu/gg v0.44.1/go.mod h1:fMFOLpxJHXFn8K7GdfmOIkK7NUiPdJ+yHjcAIQbq2Qo=
-github.com/gogpu/gogpu v0.31.0 h1:vrV0d2DhPNacYoSetkqWzHu3wI+rSCEmvmF6S7pSVus=
-github.com/gogpu/gogpu v0.31.0/go.mod h1:pPMTJ4s+Xfw+gbcO9GWijN9CmQ2J/8JP2GcZjhVHa2w=
-github.com/gogpu/gpucontext v0.16.0 h1:33PhNAtaTyOjpR/foSzW4JjgWjX1W4cuJxjofGFs74M=
-github.com/gogpu/gpucontext v0.16.0/go.mod h1:6zwdmYXH5GQltoiHbb3WXVS/UJ5bFsCux0mXCVqGlzY=
+github.com/gogpu/gg v0.46.3 h1:lvGZykQCn58+SwPEaIAmzChLxb19Nb5NblCtOTPean0=
+github.com/gogpu/gg v0.46.3/go.mod h1:83rYhMMgcEuyt2oAsnLuGL86LAO6ljWKRs+7n9evlZ0=
+github.com/gogpu/gg v0.46.4 h1:gsLtJmDuWWPgDR9F2+pljM+Mlhj2OUsiRKFrFXYXO1g=
+github.com/gogpu/gg v0.46.4/go.mod h1:83rYhMMgcEuyt2oAsnLuGL86LAO6ljWKRs+7n9evlZ0=
+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/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=
github.com/gogpu/gputypes v0.5.0/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw=
-github.com/gogpu/naga v0.17.10 h1:dJjMXb7b5ybSK8XbsiCA5aUVIAvLHjJg129FB1Ocz/I=
-github.com/gogpu/naga v0.17.10/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg=
-github.com/gogpu/wgpu v0.26.12 h1:lJQVycMf01010YpG4vnODtEWXVHGhAbsK7ksstnRVys=
-github.com/gogpu/wgpu v0.26.12/go.mod h1:qiJGYshbptL0nHkoDPW/j7aZw2ILADBTXDsPyPTylCU=
+github.com/gogpu/naga v0.17.13 h1:VlponVgD1fEfNotx0874M4n7tnfum8YlMEB3pBdd2Ps=
+github.com/gogpu/naga v0.17.13/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg=
+github.com/gogpu/wgpu v0.27.1 h1:uEiZTj6EFNZ2VWVSB9q7+Gqc+f9zsYuCe1Giu7ECKro=
+github.com/gogpu/wgpu v0.27.1/go.mod h1:LordcEpJM76P0Ispw3r+3F2fAhd8khbBL7PgUa2iW/A=
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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+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=
diff --git a/icon/icon_test.go b/icon/icon_test.go
index 095e2a2..7f92e9c 100644
--- a/icon/icon_test.go
+++ b/icon/icon_test.go
@@ -285,6 +285,7 @@ func (m *mockCanvas) PopClip() {}
func (m *mockCanvas) PushTransform(geometry.Point) {}
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) {}
diff --git a/internal/dirty/collector.go b/internal/dirty/collector.go
index e014e07..f1ec4d9 100644
--- a/internal/dirty/collector.go
+++ b/internal/dirty/collector.go
@@ -1,15 +1,28 @@
package dirty
import (
+ "fmt"
+ "log"
+ "os"
+
"github.com/gogpu/ui/geometry"
"github.com/gogpu/ui/widget"
)
+func init() {
+ if os.Getenv("GOGPU_DEBUG_COLLECTOR") == "1" {
+ collectorDebug = true
+ }
+}
+
+var collectorDebug bool
+
// Collector walks the widget tree and collects dirty regions from widgets
// that have NeedsRedraw set. It populates a Tracker with the bounds of
// each dirty widget.
type Collector struct {
tracker *Tracker
+ debug bool //nolint:unused // retained for GOGPU_DEBUG_COLLECTOR=1 (enterprise logging)
}
// NewCollector creates a new Collector that writes dirty regions to the
@@ -40,18 +53,149 @@ func (c *Collector) collect(w widget.Widget) {
return
}
- // Check if this widget needs redraw.
+ // Viewport containers (ScrollView) act as dirty boundaries.
+ // Flutter pattern: Viewport is RepaintBoundary — Collector clips child
+ // dirty regions to viewport bounds instead of reporting full content.
+ if vc, ok := w.(interface{ IsViewportClip() bool }); ok && vc.IsViewportClip() { //nolint:nestif // viewport dirty collection with leaf-dirty pattern and debug logging
+ if c.isWidgetDirty(w) {
+ children := w.Children()
+ hasDirty := c.hasDirtyChild(children)
+ if collectorDebug {
+ log.Printf("[COLLECTOR] viewport %T dirty, children=%d, hasDirtyChild=%v",
+ w, len(children), hasDirty)
+ for i, ch := range children {
+ log.Printf("[COLLECTOR] child[%d] %T dirty=%v", i, ch, c.isWidgetDirty(ch))
+ }
+ }
+ if hasDirty {
+ c.collectViewportChildren(w)
+ } else {
+ c.markWidgetDirty(w)
+ }
+ } else {
+ c.collectViewportChildren(w)
+ }
+ return
+ }
+
dirty := c.isWidgetDirty(w)
+
+ if collectorDebug && dirty {
+ children := w.Children()
+ hasDC := c.hasDirtyChild(children)
+ log.Printf("[COLLECT] %T dirty=%v children=%d hasDirtyChild=%v",
+ w, dirty, len(children), hasDC)
+ }
+
+ // Leaf dirty pattern: if widget dirty AND has dirty children,
+ // skip self and report only children (smaller dirty rects).
+ children := w.Children()
+ if dirty && c.hasDirtyChild(children) {
+ for _, child := range children {
+ c.collect(child)
+ }
+ return
+ }
+
if dirty {
c.markWidgetDirty(w)
}
- // Recurse into children.
- for _, child := range w.Children() {
+ for _, child := range children {
c.collect(child)
}
}
+// collectViewportChildren recurses into a viewport container's children,
+// collecting dirty regions clipped to the viewport bounds.
+func (c *Collector) collectViewportChildren(viewport widget.Widget) {
+ type screenBounder interface {
+ ScreenBounds() geometry.Rect
+ }
+ var vpBounds geometry.Rect
+ if sb, ok := viewport.(screenBounder); ok {
+ vpBounds = sb.ScreenBounds()
+ }
+
+ var collectClipped func(w widget.Widget, depth int)
+ collectClipped = func(w widget.Widget, depth int) {
+ if vis, ok := w.(interface{ IsVisible() bool }); ok && !vis.IsVisible() {
+ return
+ }
+ children := w.Children()
+ if c.isWidgetDirty(w) { //nolint:nestif // clipped dirty collection with leaf-dirty pattern and debug logging
+ hasDirty := c.hasDirtyChild(children)
+ if collectorDebug {
+ indent := ""
+ for range depth {
+ indent += " "
+ }
+ log.Printf("[COLLECTOR] %s%T dirty, children=%d, hasDirtyChild=%v",
+ indent, w, len(children), hasDirty)
+ }
+ if hasDirty {
+ for _, child := range children {
+ collectClipped(child, depth+1)
+ }
+ return
+ }
+ if collectorDebug {
+ type sb interface{ ScreenBounds() geometry.Rect }
+ if s, ok := w.(sb); ok {
+ log.Printf("[COLLECTOR] %s→ markClippedDirty bounds=%v", fmt.Sprintf("%*s", depth*2, ""), s.ScreenBounds())
+ }
+ }
+ c.markClippedDirty(w, vpBounds)
+ }
+ for _, child := range children {
+ collectClipped(child, depth+1)
+ }
+ }
+
+ for _, child := range viewport.Children() {
+ collectClipped(child, 0)
+ }
+}
+
+func intersectRect(a, b geometry.Rect) geometry.Rect {
+ r := geometry.Rect{
+ Min: geometry.Point{X: max(a.Min.X, b.Min.X), Y: max(a.Min.Y, b.Min.Y)},
+ Max: geometry.Point{X: min(a.Max.X, b.Max.X), Y: min(a.Max.Y, b.Max.Y)},
+ }
+ if r.Min.X >= r.Max.X || r.Min.Y >= r.Max.Y {
+ return geometry.Rect{}
+ }
+ return r
+}
+
+// markClippedDirty adds a dirty widget's bounds clipped to the viewport.
+func (c *Collector) markClippedDirty(w widget.Widget, vpBounds geometry.Rect) {
+ type screenBounder interface {
+ ScreenBounds() geometry.Rect
+ }
+ sb, ok := w.(screenBounder)
+ if !ok {
+ return
+ }
+ bounds := sb.ScreenBounds()
+ if !vpBounds.IsEmpty() {
+ bounds = intersectRect(bounds, vpBounds)
+ }
+ if !bounds.IsEmpty() {
+ c.tracker.MarkDirty(bounds)
+ }
+}
+
+// hasDirtyChild checks if any immediate child is dirty.
+func (c *Collector) hasDirtyChild(children []widget.Widget) bool {
+ for _, child := range children {
+ if c.isWidgetDirty(child) {
+ return true
+ }
+ }
+ return false
+}
+
// isWidgetDirty returns true if the widget needs redrawing.
// Widgets without a NeedsRedraw method (no WidgetBase) are always considered dirty.
func (c *Collector) isWidgetDirty(w widget.Widget) bool {
@@ -74,11 +218,21 @@ func (c *Collector) isWidgetDirty(w widget.Widget) bool {
// Follows Qt QWidgetRepaintManager::markDirty pattern: translate
// widget-local rect to top-level window coordinates at collection time.
func (c *Collector) markWidgetDirty(w widget.Widget) {
+ if collectorDebug {
+ type sb interface{ ScreenBounds() geometry.Rect }
+ if s, ok := w.(sb); ok {
+ log.Printf("[MARK-DIRTY] %T screenBounds=%v", w, s.ScreenBounds())
+ } else {
+ log.Printf("[MARK-DIRTY] %T (no ScreenBounds)", w)
+ }
+ }
type screenBounder interface {
ScreenBounds() geometry.Rect
}
if sb, ok := w.(screenBounder); ok {
- c.tracker.MarkDirty(sb.ScreenBounds())
+ bounds := sb.ScreenBounds()
+ bounds = c.clipToParentViewport(w, bounds)
+ c.tracker.MarkDirty(bounds)
return
}
type bounder interface {
@@ -88,3 +242,46 @@ func (c *Collector) markWidgetDirty(w widget.Widget) {
c.tracker.MarkDirty(b.Bounds())
}
}
+
+// clipToParentViewport intersects bounds with parent's screen bounds.
+// Scroll content widgets have bounds larger than viewport — clipping
+// prevents dirty regions from exceeding the visible area.
+func (c *Collector) clipToParentViewport(w widget.Widget, bounds geometry.Rect) geometry.Rect {
+ type parentGetter interface {
+ Parent() widget.Widget
+ }
+ pg, ok := w.(parentGetter)
+ if !ok {
+ return bounds
+ }
+ parent := pg.Parent()
+ if parent == nil {
+ return bounds
+ }
+ type screenBounder interface {
+ ScreenBounds() geometry.Rect
+ }
+ sb, ok := parent.(screenBounder)
+ if !ok {
+ return bounds
+ }
+ parentBounds := sb.ScreenBounds()
+ if parentBounds.IsEmpty() {
+ return bounds
+ }
+ // Manual intersect (geometry.Rect has no Intersect method).
+ clipped := geometry.Rect{
+ Min: geometry.Point{
+ X: max(bounds.Min.X, parentBounds.Min.X),
+ Y: max(bounds.Min.Y, parentBounds.Min.Y),
+ },
+ Max: geometry.Point{
+ X: min(bounds.Max.X, parentBounds.Max.X),
+ Y: min(bounds.Max.Y, parentBounds.Max.Y),
+ },
+ }
+ if clipped.Min.X >= clipped.Max.X || clipped.Min.Y >= clipped.Max.Y {
+ return geometry.Rect{}
+ }
+ return clipped
+}
diff --git a/internal/dirty/collector_test.go b/internal/dirty/collector_test.go
index a7cc9b6..bfc5657 100644
--- a/internal/dirty/collector_test.go
+++ b/internal/dirty/collector_test.go
@@ -230,9 +230,10 @@ func TestCollector_CustomWidgetWithChildren(t *testing.T) {
}
c.Collect(cw)
- // Custom widget itself + dirty child = 2 regions.
- if tr.RegionCount() != 2 {
- t.Errorf("region count = %d, want 2", tr.RegionCount())
+ // Leaf-dirty pattern: custom widget has dirty child → skip self,
+ // report only child. Result: 1 region (child only).
+ if tr.RegionCount() != 1 {
+ t.Errorf("region count = %d, want 1 (leaf child only)", tr.RegionCount())
}
}
@@ -319,9 +320,9 @@ func TestCollector_BothParentAndChildDirty(t *testing.T) {
c.Collect(parent)
- // Both parent and child are dirty — 2 regions (optimization merges later).
- if tr.RegionCount() != 2 {
- t.Errorf("region count = %d, want 2", tr.RegionCount())
+ // Leaf-dirty pattern: parent has dirty child → skip parent, report child only.
+ if tr.RegionCount() != 1 {
+ t.Errorf("region count = %d, want 1 (leaf child only)", tr.RegionCount())
}
}
@@ -386,9 +387,10 @@ func TestCollectOptimizeIntersect(t *testing.T) {
c.Collect(root)
tr.Optimize()
- // w1 and w2 should merge (within default 16px gap), w3 stays separate.
- if tr.RegionCount() != 2 {
- t.Errorf("after optimize: region count = %d, want 2", tr.RegionCount())
+ // With mergeGap=0, only overlapping regions merge. Adjacent (non-overlapping)
+ // regions stay separate for precise dirty tracking.
+ if tr.RegionCount() != 3 {
+ t.Errorf("after optimize: region count = %d, want 3 (no gap merge)", tr.RegionCount())
}
// Widget in merged region should intersect.
@@ -400,3 +402,362 @@ func TestCollectOptimizeIntersect(t *testing.T) {
t.Error("should not intersect between regions")
}
}
+
+// --- Viewport clip regression tests (2026-05-07) ---
+
+// viewportWidget implements IsViewportClip() to act as a ScrollView-like container.
+type viewportWidget struct {
+ widget.WidgetBase
+ kids []widget.Widget
+}
+
+func newViewportWidget(w, h float32, children ...widget.Widget) *viewportWidget {
+ vp := &viewportWidget{kids: children}
+ vp.SetVisible(true)
+ vp.SetEnabled(true)
+ vp.SetBounds(geometry.NewRect(0, 0, w, h))
+ vp.SetScreenOrigin(geometry.Pt(0, 0))
+ return vp
+}
+
+func (w *viewportWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(w.Bounds().Size())
+}
+
+func (w *viewportWidget) Draw(_ widget.Context, _ widget.Canvas) {}
+
+func (w *viewportWidget) Event(_ widget.Context, _ event.Event) bool { return false }
+
+func (w *viewportWidget) Children() []widget.Widget { return w.kids }
+
+func (w *viewportWidget) IsViewportClip() bool { return true }
+
+func (w *viewportWidget) ScreenBounds() geometry.Rect {
+ return geometry.NewRect(
+ w.Bounds().Min.X, w.Bounds().Min.Y,
+ w.Bounds().Width(), w.Bounds().Height(),
+ )
+}
+
+// TestCollectorViewportClipsDirtyRegion verifies that a dirty widget inside
+// a viewport container (IsViewportClip=true) has its dirty region clipped to
+// the viewport bounds. Before the fix, a widget with 36000px height inside
+// a 300px ScrollView would produce a 36000px dirty region, causing the
+// entire window to be repainted.
+// Regression: widget with bounds 36000px inside ScrollView -> huge dirty region (2026-05-07)
+func TestCollectorViewportClipsDirtyRegion(t *testing.T) {
+ tr := NewTracker()
+ c := NewCollector(tr)
+
+ // Large dirty content inside a small viewport.
+ content := newTestWidget(0, 0, 300, 36000)
+ content.SetNeedsRedraw(true)
+
+ viewport := newViewportWidget(300, 300, content)
+ viewport.ClearRedraw()
+
+ c.Collect(viewport)
+
+ if tr.IsEmpty() {
+ t.Fatal("dirty content inside viewport should produce a dirty region")
+ }
+
+ // The dirty region must be clipped to the viewport bounds (300px),
+ // not the full content bounds (36000px).
+ regions := tr.DirtyRegions()
+ for _, r := range regions {
+ if r.Bounds.Height() > 300 {
+ t.Errorf("dirty region height = %v, want <= 300 (viewport clip); "+
+ "Collector must clip dirty regions to viewport bounds",
+ r.Bounds.Height())
+ }
+ }
+}
+
+// TestCollectorSkipsCleanViewportChildren verifies that when a viewport
+// container and all its children are clean, the Collector reports zero
+// dirty regions. This ensures the viewport-specific path does not
+// spuriously generate dirty regions.
+// Regression: ensures viewport clean path produces 0 regions (2026-05-07)
+func TestCollectorSkipsCleanViewportChildren(t *testing.T) {
+ tr := NewTracker()
+ c := NewCollector(tr)
+
+ // Clean content inside clean viewport.
+ content := newTestWidget(0, 0, 300, 1000)
+ content.ClearRedraw()
+
+ viewport := newViewportWidget(300, 300, content)
+ viewport.ClearRedraw()
+
+ c.Collect(viewport)
+
+ if !tr.IsEmpty() {
+ t.Errorf("all-clean viewport should produce 0 dirty regions, got %d",
+ tr.RegionCount())
+ }
+}
+
+// --- Leaf Dirty Region Tests (ADR-024 RepaintBoundary integration) ---
+//
+// When a child widget is dirty (e.g., checkbox hover), propagateDirtyUpward
+// marks all ancestors dirty too. The Collector must report LEAF dirty widget
+// bounds (small rect), NOT parent container bounds (full card).
+//
+// Without this, cyan overlay shows full-window dirty on every hover →
+// appears as "always full repaint" when only a small widget changed.
+
+// TestCollector_LeafDirtyNotParent verifies that when a child is dirty
+// AND its parent is dirty (via propagation), only the CHILD's bounds
+// are reported — not the parent's large bounds.
+func TestCollector_LeafDirtyNotParent(t *testing.T) {
+ // Parent: large card (0,0 → 400,300).
+ parent := newTestWidget(0, 0, 400, 300)
+
+ // Child: small checkbox (10,10 → 200,36).
+ child := newTestWidget(10, 10, 200, 36)
+ child.SetParent(parent)
+ parent.AddChild(child)
+
+ // Simulate propagateDirtyUpward: child dirty → parent dirty.
+ child.SetNeedsRedraw(true)
+ parent.SetNeedsRedraw(true) // marked by propagation
+
+ tr := NewTracker()
+ c := NewCollector(tr)
+ c.Collect(parent)
+
+ regions := tr.DirtyRegions()
+
+ // We should get child bounds (small), NOT parent bounds (large).
+ // If parent bounds are reported, it means the overlay will show
+ // full card cyan on every checkbox hover.
+ for _, r := range regions {
+ if r.Bounds.Width() > 250 || r.Bounds.Height() > 100 {
+ t.Errorf("dirty region too large: %v — should be child bounds (~200x36), "+
+ "not parent bounds (~400x300). Collector reports parent container "+
+ "instead of leaf dirty widget.", r.Bounds)
+ }
+ }
+
+ // Must have at least one region (the child).
+ if len(regions) == 0 {
+ t.Error("expected at least 1 dirty region for the dirty child")
+ }
+}
+
+// TestCollector_OnlyLeafDirtyReported verifies that when parent is dirty
+// ONLY because of propagation (has dirty children), the parent's own bounds
+// are NOT added — only leaf dirty children are reported.
+func TestCollector_OnlyLeafDirtyReported(t *testing.T) {
+ parent := newTestWidget(0, 0, 800, 600)
+
+ child1 := newTestWidget(10, 10, 100, 30) // dirty
+ child1.SetParent(parent)
+ parent.AddChild(child1)
+
+ child2 := newTestWidget(10, 50, 100, 30) // clean
+ child2.SetParent(parent)
+ parent.AddChild(child2)
+
+ child1.SetNeedsRedraw(true)
+ parent.SetNeedsRedraw(true) // propagation artifact
+
+ tr := NewTracker()
+ c := NewCollector(tr)
+ c.Collect(parent)
+
+ regions := tr.DirtyRegions()
+
+ // Should have exactly 1 region: child1 bounds.
+ // Parent should NOT be reported (it has dirty children → skip self).
+ // child2 should NOT be reported (it's clean).
+ foundChild1 := false
+ foundParent := false
+ for _, r := range regions {
+ if r.Bounds.Width() >= 700 {
+ foundParent = true
+ }
+ if r.Bounds.Width() <= 150 && r.Bounds.Height() <= 50 {
+ foundChild1 = true
+ }
+ }
+
+ if foundParent {
+ t.Error("parent bounds (800x600) should NOT be reported when it has dirty children; " +
+ "Collector should skip parent and report only leaf dirty widgets")
+ }
+ if !foundChild1 {
+ t.Error("child1 bounds should be reported as dirty region")
+ }
+}
+
+// TestCollector_DeepNestingLeafDirty verifies that leaf-dirty pattern
+// works through deeply nested containers (taskmanager/gallery pattern:
+// chart inside collapsible inside card inside ScrollView).
+func TestCollector_DeepNestingLeafDirty(t *testing.T) {
+ // Simulate: root → card → section → chart (dirty)
+ root := newTestWidget(0, 0, 800, 600)
+ card := newTestWidget(24, 24, 736, 500)
+ card.SetParent(root)
+ root.AddChild(card)
+
+ section := newTestWidget(32, 100, 672, 200)
+ section.SetParent(card)
+ card.AddChild(section)
+
+ chart := newTestWidget(32, 120, 640, 160)
+ chart.SetParent(section)
+ section.AddChild(chart)
+
+ // Only chart dirty (PushValue → SetNeedsRedraw → propagation)
+ chart.SetNeedsRedraw(true)
+ // propagation marks ancestors: section, card, root
+
+ tr := NewTracker()
+ c := NewCollector(tr)
+ c.Collect(root)
+
+ regions := tr.DirtyRegions()
+
+ // Should find chart bounds (~640x160), NOT root/card/section bounds
+ foundLeaf := false
+ foundLarge := false
+ for _, r := range regions {
+ if r.Bounds.Width() <= 650 && r.Bounds.Height() <= 170 {
+ foundLeaf = true
+ }
+ if r.Bounds.Width() > 700 {
+ foundLarge = true
+ }
+ }
+
+ if !foundLeaf {
+ t.Error("chart leaf bounds NOT found in dirty regions; " +
+ "Collector doesn't recurse deep enough through leaf-dirty pattern")
+ }
+ if foundLarge {
+ t.Error("parent container bounds found — leaf-dirty not working for deep nesting")
+ }
+}
+
+// TestCollector_GalleryPattern_ScrollViewWithSections verifies leaf-dirty
+// for gallery pattern: ScrollView(viewport) → VBox → sections → leaf widget.
+func TestCollector_GalleryPattern_ScrollViewWithSections(t *testing.T) {
+ chart := newTestWidget(32, 340, 640, 160)
+
+ section1 := newTestWidget(24, 0, 720, 300)
+ section2 := newTestWidget(24, 320, 720, 200)
+ section2.AddChild(chart)
+ chart.SetParent(section2)
+
+ vbox := newTestWidget(0, 0, 760, 2000)
+ vbox.AddChild(section1)
+ vbox.AddChild(section2)
+ section1.SetParent(vbox)
+ section2.SetParent(vbox)
+
+ scrollView := newViewportWidget(800, 600, vbox)
+ vbox.SetParent(scrollView)
+
+ chart.SetNeedsRedraw(true)
+
+ tr := NewTracker()
+ c := NewCollector(tr)
+ c.Collect(scrollView)
+
+ regions := tr.DirtyRegions()
+
+ foundChart := false
+ foundLarge := false
+ for _, r := range regions {
+ w := r.Bounds.Width()
+ h := r.Bounds.Height()
+ if w <= 650 && h <= 170 {
+ foundChart = true
+ }
+ if w > 700 && h > 300 {
+ foundLarge = true
+ }
+ }
+
+ if !foundChart {
+ t.Errorf("chart leaf bounds NOT found; regions=%v", regions)
+ }
+ if foundLarge {
+ t.Errorf("large container bounds found — leaf-dirty not working through viewport; regions=%v", regions)
+ }
+}
+
+// TestCollector_TaskmanagerPattern_ChartInCollapsible verifies leaf-dirty
+// for taskmanager pattern: ScrollView → VBox → Collapsible → chart.
+// Chart updates via PushValue → SetNeedsRedraw → only chart bounds reported.
+func TestCollector_TaskmanagerPattern_ChartInCollapsible(t *testing.T) {
+ cpuChart := newTestWidget(12, 40, 660, 200)
+
+ collapsibleCPU := newTestWidget(0, 0, 700, 350)
+ collapsibleCPU.AddChild(cpuChart)
+ cpuChart.SetParent(collapsibleCPU)
+
+ collapsibleMem := newTestWidget(0, 370, 700, 250)
+
+ vbox := newTestWidget(0, 0, 700, 1200)
+ vbox.AddChild(collapsibleCPU)
+ vbox.AddChild(collapsibleMem)
+ collapsibleCPU.SetParent(vbox)
+ collapsibleMem.SetParent(vbox)
+
+ scrollView := newViewportWidget(700, 800, vbox)
+ vbox.SetParent(scrollView)
+
+ cpuChart.SetNeedsRedraw(true)
+
+ tr := NewTracker()
+ c := NewCollector(tr)
+ c.Collect(scrollView)
+
+ regions := tr.DirtyRegions()
+
+ foundChart := false
+ foundLarge := false
+ for _, r := range regions {
+ w := r.Bounds.Width()
+ h := r.Bounds.Height()
+ if w <= 670 && h <= 210 {
+ foundChart = true
+ }
+ if w > 690 || h > 400 {
+ foundLarge = true
+ }
+ }
+
+ if !foundChart {
+ t.Errorf("chart bounds NOT found; regions=%v", regions)
+ }
+ if foundLarge {
+ t.Errorf("large container bounds found; regions=%v", regions)
+ }
+}
+
+// TestCollector_NoDirtyChildren_ReportSelf verifies that when a widget is
+// dirty but has NO dirty children, it reports its own bounds.
+func TestCollector_NoDirtyChildren_ReportSelf(t *testing.T) {
+ parent := newTestWidget(0, 0, 800, 600)
+
+ child1 := newTestWidget(10, 10, 100, 30)
+ child1.SetParent(parent)
+ parent.AddChild(child1)
+
+ // Only parent dirty, children clean (e.g., theme change).
+ parent.SetNeedsRedraw(true)
+ child1.ClearRedraw()
+
+ tr := NewTracker()
+ c := NewCollector(tr)
+ c.Collect(parent)
+
+ regions := tr.DirtyRegions()
+ if len(regions) == 0 {
+ t.Error("expected parent bounds as dirty region (no dirty children)")
+ }
+}
diff --git a/internal/dirty/region.go b/internal/dirty/region.go
index bb4adf6..ef3ad72 100644
--- a/internal/dirty/region.go
+++ b/internal/dirty/region.go
@@ -18,7 +18,7 @@ import (
// defaultMergeGap is the default pixel gap threshold for merging nearby regions.
// Two regions separated by less than this distance are merged into one to reduce
// draw calls at the cost of slightly more overdraw.
-const defaultMergeGap float32 = 16
+const defaultMergeGap float32 = 0
// maxRegionsBeforeFullRepaint is the maximum number of dirty regions before
// the tracker falls back to a single full-viewport repaint. When many small
diff --git a/internal/render/canvas.go b/internal/render/canvas.go
index f66f23b..aee8ee5 100644
--- a/internal/render/canvas.go
+++ b/internal/render/canvas.go
@@ -410,6 +410,10 @@ func (c *Canvas) TransformOffset() geometry.Point {
return c.currentOffset
}
+// ScreenOriginBase returns the screen-space base offset for this canvas.
+// For the main window canvas this is always (0,0).
+func (c *Canvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+
// ClipDepth returns the current depth of the clip stack.
func (c *Canvas) ClipDepth() int {
return len(c.clipStack)
@@ -654,6 +658,12 @@ func (c *Canvas) ReplayScene(s *scene.Scene) {
c.dc.Pop()
}
+// SetDamageTracking enables or disables damage tracking on the underlying gg.Context.
+// Implements widget.DamageController for retained-mode optimization.
+func (c *Canvas) SetDamageTracking(enabled bool) {
+ c.dc.SetDamageTracking(enabled)
+}
+
// RenderSVG renders full SVG XML within the given bounds with color override.
// Uses gg/svg.Document.RenderToWithColor to draw directly into the gg.Context.
func (c *Canvas) RenderSVG(svgXML []byte, bounds geometry.Rect, color widget.Color) {
@@ -677,6 +687,18 @@ func (c *Canvas) RenderSVG(svgXML []byte, bounds geometry.Rect, color widget.Col
float64(bounds.Width()), float64(bounds.Height()), svgColor)
}
+// SetTextMode sets the text rendering strategy on the underlying gg.Context.
+// Implements widget.TextModeController.
+func (c *Canvas) SetTextMode(mode widget.TextMode) {
+ c.dc.SetTextMode(gg.TextMode(mode))
+}
+
+// TextMode returns the current text rendering strategy.
+// Implements widget.TextModeController.
+func (c *Canvas) TextMode() widget.TextMode {
+ return widget.TextMode(c.dc.TextMode())
+}
+
// toGGLineCap converts widget.LineCap to gg.LineCap.
func toGGLineCap(lc widget.LineCap) gg.LineCap {
switch lc {
diff --git a/internal/render/canvas_test.go b/internal/render/canvas_test.go
index ffa0259..fbaafed 100644
--- a/internal/render/canvas_test.go
+++ b/internal/render/canvas_test.go
@@ -439,6 +439,34 @@ func BenchmarkCanvas_PushPopTransform(b *testing.B) {
}
}
+func TestCanvas_TextModeController(t *testing.T) {
+ canvas := newTestCanvas(100, 100)
+
+ tc, ok := widget.Canvas(canvas).(widget.TextModeController)
+ if !ok {
+ t.Fatal("Canvas should implement TextModeController")
+ }
+
+ if tc.TextMode() != widget.TextModeAuto {
+ t.Errorf("default TextMode = %v, want Auto", tc.TextMode())
+ }
+
+ tc.SetTextMode(widget.TextModeMSDF)
+ if tc.TextMode() != widget.TextModeMSDF {
+ t.Errorf("TextMode = %v, want MSDF", tc.TextMode())
+ }
+
+ tc.SetTextMode(widget.TextModeVector)
+ if tc.TextMode() != widget.TextModeVector {
+ t.Errorf("TextMode = %v, want Vector", tc.TextMode())
+ }
+
+ tc.SetTextMode(widget.TextModeAuto)
+ if tc.TextMode() != widget.TextModeAuto {
+ t.Errorf("TextMode = %v, want Auto after reset", tc.TextMode())
+ }
+}
+
func BenchmarkCanvas_Clear(b *testing.B) {
canvas := newTestCanvas(800, 600)
color := widget.ColorWhite
diff --git a/internal/render/icon_cache.go b/internal/render/icon_cache.go
new file mode 100644
index 0000000..4e63bb6
--- /dev/null
+++ b/internal/render/icon_cache.go
@@ -0,0 +1,317 @@
+package render
+
+import (
+ "container/list"
+ "sync"
+ "sync/atomic"
+ "unsafe"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/gg/svg"
+ "github.com/gogpu/ui/widget"
+)
+
+// Icon cache configuration constants.
+const (
+ // defaultIconCacheMaxEntries is the default maximum number of rasterized
+ // icon images cached in Level 2.
+ defaultIconCacheMaxEntries = 256
+)
+
+// iconImageKey uniquely identifies a rasterized icon image.
+// The cache stores *scene.Image keyed by the SVG data identity (pointer),
+// output dimensions, and fill color.
+type iconImageKey struct {
+ svgPtr uintptr // pointer to SVG data (SliceData for []byte, string data ptr)
+ width int
+ height int
+ color uint32 // packed RGBA (8 bits per channel)
+}
+
+// iconImageEntry is a single entry in the Level 2 rasterized image cache.
+type iconImageEntry struct {
+ key iconImageKey
+ img *scene.Image
+ element *list.Element
+}
+
+// IconCacheStats contains cache statistics for monitoring.
+type IconCacheStats struct {
+ // DocEntries is the number of parsed SVG documents in Level 1.
+ DocEntries int
+ // ImageEntries is the number of rasterized images in Level 2.
+ ImageEntries int
+ // MaxImageEntries is the maximum number of Level 2 entries.
+ MaxImageEntries int
+ // Hits is the number of Level 2 cache hits.
+ Hits uint64
+ // Misses is the number of Level 2 cache misses.
+ Misses uint64
+ // HitRate is the Level 2 hit rate (0.0 to 1.0).
+ HitRate float64
+ // Evictions is the number of Level 2 entries evicted.
+ Evictions uint64
+ // DocHits is the number of Level 1 cache hits.
+ DocHits uint64
+ // DocMisses is the number of Level 1 cache misses.
+ DocMisses uint64
+}
+
+// iconCache provides a 2-level LRU cache for SVG icon rendering.
+//
+// Level 1 caches parsed [svg.Document] by SVG XML pointer, avoiding
+// repeated XML parsing (typically ~0.5ms per parse). Documents are
+// lightweight (a few KB each) and never evicted — the set of distinct
+// SVG icons in a UI is bounded and small.
+//
+// Level 2 caches rasterized [scene.Image] by (svgPtr, width, height, color),
+// avoiding repeated CPU rasterization (typically ~0.15ms per icon). Images
+// are evicted via LRU when the entry count exceeds the configured maximum.
+//
+// The cache is a package-level singleton shared across all SceneCanvas
+// instances so it survives RepaintBoundary re-recording. It is protected
+// by sync.Mutex for thread safety, though in practice all access occurs on
+// the main/UI thread.
+type iconCache struct {
+ mu sync.Mutex
+
+ // Level 1: parsed SVG documents by data pointer.
+ docs map[uintptr]*svg.Document
+ docHits atomic.Uint64
+ docMiss atomic.Uint64
+
+ // Level 2: rasterized scene images by composite key.
+ images map[iconImageKey]*iconImageEntry
+ lru *list.List // front = most recent
+ maxItems int
+
+ // Level 2 statistics (atomic for lock-free reads).
+ hits atomic.Uint64
+ misses atomic.Uint64
+ evictions atomic.Uint64
+}
+
+// globalIconCache is the package-level singleton icon cache.
+// Shared across all SceneCanvas instances, survives boundary re-recording.
+var globalIconCache = newIconCache(defaultIconCacheMaxEntries)
+
+// newIconCache creates an icon cache with the specified maximum Level 2 entries.
+func newIconCache(maxItems int) *iconCache {
+ if maxItems <= 0 {
+ maxItems = defaultIconCacheMaxEntries
+ }
+ return &iconCache{
+ docs: make(map[uintptr]*svg.Document),
+ images: make(map[iconImageKey]*iconImageEntry),
+ lru: list.New(),
+ maxItems: maxItems,
+ }
+}
+
+// getDoc retrieves or parses an SVG document for the given XML data.
+// Level 1 cache: keyed by the data pointer of the []byte slice header.
+// Returns nil if parsing fails.
+func (c *iconCache) getDoc(svgXML []byte) *svg.Document {
+ ptr := svgSlicePtr(svgXML)
+
+ c.mu.Lock()
+ if doc, ok := c.docs[ptr]; ok {
+ c.mu.Unlock()
+ c.docHits.Add(1)
+ return doc
+ }
+ c.mu.Unlock()
+
+ // Parse outside the lock — svg.Parse is pure computation.
+ doc, err := svg.Parse(svgXML)
+ if err != nil {
+ c.docMiss.Add(1)
+ return nil
+ }
+
+ c.mu.Lock()
+ // Double-check: another goroutine may have inserted while we parsed.
+ if existing, ok := c.docs[ptr]; ok {
+ c.mu.Unlock()
+ c.docHits.Add(1)
+ return existing
+ }
+ c.docs[ptr] = doc
+ c.mu.Unlock()
+
+ c.docMiss.Add(1)
+ return doc
+}
+
+// getImage retrieves a cached rasterized image by its composite key.
+// On cache hit, the entry is promoted to the front of the LRU list.
+// Returns nil on cache miss.
+func (c *iconCache) getImage(key iconImageKey) *scene.Image {
+ c.mu.Lock()
+ entry, ok := c.images[key]
+ if !ok {
+ c.mu.Unlock()
+ c.misses.Add(1)
+ return nil
+ }
+ c.lru.MoveToFront(entry.element)
+ img := entry.img
+ c.mu.Unlock()
+
+ c.hits.Add(1)
+ return img
+}
+
+// putImage stores a rasterized image in the Level 2 cache.
+// Evicts the least recently used entry if the cache is full.
+func (c *iconCache) putImage(key iconImageKey, img *scene.Image) {
+ if img == nil {
+ return
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ // Replace existing entry with the same key.
+ if existing, ok := c.images[key]; ok {
+ c.lru.Remove(existing.element)
+ delete(c.images, key)
+ }
+
+ // Evict LRU entries until under capacity.
+ for c.lru.Len() >= c.maxItems {
+ back := c.lru.Back()
+ if back == nil {
+ break
+ }
+ victim := back.Value.(*iconImageEntry)
+ c.lru.Remove(back)
+ delete(c.images, victim.key)
+ c.evictions.Add(1)
+ }
+
+ entry := &iconImageEntry{
+ key: key,
+ img: img,
+ }
+ entry.element = c.lru.PushFront(entry)
+ c.images[key] = entry
+}
+
+// invalidateImages clears all Level 2 rasterized images.
+// Level 1 parsed documents are preserved because they are color-independent.
+// Call this on theme change — colors change, parsed structure does not.
+func (c *iconCache) invalidateImages() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ evicted := uint64(len(c.images))
+ c.images = make(map[iconImageKey]*iconImageEntry)
+ c.lru.Init()
+
+ if evicted > 0 {
+ c.evictions.Add(evicted)
+ }
+}
+
+// invalidateAll clears both Level 1 and Level 2 caches entirely.
+func (c *iconCache) invalidateAll() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ evicted := uint64(len(c.images))
+ c.docs = make(map[uintptr]*svg.Document)
+ c.images = make(map[iconImageKey]*iconImageEntry)
+ c.lru.Init()
+
+ if evicted > 0 {
+ c.evictions.Add(evicted)
+ }
+}
+
+// stats returns current cache statistics.
+func (c *iconCache) stats() IconCacheStats {
+ c.mu.Lock()
+ docEntries := len(c.docs)
+ imageEntries := len(c.images)
+ maxItems := c.maxItems
+ c.mu.Unlock()
+
+ hits := c.hits.Load()
+ misses := c.misses.Load()
+
+ var hitRate float64
+ total := hits + misses
+ if total > 0 {
+ hitRate = float64(hits) / float64(total)
+ }
+
+ return IconCacheStats{
+ DocEntries: docEntries,
+ ImageEntries: imageEntries,
+ MaxImageEntries: maxItems,
+ Hits: hits,
+ Misses: misses,
+ HitRate: hitRate,
+ Evictions: c.evictions.Load(),
+ DocHits: c.docHits.Load(),
+ DocMisses: c.docMiss.Load(),
+ }
+}
+
+// resetStats resets all hit/miss/eviction counters to zero.
+func (c *iconCache) resetStats() {
+ c.hits.Store(0)
+ c.misses.Store(0)
+ c.evictions.Store(0)
+ c.docHits.Store(0)
+ c.docMiss.Store(0)
+}
+
+// --- Public API for external access ---
+
+// InvalidateIconImages clears all cached rasterized SVG icon images.
+// Parsed SVG documents (Level 1) are preserved because they are
+// color-independent. Call this when the theme changes.
+func InvalidateIconImages() {
+ globalIconCache.invalidateImages()
+}
+
+// InvalidateIconCache clears the entire icon cache (both levels).
+func InvalidateIconCache() {
+ globalIconCache.invalidateAll()
+}
+
+// IconCacheStatsSnapshot returns current icon cache statistics.
+func IconCacheStatsSnapshot() IconCacheStats {
+ return globalIconCache.stats()
+}
+
+// --- Helper functions ---
+
+// packColor packs a widget.Color into a uint32 (8 bits per RGBA channel).
+// This produces a deterministic key from float32 color values.
+func packColor(color widget.Color) uint32 {
+ r, g, b, a := color.RGBA8()
+ return uint32(r)<<24 | uint32(g)<<16 | uint32(b)<<8 | uint32(a)
+}
+
+// svgSlicePtr extracts the data pointer from a []byte slice header.
+// Icons are typically go:embed constants, so the pointer is stable for
+// the process lifetime. This is the identity key for Level 1 caching.
+func svgSlicePtr(data []byte) uintptr {
+ if len(data) == 0 {
+ return 0
+ }
+ return uintptr(unsafe.Pointer(unsafe.SliceData(data)))
+}
+
+// svgStringPtr extracts the data pointer from a string header.
+// SVG path data strings are typically string constants, so the pointer
+// is stable for the process lifetime.
+func svgStringPtr(s string) uintptr {
+ if s == "" {
+ return 0
+ }
+ return uintptr(unsafe.Pointer(unsafe.StringData(s)))
+}
diff --git a/internal/render/icon_cache_test.go b/internal/render/icon_cache_test.go
new file mode 100644
index 0000000..57c0ea2
--- /dev/null
+++ b/internal/render/icon_cache_test.go
@@ -0,0 +1,632 @@
+package render
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/widget"
+)
+
+// --- Construction Tests ---
+
+func TestNewIconCache_DefaultSize(t *testing.T) {
+ c := newIconCache(0)
+ if c.maxItems != defaultIconCacheMaxEntries {
+ t.Errorf("maxItems = %d, want %d", c.maxItems, defaultIconCacheMaxEntries)
+ }
+}
+
+func TestNewIconCache_CustomSize(t *testing.T) {
+ c := newIconCache(128)
+ if c.maxItems != 128 {
+ t.Errorf("maxItems = %d, want 128", c.maxItems)
+ }
+}
+
+func TestNewIconCache_NegativeUsesDefault(t *testing.T) {
+ c := newIconCache(-1)
+ if c.maxItems != defaultIconCacheMaxEntries {
+ t.Errorf("maxItems = %d, want %d", c.maxItems, defaultIconCacheMaxEntries)
+ }
+}
+
+// --- Level 1: Document Cache ---
+
+// minimalSVG is a valid minimal SVG document for testing.
+var minimalSVG = []byte(``)
+
+// anotherSVG is a distinct SVG document for testing separate cache entries.
+var anotherSVG = []byte(``)
+
+func TestIconCache_GetDoc_ParsesAndCaches(t *testing.T) {
+ c := newIconCache(16)
+
+ doc := c.getDoc(minimalSVG)
+ if doc == nil {
+ t.Fatal("expected non-nil document from valid SVG")
+ }
+
+ stats := c.stats()
+ if stats.DocEntries != 1 {
+ t.Errorf("DocEntries = %d, want 1", stats.DocEntries)
+ }
+ if stats.DocMisses != 1 {
+ t.Errorf("DocMisses = %d, want 1 (first parse)", stats.DocMisses)
+ }
+
+ // Second call should hit cache.
+ doc2 := c.getDoc(minimalSVG)
+ if doc2 != doc {
+ t.Error("expected same document pointer on cache hit")
+ }
+
+ stats = c.stats()
+ if stats.DocHits != 1 {
+ t.Errorf("DocHits = %d, want 1", stats.DocHits)
+ }
+}
+
+func TestIconCache_GetDoc_DifferentSVGs(t *testing.T) {
+ c := newIconCache(16)
+
+ doc1 := c.getDoc(minimalSVG)
+ doc2 := c.getDoc(anotherSVG)
+
+ if doc1 == nil || doc2 == nil {
+ t.Fatal("expected non-nil documents")
+ }
+ if doc1 == doc2 {
+ t.Error("different SVGs should produce different documents")
+ }
+
+ stats := c.stats()
+ if stats.DocEntries != 2 {
+ t.Errorf("DocEntries = %d, want 2", stats.DocEntries)
+ }
+}
+
+func TestIconCache_GetDoc_InvalidSVG(t *testing.T) {
+ c := newIconCache(16)
+
+ doc := c.getDoc([]byte("not valid svg"))
+ if doc != nil {
+ t.Error("expected nil for invalid SVG")
+ }
+
+ stats := c.stats()
+ if stats.DocEntries != 0 {
+ t.Errorf("DocEntries = %d, want 0 (invalid SVG should not be cached)", stats.DocEntries)
+ }
+}
+
+func TestIconCache_GetDoc_Empty(t *testing.T) {
+ c := newIconCache(16)
+
+ doc := c.getDoc(nil)
+ if doc != nil {
+ t.Error("expected nil for nil input")
+ }
+
+ doc = c.getDoc([]byte{})
+ if doc != nil {
+ t.Error("expected nil for empty input")
+ }
+}
+
+// --- Level 2: Image Cache ---
+
+func makeSceneImage(w, h int) *scene.Image {
+ img := scene.NewImage(w, h)
+ img.Data = make([]byte, w*h*4)
+ return img
+}
+
+func TestIconCache_PutGetImage_RoundTrip(t *testing.T) {
+ c := newIconCache(16)
+ img := makeSceneImage(24, 24)
+ key := iconImageKey{svgPtr: 0x1000, width: 24, height: 24, color: 0xFF0000FF}
+
+ c.putImage(key, img)
+
+ got := c.getImage(key)
+ if got != img {
+ t.Error("expected same image pointer on cache hit")
+ }
+
+ stats := c.stats()
+ if stats.ImageEntries != 1 {
+ t.Errorf("ImageEntries = %d, want 1", stats.ImageEntries)
+ }
+ if stats.Hits != 1 {
+ t.Errorf("Hits = %d, want 1", stats.Hits)
+ }
+}
+
+func TestIconCache_GetImage_Miss(t *testing.T) {
+ c := newIconCache(16)
+ key := iconImageKey{svgPtr: 0x9999, width: 24, height: 24, color: 0xFF0000FF}
+
+ got := c.getImage(key)
+ if got != nil {
+ t.Error("expected nil on cache miss")
+ }
+
+ stats := c.stats()
+ if stats.Misses != 1 {
+ t.Errorf("Misses = %d, want 1", stats.Misses)
+ }
+}
+
+func TestIconCache_PutImage_NilIgnored(t *testing.T) {
+ c := newIconCache(16)
+ key := iconImageKey{svgPtr: 0x1000, width: 24, height: 24, color: 0xFF0000FF}
+
+ c.putImage(key, nil)
+ if c.stats().ImageEntries != 0 {
+ t.Error("nil image should not be stored")
+ }
+}
+
+func TestIconCache_PutImage_ReplacesExisting(t *testing.T) {
+ c := newIconCache(16)
+ key := iconImageKey{svgPtr: 0x1000, width: 24, height: 24, color: 0xFF0000FF}
+
+ img1 := makeSceneImage(24, 24)
+ img2 := makeSceneImage(24, 24)
+
+ c.putImage(key, img1)
+ c.putImage(key, img2)
+
+ if c.stats().ImageEntries != 1 {
+ t.Errorf("ImageEntries = %d, want 1 (replaced)", c.stats().ImageEntries)
+ }
+
+ got := c.getImage(key)
+ if got != img2 {
+ t.Error("expected replaced image")
+ }
+}
+
+func TestIconCache_DifferentColors_DifferentEntries(t *testing.T) {
+ c := newIconCache(16)
+ img1 := makeSceneImage(24, 24)
+ img2 := makeSceneImage(24, 24)
+
+ key1 := iconImageKey{svgPtr: 0x1000, width: 24, height: 24, color: 0xFF0000FF}
+ key2 := iconImageKey{svgPtr: 0x1000, width: 24, height: 24, color: 0x00FF00FF}
+
+ c.putImage(key1, img1)
+ c.putImage(key2, img2)
+
+ if c.stats().ImageEntries != 2 {
+ t.Errorf("ImageEntries = %d, want 2", c.stats().ImageEntries)
+ }
+
+ if c.getImage(key1) != img1 {
+ t.Error("key1 should return img1")
+ }
+ if c.getImage(key2) != img2 {
+ t.Error("key2 should return img2")
+ }
+}
+
+func TestIconCache_DifferentSizes_DifferentEntries(t *testing.T) {
+ c := newIconCache(16)
+ img1 := makeSceneImage(16, 16)
+ img2 := makeSceneImage(24, 24)
+
+ key1 := iconImageKey{svgPtr: 0x1000, width: 16, height: 16, color: 0xFF0000FF}
+ key2 := iconImageKey{svgPtr: 0x1000, width: 24, height: 24, color: 0xFF0000FF}
+
+ c.putImage(key1, img1)
+ c.putImage(key2, img2)
+
+ if c.stats().ImageEntries != 2 {
+ t.Errorf("ImageEntries = %d, want 2", c.stats().ImageEntries)
+ }
+}
+
+// --- LRU Eviction Tests ---
+
+func TestIconCache_LRU_Eviction(t *testing.T) {
+ c := newIconCache(3) // max 3 entries
+
+ for i := range 5 {
+ key := iconImageKey{svgPtr: uintptr(i), width: 24, height: 24, color: 0xFF0000FF}
+ c.putImage(key, makeSceneImage(24, 24))
+ }
+
+ stats := c.stats()
+ if stats.ImageEntries != 3 {
+ t.Errorf("ImageEntries = %d, want 3 (capacity)", stats.ImageEntries)
+ }
+ if stats.Evictions < 2 {
+ t.Errorf("Evictions = %d, want >= 2", stats.Evictions)
+ }
+
+ // First two should be evicted.
+ key0 := iconImageKey{svgPtr: 0, width: 24, height: 24, color: 0xFF0000FF}
+ if c.getImage(key0) != nil {
+ t.Error("entry 0 should have been evicted")
+ }
+
+ key1 := iconImageKey{svgPtr: 1, width: 24, height: 24, color: 0xFF0000FF}
+ if c.getImage(key1) != nil {
+ t.Error("entry 1 should have been evicted")
+ }
+
+ // Last three should still be present.
+ for i := 2; i < 5; i++ {
+ key := iconImageKey{svgPtr: uintptr(i), width: 24, height: 24, color: 0xFF0000FF}
+ if c.getImage(key) == nil {
+ t.Errorf("entry %d should still be in cache", i)
+ }
+ }
+}
+
+func TestIconCache_LRU_AccessPromotes(t *testing.T) {
+ c := newIconCache(3)
+
+ key1 := iconImageKey{svgPtr: 1, width: 24, height: 24, color: 0xFF0000FF}
+ key2 := iconImageKey{svgPtr: 2, width: 24, height: 24, color: 0xFF0000FF}
+ key3 := iconImageKey{svgPtr: 3, width: 24, height: 24, color: 0xFF0000FF}
+ key4 := iconImageKey{svgPtr: 4, width: 24, height: 24, color: 0xFF0000FF}
+
+ c.putImage(key1, makeSceneImage(24, 24)) // LRU order: [1]
+ c.putImage(key2, makeSceneImage(24, 24)) // LRU order: [2, 1]
+ c.putImage(key3, makeSceneImage(24, 24)) // LRU order: [3, 2, 1]
+
+ // Access key1 to promote it to front: [1, 3, 2]
+ _ = c.getImage(key1)
+
+ // Insert key4 — should evict key2 (LRU), not key1.
+ c.putImage(key4, makeSceneImage(24, 24))
+
+ if c.getImage(key2) != nil {
+ t.Error("key2 should have been evicted (LRU)")
+ }
+ if c.getImage(key1) == nil {
+ t.Error("key1 should still be present (recently accessed)")
+ }
+ if c.getImage(key4) == nil {
+ t.Error("key4 should be present (just inserted)")
+ }
+}
+
+// --- Invalidation Tests ---
+
+func TestIconCache_InvalidateImages(t *testing.T) {
+ c := newIconCache(16)
+
+ // Populate both levels.
+ _ = c.getDoc(minimalSVG)
+ key := iconImageKey{svgPtr: svgSlicePtr(minimalSVG), width: 24, height: 24, color: 0xFF0000FF}
+ c.putImage(key, makeSceneImage(24, 24))
+
+ c.invalidateImages()
+
+ stats := c.stats()
+ if stats.ImageEntries != 0 {
+ t.Errorf("ImageEntries = %d, want 0 after invalidateImages", stats.ImageEntries)
+ }
+ // Level 1 should be preserved.
+ if stats.DocEntries != 1 {
+ t.Errorf("DocEntries = %d, want 1 (should be preserved)", stats.DocEntries)
+ }
+}
+
+func TestIconCache_InvalidateAll(t *testing.T) {
+ c := newIconCache(16)
+
+ _ = c.getDoc(minimalSVG)
+ key := iconImageKey{svgPtr: svgSlicePtr(minimalSVG), width: 24, height: 24, color: 0xFF0000FF}
+ c.putImage(key, makeSceneImage(24, 24))
+
+ c.invalidateAll()
+
+ stats := c.stats()
+ if stats.ImageEntries != 0 {
+ t.Errorf("ImageEntries = %d, want 0", stats.ImageEntries)
+ }
+ if stats.DocEntries != 0 {
+ t.Errorf("DocEntries = %d, want 0", stats.DocEntries)
+ }
+}
+
+func TestIconCache_InvalidateImages_EvictionCount(t *testing.T) {
+ c := newIconCache(16)
+
+ for i := range 5 {
+ key := iconImageKey{svgPtr: uintptr(i), width: 24, height: 24, color: 0xFF0000FF}
+ c.putImage(key, makeSceneImage(24, 24))
+ }
+
+ c.invalidateImages()
+
+ stats := c.stats()
+ if stats.Evictions != 5 {
+ t.Errorf("Evictions = %d, want 5", stats.Evictions)
+ }
+}
+
+// --- packColor Tests ---
+
+func TestPackColor(t *testing.T) {
+ tests := []struct {
+ name string
+ color widget.Color
+ want uint32
+ }{
+ {"red", widget.Color{R: 1, G: 0, B: 0, A: 1}, 0xFF0000FF},
+ {"green", widget.Color{R: 0, G: 1, B: 0, A: 1}, 0x00FF00FF},
+ {"blue", widget.Color{R: 0, G: 0, B: 1, A: 1}, 0x0000FFFF},
+ {"white", widget.Color{R: 1, G: 1, B: 1, A: 1}, 0xFFFFFFFF},
+ {"black_opaque", widget.Color{R: 0, G: 0, B: 0, A: 1}, 0x000000FF},
+ {"transparent", widget.Color{R: 0, G: 0, B: 0, A: 0}, 0x00000000},
+ {"half_alpha", widget.Color{R: 1, G: 1, B: 1, A: 0.5}, 0xFFFFFF7F},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := packColor(tt.color)
+ if got != tt.want {
+ t.Errorf("packColor(%v) = 0x%08X, want 0x%08X", tt.color, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPackColor_Deterministic(t *testing.T) {
+ c := widget.Color{R: 0.5, G: 0.25, B: 0.75, A: 1.0}
+ p1 := packColor(c)
+ p2 := packColor(c)
+ if p1 != p2 {
+ t.Errorf("packColor not deterministic: 0x%08X != 0x%08X", p1, p2)
+ }
+}
+
+// --- Pointer helper tests ---
+
+func TestSvgSlicePtr_StableForSameSlice(t *testing.T) {
+ data := []byte("test svg data")
+ p1 := svgSlicePtr(data)
+ p2 := svgSlicePtr(data)
+ if p1 != p2 {
+ t.Errorf("same slice should produce same pointer: %v != %v", p1, p2)
+ }
+ if p1 == 0 {
+ t.Error("expected non-zero pointer for non-empty slice")
+ }
+}
+
+func TestSvgSlicePtr_NilAndEmpty(t *testing.T) {
+ if svgSlicePtr(nil) != 0 {
+ t.Error("nil slice should return 0")
+ }
+ if svgSlicePtr([]byte{}) != 0 {
+ t.Error("empty slice should return 0")
+ }
+}
+
+func TestSvgStringPtr_StableForSameString(t *testing.T) {
+ s := "M12 2L2 22h20z"
+ p1 := svgStringPtr(s)
+ p2 := svgStringPtr(s)
+ if p1 != p2 {
+ t.Errorf("same string should produce same pointer: %v != %v", p1, p2)
+ }
+ if p1 == 0 {
+ t.Error("expected non-zero pointer for non-empty string")
+ }
+}
+
+func TestSvgStringPtr_Empty(t *testing.T) {
+ if svgStringPtr("") != 0 {
+ t.Error("empty string should return 0")
+ }
+}
+
+// --- Statistics Tests ---
+
+func TestIconCache_Stats(t *testing.T) {
+ c := newIconCache(16)
+
+ _ = c.getDoc(minimalSVG)
+ _ = c.getDoc(minimalSVG) // doc hit
+
+ key := iconImageKey{svgPtr: 1, width: 24, height: 24, color: 0xFF0000FF}
+ c.putImage(key, makeSceneImage(24, 24))
+ _ = c.getImage(key) // hit
+ _ = c.getImage(iconImageKey{svgPtr: 9, width: 24, height: 24, color: 0xFF00FF}) // miss
+
+ stats := c.stats()
+ if stats.DocEntries != 1 {
+ t.Errorf("DocEntries = %d, want 1", stats.DocEntries)
+ }
+ if stats.DocHits != 1 {
+ t.Errorf("DocHits = %d, want 1", stats.DocHits)
+ }
+ if stats.DocMisses != 1 {
+ t.Errorf("DocMisses = %d, want 1", stats.DocMisses)
+ }
+ if stats.ImageEntries != 1 {
+ t.Errorf("ImageEntries = %d, want 1", stats.ImageEntries)
+ }
+ if stats.Hits != 1 {
+ t.Errorf("Hits = %d, want 1", stats.Hits)
+ }
+ if stats.Misses != 1 {
+ t.Errorf("Misses = %d, want 1", stats.Misses)
+ }
+ if stats.HitRate < 0.49 || stats.HitRate > 0.51 {
+ t.Errorf("HitRate = %f, want ~0.5", stats.HitRate)
+ }
+ if stats.MaxImageEntries != 16 {
+ t.Errorf("MaxImageEntries = %d, want 16", stats.MaxImageEntries)
+ }
+}
+
+func TestIconCache_Stats_Empty(t *testing.T) {
+ c := newIconCache(16)
+ stats := c.stats()
+
+ if stats.HitRate != 0 {
+ t.Errorf("HitRate = %f, want 0", stats.HitRate)
+ }
+ if stats.DocEntries != 0 || stats.ImageEntries != 0 {
+ t.Error("empty cache should have 0 entries")
+ }
+}
+
+func TestIconCache_ResetStats(t *testing.T) {
+ c := newIconCache(16)
+
+ key := iconImageKey{svgPtr: 1, width: 24, height: 24, color: 0xFF0000FF}
+ c.putImage(key, makeSceneImage(24, 24))
+ _ = c.getImage(key) // hit
+ _ = c.getImage(iconImageKey{svgPtr: 9, width: 24, height: 24, color: 0xFF00FF})
+
+ c.resetStats()
+
+ stats := c.stats()
+ if stats.Hits != 0 || stats.Misses != 0 || stats.Evictions != 0 {
+ t.Errorf("after resetStats: Hits=%d Misses=%d Evictions=%d, want all 0",
+ stats.Hits, stats.Misses, stats.Evictions)
+ }
+ if stats.DocHits != 0 || stats.DocMisses != 0 {
+ t.Errorf("after resetStats: DocHits=%d DocMisses=%d, want all 0",
+ stats.DocHits, stats.DocMisses)
+ }
+ // Entries should still be present.
+ if stats.ImageEntries != 1 {
+ t.Errorf("ImageEntries = %d, want 1 (resetStats should not clear entries)", stats.ImageEntries)
+ }
+}
+
+// --- Thread Safety Tests ---
+
+func TestIconCache_ConcurrentPutGet(t *testing.T) {
+ c := newIconCache(64)
+ const numGoroutines = 16
+ const opsPerGoroutine = 50
+
+ var wg sync.WaitGroup
+ wg.Add(numGoroutines)
+
+ for g := range numGoroutines {
+ go func(id int) {
+ defer wg.Done()
+ for i := range opsPerGoroutine {
+ key := iconImageKey{
+ svgPtr: uintptr(id*opsPerGoroutine + i),
+ width: 24,
+ height: 24,
+ color: uint32(id),
+ }
+ c.putImage(key, makeSceneImage(24, 24))
+ _ = c.getImage(key)
+ }
+ }(g)
+ }
+
+ wg.Wait()
+
+ stats := c.stats()
+ if stats.ImageEntries > 64 {
+ t.Errorf("ImageEntries = %d, should be <= maxItems (64)", stats.ImageEntries)
+ }
+}
+
+func TestIconCache_ConcurrentDocAccess(t *testing.T) {
+ c := newIconCache(16)
+ const numGoroutines = 8
+
+ var wg sync.WaitGroup
+ wg.Add(numGoroutines)
+
+ for range numGoroutines {
+ go func() {
+ defer wg.Done()
+ doc := c.getDoc(minimalSVG)
+ if doc == nil {
+ t.Error("expected non-nil document")
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ stats := c.stats()
+ if stats.DocEntries != 1 {
+ t.Errorf("DocEntries = %d, want 1 (same SVG)", stats.DocEntries)
+ }
+}
+
+func TestIconCache_ConcurrentPutAndInvalidate(t *testing.T) {
+ c := newIconCache(32)
+ const numGoroutines = 8
+ const opsPerGoroutine = 50
+
+ var wg sync.WaitGroup
+ wg.Add(numGoroutines * 2)
+
+ for g := range numGoroutines {
+ go func(id int) {
+ defer wg.Done()
+ for i := range opsPerGoroutine {
+ key := iconImageKey{
+ svgPtr: uintptr(id*opsPerGoroutine + i),
+ width: 24,
+ height: 24,
+ color: 0xFF0000FF,
+ }
+ c.putImage(key, makeSceneImage(24, 24))
+ }
+ }(g)
+ go func() {
+ defer wg.Done()
+ for range opsPerGoroutine {
+ c.invalidateImages()
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ // Must not panic. Entries may or may not be present.
+ stats := c.stats()
+ if stats.ImageEntries < 0 {
+ t.Errorf("ImageEntries = %d, should not be negative", stats.ImageEntries)
+ }
+}
+
+// --- Public API Tests ---
+
+func TestInvalidateIconImages(t *testing.T) {
+ // Ensure the global function does not panic.
+ InvalidateIconImages()
+}
+
+func TestInvalidateIconCache(t *testing.T) {
+ // Ensure the global function does not panic.
+ InvalidateIconCache()
+}
+
+func TestIconCacheStatsSnapshot(t *testing.T) {
+ stats := IconCacheStatsSnapshot()
+ if stats.MaxImageEntries != defaultIconCacheMaxEntries {
+ t.Errorf("MaxImageEntries = %d, want %d", stats.MaxImageEntries, defaultIconCacheMaxEntries)
+ }
+}
+
+// --- Global cache singleton test ---
+
+func TestGlobalIconCache_Exists(t *testing.T) {
+ if globalIconCache == nil {
+ t.Fatal("globalIconCache should not be nil")
+ }
+ if globalIconCache.maxItems != defaultIconCacheMaxEntries {
+ t.Errorf("maxItems = %d, want %d", globalIconCache.maxItems, defaultIconCacheMaxEntries)
+ }
+}
diff --git a/internal/render/scene_canvas.go b/internal/render/scene_canvas.go
index c5c0dad..81968e4 100644
--- a/internal/render/scene_canvas.go
+++ b/internal/render/scene_canvas.go
@@ -2,6 +2,7 @@ package render
import (
"image"
+ stdcolor "image/color"
"image/draw"
"math"
@@ -37,6 +38,19 @@ type SceneCanvas struct {
transformStack []geometry.Point
// Current cumulative transform offset.
currentOffset geometry.Point
+
+ // screenOriginBase is the screen-space position of the RepaintBoundary
+ // that owns this SceneCanvas. Set before recording child drawing so
+ // StampScreenOrigin produces correct screen-space ScreenOrigin values.
+ screenOriginBase geometry.Point
+
+ // deviceScale is the display scale factor (DPI scaling). SVG icons are
+ // rasterized at ceil(logicalSize * deviceScale) physical pixels, then
+ // drawn with an inverse-scale affine transform so they appear at the
+ // correct logical size but with crisp, HiDPI-quality rendering.
+ // Follows the Qt6/Chromium/IntelliJ pattern (ADR-026).
+ // A value <= 0 is treated as 1.0.
+ deviceScale float32
}
// NewSceneCanvas creates a new SceneCanvas that records drawing commands
@@ -66,6 +80,13 @@ func (c *SceneCanvas) Scene() *scene.Scene {
return c.sc
}
+// IsBoundaryRecording returns true. SceneCanvas records into a boundary's
+// scene.Scene. DrawChild uses this to skip child boundaries — they have
+// their own PictureLayers in the compositor (Flutter paintChild pattern).
+func (c *SceneCanvas) IsBoundaryRecording() bool {
+ return true
+}
+
// --- widget.Canvas interface ---
// Clear fills the entire canvas with the given color.
@@ -446,6 +467,29 @@ func (c *SceneCanvas) TransformOffset() geometry.Point {
return c.currentOffset
}
+// ScreenOriginBase returns the screen-space base offset for this SceneCanvas.
+// For RepaintBoundary recording, this is the boundary widget's screen position
+// so that StampScreenOrigin computes correct screen-space coordinates even
+// after PushTransform(-bounds.Min) shifts to local coordinates.
+func (c *SceneCanvas) ScreenOriginBase() geometry.Point { return c.screenOriginBase }
+
+// SetScreenOriginBase sets the screen-space base offset for this SceneCanvas.
+func (c *SceneCanvas) SetScreenOriginBase(p geometry.Point) { c.screenOriginBase = p }
+
+// DeviceScale returns the display scale factor used for SVG rasterization.
+// Returns 1.0 if no scale has been set.
+func (c *SceneCanvas) DeviceScale() float32 {
+ if c.deviceScale <= 0 {
+ return 1
+ }
+ return c.deviceScale
+}
+
+// SetDeviceScale sets the display scale factor for HiDPI-aware SVG icon
+// rasterization (ADR-026). Icons are rasterized at physical pixel size
+// (ceil(logical * scale)) and drawn with an inverse-scale transform.
+func (c *SceneCanvas) SetDeviceScale(scale float32) { c.deviceScale = scale }
+
// ClipBounds returns the current clip rectangle.
func (c *SceneCanvas) ClipBounds() geometry.Rect {
return c.currentClip
@@ -468,6 +512,24 @@ func (c *SceneCanvas) ReplayScene(s *scene.Scene) {
// --- Internal helpers ---
+// svgDrawTransform builds the affine transform for drawing a rasterized SVG
+// icon that was rendered at physical pixel size. When scale == 1, this is a
+// pure translation. When scale > 1, the image is drawn at 1/scale size so
+// the oversized raster maps back to the correct logical pixel area.
+//
+// The transform is: translate(tx, ty) * scale(1/s, 1/s).
+// Matrix form:
+//
+// | 1/s 0 tx |
+// | 0 1/s ty |
+func svgDrawTransform(tx, ty, scale float32) scene.Affine {
+ if scale <= 1 {
+ return scene.TranslateAffine(tx, ty)
+ }
+ inv := 1.0 / scale
+ return scene.NewAffine(inv, 0, tx, 0, inv, ty)
+}
+
// applyTransform applies the current transform offset to a rectangle
// and snaps to pixel grid.
func (c *SceneCanvas) applyTransform(r geometry.Rect) geometry.Rect {
@@ -506,6 +568,15 @@ func imageToRGBA(img image.Image) *image.RGBA {
}
// FillSVGPath fills an SVG path within the given bounds using a temporary gg.Context.
+//
+// When deviceScale > 1, the icon is rasterized at physical pixel size
+// (ceil(logical * scale)) and drawn with an inverse-scale affine transform
+// so it appears at the correct logical size with crisp HiDPI rendering.
+// This follows the Qt6/Chromium/IntelliJ pattern (ADR-026).
+//
+// Results are cached in the global icon cache: the rasterized scene.Image is
+// keyed by (svgData pointer, width, height, color). Cache hits skip parsing
+// and rasterization entirely — only a map lookup + scene.DrawImage.
func (c *SceneCanvas) FillSVGPath(svgData string, viewBox float32, bounds geometry.Rect, color widget.Color) {
if svgData == "" || viewBox <= 0 {
return
@@ -516,34 +587,56 @@ func (c *SceneCanvas) FillSVGPath(svgData string, viewBox float32, bounds geomet
return
}
- w := int(math.Ceil(float64(bounds.Width())))
- h := int(math.Ceil(float64(bounds.Height())))
- if w <= 0 || h <= 0 {
+ dpiScale := c.DeviceScale()
+
+ // Physical pixel dimensions for rasterization.
+ physW := int(math.Ceil(float64(bounds.Width()) * float64(dpiScale)))
+ physH := int(math.Ceil(float64(bounds.Height()) * float64(dpiScale)))
+ if physW <= 0 || physH <= 0 {
return
}
+ // Icon cache lookup (Level 2: rasterized image).
+ // Key uses physical dimensions — different scales produce different entries.
+ key := iconImageKey{
+ svgPtr: svgStringPtr(svgData),
+ width: physW,
+ height: physH,
+ color: packColor(color),
+ }
+ if cached := globalIconCache.getImage(key); cached != nil {
+ c.sc.DrawImage(cached, svgDrawTransform(bounds.Min.X, bounds.Min.Y, dpiScale))
+ return
+ }
+
+ // Cache miss: parse + rasterize at physical resolution.
path, err := gg.ParseSVGPath(svgData)
if err != nil {
return
}
- dc := gg.NewContext(w, h)
- scale := float64(bounds.Width()) / float64(viewBox)
- scaleY := float64(bounds.Height()) / float64(viewBox)
- if scaleY < scale {
- scale = scaleY
+ dc := gg.NewContext(physW, physH)
+ dc.SetRasterizerMode(gg.RasterizerAnalytic) // CPU-only: bypass GPU queueing
+ // Scale SVG viewBox to physical pixel dimensions.
+ svgScale := float64(physW) / float64(viewBox)
+ svgScaleY := float64(physH) / float64(viewBox)
+ if svgScaleY < svgScale {
+ svgScale = svgScaleY
}
- dc.Scale(scale, scale)
+ dc.Scale(svgScale, svgScale)
dc.SetRGBA(float64(color.R), float64(color.G), float64(color.B), float64(color.A))
dc.SetFillRule(gg.FillRuleEvenOdd)
dc.FillPath(path)
img := dc.Image()
rgba := imageToRGBA(img)
- scImg := scene.NewImage(w, h)
+ scImg := scene.NewImage(physW, physH)
scImg.Data = rgba.Pix
- c.sc.DrawImage(scImg, scene.TranslateAffine(bounds.Min.X, bounds.Min.Y))
+ c.sc.DrawImage(scImg, svgDrawTransform(bounds.Min.X, bounds.Min.Y, dpiScale))
_ = dc.Close()
+
+ // Store in cache for next frame.
+ globalIconCache.putImage(key, scImg)
}
// toSceneLineCap converts widget.LineCap to scene.LineCap.
@@ -558,8 +651,81 @@ func toSceneLineCap(lc widget.LineCap) scene.LineCap {
}
}
+// SetTextMode is a no-op on SceneCanvas. Scene text uses TagText which
+// handles mode selection at replay time via GPUSceneRenderer.
+func (c *SceneCanvas) SetTextMode(_ widget.TextMode) {}
+
+// TextMode always returns TextModeAuto on SceneCanvas.
+func (c *SceneCanvas) TextMode() widget.TextMode { return widget.TextModeAuto }
+
+// RenderSVG rasterizes full SVG XML to bitmap and encodes as scene image.
+//
+// When deviceScale > 1, the SVG is rasterized at physical pixel size
+// (ceil(logical * scale)) and drawn with an inverse-scale affine transform
+// so it appears at the correct logical size with crisp HiDPI rendering.
+// This follows the Qt6/Chromium/IntelliJ pattern (ADR-026).
+//
+// Uses the global icon cache for both parsing (Level 1: svg.Document by
+// data pointer) and rasterization (Level 2: scene.Image by pointer+size+color).
+// On cache hit, the entire method reduces to a map lookup + scene.DrawImage.
+func (c *SceneCanvas) RenderSVG(svgXML []byte, bounds geometry.Rect, color widget.Color) {
+ if len(svgXML) == 0 {
+ return
+ }
+ bounds = c.applyTransform(bounds)
+
+ dpiScale := c.DeviceScale()
+
+ // Physical pixel dimensions for rasterization.
+ physW := int(math.Ceil(float64(bounds.Width()) * float64(dpiScale)))
+ physH := int(math.Ceil(float64(bounds.Height()) * float64(dpiScale)))
+ if physW <= 0 || physH <= 0 {
+ return
+ }
+
+ // Icon cache lookup (Level 2: rasterized image).
+ // Key uses physical dimensions — different scales produce different entries.
+ key := iconImageKey{
+ svgPtr: svgSlicePtr(svgXML),
+ width: physW,
+ height: physH,
+ color: packColor(color),
+ }
+ if cached := globalIconCache.getImage(key); cached != nil {
+ c.sc.DrawImage(cached, svgDrawTransform(bounds.Min.X, bounds.Min.Y, dpiScale))
+ return
+ }
+
+ // Cache miss: parse SVG (Level 1 cache) + rasterize at physical resolution.
+ doc := globalIconCache.getDoc(svgXML)
+ if doc == nil {
+ return
+ }
+
+ dc := gg.NewContext(physW, physH)
+ dc.SetRasterizerMode(gg.RasterizerAnalytic) // CPU-only: bypass GPU queueing
+ r8, g8, b8, a8 := color.RGBA8()
+ doc.RenderToWithColor(dc, 0, 0, float64(physW), float64(physH),
+ stdcolor.NRGBA{R: r8, G: g8, B: b8, A: a8})
+
+ rgba := imageToRGBA(dc.Image())
+ scImg := scene.NewImage(physW, physH)
+ scImg.Data = rgba.Pix
+ c.sc.DrawImage(scImg, svgDrawTransform(bounds.Min.X, bounds.Min.Y, dpiScale))
+ _ = dc.Close()
+
+ // Store in cache for next frame.
+ globalIconCache.putImage(key, scImg)
+}
+
// Verify SceneCanvas implements widget.Canvas.
var _ widget.Canvas = (*SceneCanvas)(nil)
// Verify SceneCanvas implements widget.ArcStroker.
var _ widget.ArcStroker = (*SceneCanvas)(nil)
+
+// Verify SceneCanvas implements widget.SVGFiller.
+var _ widget.SVGFiller = (*SceneCanvas)(nil)
+
+// Verify SceneCanvas implements widget.SVGRenderer.
+var _ widget.SVGRenderer = (*SceneCanvas)(nil)
diff --git a/internal/render/scene_canvas_test.go b/internal/render/scene_canvas_test.go
index 926057f..868df91 100644
--- a/internal/render/scene_canvas_test.go
+++ b/internal/render/scene_canvas_test.go
@@ -438,15 +438,15 @@ func TestSceneCanvas_DrawText_VectorPaths(t *testing.T) {
// Verify that fill commands were recorded (glyph outlines).
tags := sc.Encoding().Tags()
- hasFill := false
+ hasText := false
for _, tag := range tags {
- if tag == scene.TagFill {
- hasFill = true
+ if tag == scene.TagFill || tag == scene.TagText {
+ hasText = true
break
}
}
- if !hasFill {
- t.Error("expected TagFill commands from vector text rendering")
+ if !hasText {
+ t.Error("expected TagFill or TagText commands from text rendering")
}
}
@@ -559,3 +559,364 @@ func TestImageToRGBA_NonRGBA(t *testing.T) {
t.Error("bounds should match")
}
}
+
+func TestSceneCanvas_TextModeController(t *testing.T) {
+ sc := scene.NewScene()
+ canvas := NewSceneCanvas(sc, 100, 100)
+
+ tc, ok := widget.Canvas(canvas).(widget.TextModeController)
+ if !ok {
+ t.Fatal("SceneCanvas should implement TextModeController")
+ }
+
+ if tc.TextMode() != widget.TextModeAuto {
+ t.Errorf("TextMode = %v, want Auto", tc.TextMode())
+ }
+
+ tc.SetTextMode(widget.TextModeMSDF)
+ if tc.TextMode() != widget.TextModeAuto {
+ t.Error("SceneCanvas.TextMode should always return Auto (no-op)")
+ }
+}
+
+// --- DeviceScaler (ADR-026) ---
+
+func TestSceneCanvas_DeviceScaler_Interface(t *testing.T) {
+ sc := scene.NewScene()
+ canvas := NewSceneCanvas(sc, 100, 100)
+
+ _, ok := widget.Canvas(canvas).(widget.DeviceScaler)
+ if !ok {
+ t.Fatal("SceneCanvas should implement widget.DeviceScaler")
+ }
+}
+
+func TestSceneCanvas_DeviceScale_Default(t *testing.T) {
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 100, 100)
+
+ if got := c.DeviceScale(); got != 1 {
+ t.Errorf("DeviceScale() default = %f, want 1.0", got)
+ }
+}
+
+func TestSceneCanvas_DeviceScale_SetGet(t *testing.T) {
+ tests := []struct {
+ name string
+ set float32
+ expected float32
+ }{
+ {"scale 2.0", 2.0, 2.0},
+ {"scale 1.5", 1.5, 1.5},
+ {"scale 1.0", 1.0, 1.0},
+ {"scale 0 → default 1.0", 0, 1.0},
+ {"scale negative → default 1.0", -1, 1.0},
+ {"scale 3.0", 3.0, 3.0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 100, 100)
+ c.SetDeviceScale(tt.set)
+
+ if got := c.DeviceScale(); got != tt.expected {
+ t.Errorf("DeviceScale() = %f, want %f", got, tt.expected)
+ }
+ })
+ }
+}
+
+// --- svgDrawTransform ---
+
+func TestSvgDrawTransform_Scale1(t *testing.T) {
+ // At scale 1.0, should produce a pure translation.
+ aff := svgDrawTransform(10, 20, 1.0)
+ expected := scene.TranslateAffine(10, 20)
+
+ if aff != expected {
+ t.Errorf("svgDrawTransform(10,20,1) = %+v, want %+v", aff, expected)
+ }
+}
+
+func TestSvgDrawTransform_ScaleLessThan1(t *testing.T) {
+ // Scale < 1 should also produce pure translation (no downscale).
+ aff := svgDrawTransform(10, 20, 0.5)
+ expected := scene.TranslateAffine(10, 20)
+
+ if aff != expected {
+ t.Errorf("svgDrawTransform(10,20,0.5) = %+v, want %+v", aff, expected)
+ }
+}
+
+func TestSvgDrawTransform_Scale2(t *testing.T) {
+ // Scale 2.0: translate(10,20) * scale(0.5, 0.5)
+ aff := svgDrawTransform(10, 20, 2.0)
+
+ // Expected: A=0.5, B=0, C=10, D=0, E=0.5, F=20
+ if aff.A != 0.5 || aff.E != 0.5 {
+ t.Errorf("scale components: A=%f, E=%f, want 0.5, 0.5", aff.A, aff.E)
+ }
+ if aff.C != 10 || aff.F != 20 {
+ t.Errorf("translation components: C=%f, F=%f, want 10, 20", aff.C, aff.F)
+ }
+ if aff.B != 0 || aff.D != 0 {
+ t.Errorf("off-diagonal: B=%f, D=%f, want 0, 0", aff.B, aff.D)
+ }
+}
+
+func TestSvgDrawTransform_PointMapping(t *testing.T) {
+ // A 40×40 image drawn at scale 2.0 should map (40,40) → (30,30) = (10+40*0.5, 20+40*0.5)
+ aff := svgDrawTransform(10, 20, 2.0)
+ x, y := aff.TransformPoint(40, 40)
+
+ if x != 30 || y != 40 {
+ t.Errorf("TransformPoint(40,40) = (%f,%f), want (30,40)", x, y)
+ }
+
+ // Origin maps to the translation offset.
+ x0, y0 := aff.TransformPoint(0, 0)
+ if x0 != 10 || y0 != 20 {
+ t.Errorf("TransformPoint(0,0) = (%f,%f), want (10,20)", x0, y0)
+ }
+}
+
+// --- FillSVGPath DPI-aware rendering ---
+
+// simpleSVGPath is a valid SVG path for testing FillSVGPath.
+const simpleSVGPath = "M12 2L2 22h20z"
+
+func TestSceneCanvas_FillSVGPath_Scale1_ProducesScene(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ // Scale 1.0 (default) — baseline behavior.
+ v0 := sc.Version()
+ c.FillSVGPath(simpleSVGPath, 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 <= v0 {
+ t.Error("FillSVGPath at scale=1 should produce scene commands")
+ }
+}
+
+func TestSceneCanvas_FillSVGPath_Scale2_ProducesScene(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ c.SetDeviceScale(2.0)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.FillSVGPath(simpleSVGPath, 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 <= v0 {
+ t.Error("FillSVGPath at scale=2 should produce scene commands")
+ }
+}
+
+func TestSceneCanvas_FillSVGPath_DifferentScales_DifferentCacheEntries(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ // Render at scale 1.
+ sc1 := scene.NewScene()
+ c1 := NewSceneCanvas(sc1, 200, 200)
+ c1.SetDeviceScale(1.0)
+ c1.FillSVGPath(simpleSVGPath, 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ c1.Close()
+
+ stats1 := globalIconCache.stats()
+ entries1 := stats1.ImageEntries
+
+ // Render at scale 2 — must create a SEPARATE cache entry.
+ sc2 := scene.NewScene()
+ c2 := NewSceneCanvas(sc2, 200, 200)
+ c2.SetDeviceScale(2.0)
+ c2.FillSVGPath(simpleSVGPath, 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ c2.Close()
+
+ stats2 := globalIconCache.stats()
+ entries2 := stats2.ImageEntries
+
+ if entries2 <= entries1 {
+ t.Errorf("different scales should produce different cache entries: scale1=%d, scale2=%d",
+ entries1, entries2)
+ }
+}
+
+func TestSceneCanvas_FillSVGPath_EmptyPath(t *testing.T) {
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.FillSVGPath("", 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 != v0 {
+ t.Error("empty SVG path should not produce scene commands")
+ }
+}
+
+func TestSceneCanvas_FillSVGPath_ZeroViewBox(t *testing.T) {
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.FillSVGPath(simpleSVGPath, 0, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 != v0 {
+ t.Error("zero viewBox should not produce scene commands")
+ }
+}
+
+// --- RenderSVG DPI-aware rendering ---
+
+// minimalSVGForCanvas is a valid SVG XML for testing RenderSVG in scene_canvas tests.
+var minimalSVGForCanvas = []byte(``)
+
+func TestSceneCanvas_RenderSVG_Scale1_ProducesScene(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 <= v0 {
+ t.Error("RenderSVG at scale=1 should produce scene commands")
+ }
+}
+
+func TestSceneCanvas_RenderSVG_Scale2_ProducesScene(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ c.SetDeviceScale(2.0)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 <= v0 {
+ t.Error("RenderSVG at scale=2 should produce scene commands")
+ }
+}
+
+func TestSceneCanvas_RenderSVG_DifferentScales_DifferentCacheEntries(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ // Render at scale 1.
+ sc1 := scene.NewScene()
+ c1 := NewSceneCanvas(sc1, 200, 200)
+ c1.SetDeviceScale(1.0)
+ c1.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ c1.Close()
+
+ stats1 := globalIconCache.stats()
+ entries1 := stats1.ImageEntries
+
+ // Render at scale 2.
+ sc2 := scene.NewScene()
+ c2 := NewSceneCanvas(sc2, 200, 200)
+ c2.SetDeviceScale(2.0)
+ c2.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ c2.Close()
+
+ stats2 := globalIconCache.stats()
+ entries2 := stats2.ImageEntries
+
+ if entries2 <= entries1 {
+ t.Errorf("different scales should produce different cache entries: scale1=%d, scale2=%d",
+ entries1, entries2)
+ }
+}
+
+func TestSceneCanvas_RenderSVG_EmptyXML(t *testing.T) {
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.RenderSVG(nil, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 != v0 {
+ t.Error("nil SVG XML should not produce scene commands")
+ }
+}
+
+func TestSceneCanvas_RenderSVG_ZeroBounds(t *testing.T) {
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ v0 := sc.Version()
+ c.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 0, 0), widget.ColorBlack)
+ v1 := sc.Version()
+
+ if v1 != v0 {
+ t.Error("zero-size bounds should not produce scene commands")
+ }
+}
+
+func TestSceneCanvas_FillSVGPath_Scale1_CacheHit(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ defer c.Close()
+
+ // First call: cache miss.
+ c.FillSVGPath(simpleSVGPath, 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ stats1 := globalIconCache.stats()
+
+ // Second call with same params: cache hit.
+ c.FillSVGPath(simpleSVGPath, 24, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ stats2 := globalIconCache.stats()
+
+ if stats2.Hits <= stats1.Hits {
+ t.Error("second FillSVGPath call should produce a cache hit")
+ }
+}
+
+func TestSceneCanvas_RenderSVG_Scale2_CacheHit(t *testing.T) {
+ globalIconCache.invalidateAll()
+ defer globalIconCache.invalidateAll()
+
+ sc := scene.NewScene()
+ c := NewSceneCanvas(sc, 200, 200)
+ c.SetDeviceScale(2.0)
+ defer c.Close()
+
+ // First call: cache miss.
+ c.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ stats1 := globalIconCache.stats()
+
+ // Second call with same params + scale: cache hit.
+ c.RenderSVG(minimalSVGForCanvas, geometry.NewRect(10, 10, 20, 20), widget.ColorBlack)
+ stats2 := globalIconCache.stats()
+
+ if stats2.Hits <= stats1.Hits {
+ t.Error("second RenderSVG call at same scale should produce a cache hit")
+ }
+}
diff --git a/primitives/box.go b/primitives/box.go
index 0dd1124..59422ae 100644
--- a/primitives/box.go
+++ b/primitives/box.go
@@ -26,6 +26,29 @@ type BoxStyle struct {
MaxHeight float32
}
+// BoxWidget is a container that lays out children vertically or horizontally
+// with optional padding, background, border, rounded corners, shadow, and gap.
+//
+// BoxWidget implements [widget.Widget], [a11y.Accessible], and [widget.Lifecycle].
+//
+// Create a BoxWidget with the [Box] constructor. Use [HBox] or [VBox] for
+// convenience constructors with a pre-set direction.
+// CrossAxisAlignment controls how children are positioned on the cross axis.
+// For VBox (vertical): cross axis = horizontal. For HBox: cross axis = vertical.
+// Flutter equivalent: CrossAxisAlignment in Column/Row.
+type CrossAxisAlignment int
+
+const (
+ // CrossAxisStart aligns children to the start (left for VBox, top for HBox).
+ CrossAxisStart CrossAxisAlignment = iota
+ // CrossAxisCenter centers children on the cross axis.
+ CrossAxisCenter
+ // CrossAxisEnd aligns children to the end (right for VBox, bottom for HBox).
+ CrossAxisEnd
+ // CrossAxisStretch stretches children to fill the cross axis (default).
+ CrossAxisStretch
+)
+
// BoxWidget is a container that lays out children vertically or horizontally
// with optional padding, background, border, rounded corners, shadow, and gap.
//
@@ -39,6 +62,7 @@ type BoxWidget struct {
style BoxStyle
direction Direction
directionSignal state.ReadonlySignal[Direction]
+ crossAlign CrossAxisAlignment
children []widget.Widget
accessibilityLabel string
}
@@ -159,6 +183,19 @@ func (b *BoxWidget) Gap(v float32) *BoxWidget {
return b
}
+// CrossAlign sets the cross-axis alignment for child widgets.
+// For VBox: controls horizontal alignment (center, start, end, stretch).
+// For HBox: controls vertical alignment.
+// Default is CrossAxisStart (left-aligned for VBox).
+//
+// Flutter equivalent: CrossAxisAlignment on Column/Row.
+//
+// primitives.VBox(spinner).CrossAlign(primitives.CrossAxisCenter)
+func (b *BoxWidget) CrossAlign(a CrossAxisAlignment) *BoxWidget {
+ b.crossAlign = a
+ return b
+}
+
// SetDirection sets the layout direction for child widgets.
//
// primitives.Box(a, b, c).SetDirection(primitives.DirectionHorizontal)
@@ -303,13 +340,15 @@ func (b *BoxWidget) layoutVerticalSimple(
}
remaining.MinHeight = 0
+ // CrossAxisStretch: give child full width. Others: let child choose.
+ if b.crossAlign != CrossAxisStretch {
+ remaining.MinWidth = 0
+ }
+
size := child.Layout(ctx, remaining)
childX := pad.Left
childY := pad.Top + totalHeight
- child.(interface{ SetBounds(geometry.Rect) }).SetBounds(
- geometry.FromPointSize(geometry.Pt(childX, childY), size),
- )
totalHeight += size.Height
if i < childCount-1 {
@@ -318,12 +357,35 @@ func (b *BoxWidget) layoutVerticalSimple(
if size.Width > maxChildWidth {
maxChildWidth = size.Width
}
+
+ child.(interface{ SetBounds(geometry.Rect) }).SetBounds(
+ geometry.FromPointSize(geometry.Pt(childX, childY), size),
+ )
}
contentWidth := maxChildWidth + pad.Horizontal()
contentHeight := totalHeight + pad.Vertical()
resultSize := constraints.Constrain(geometry.Sz(contentWidth, contentHeight))
+
+ // Second pass: apply cross-axis alignment now that we know total width.
+ if b.crossAlign == CrossAxisCenter || b.crossAlign == CrossAxisEnd {
+ availWidth := resultSize.Width - pad.Horizontal()
+ for _, child := range b.children {
+ cb := child.(interface{ Bounds() geometry.Rect }).Bounds()
+ cw := cb.Width()
+ ch := cb.Height()
+ var newX float32
+ if b.crossAlign == CrossAxisCenter {
+ newX = pad.Left + (availWidth-cw)/2
+ } else {
+ newX = pad.Left + availWidth - cw
+ }
+ child.(interface{ SetBounds(geometry.Rect) }).SetBounds(
+ geometry.NewRect(newX, cb.Min.Y, cw, ch),
+ )
+ }
+ }
b.SetBounds(geometry.FromPointSize(b.Position(), resultSize))
return resultSize
}
@@ -596,21 +658,15 @@ func (b *BoxWidget) Draw(ctx widget.Context, canvas widget.Canvas) {
}
// Draw children with transform offset for this box's position.
- // Viewport culling: skip Draw for children outside the clip region.
- // This prevents offscreen widgets (e.g., spinner scrolled out of
- // ScrollView) from ticking animations and triggering redraws.
+ // No viewport culling here — DrawChild skip pattern makes offscreen
+ // boundary children cheap (O(1) stamp + skip). Compositor-level culling
+ // via CompositorClip handles GPU texture rendering and compositing.
+ // Flutter: PaintingContext.paintChild always calls paint on all children;
+ // visibility culling is in the compositor (addRetained vs addToScene).
canvas.PushTransform(bounds.Min)
- clipBounds := canvas.ClipBounds()
- offset := canvas.TransformOffset()
for _, child := range b.children {
- if bg, ok := child.(interface{ Bounds() geometry.Rect }); ok {
- childRect := bg.Bounds().Translate(offset)
- if !clipBounds.Intersects(childRect) {
- continue
- }
- }
widget.StampScreenOrigin(child, canvas)
- child.Draw(ctx, canvas)
+ widget.DrawChild(child, ctx, canvas)
}
canvas.PopTransform()
diff --git a/primitives/box_test.go b/primitives/box_test.go
index 7a9952e..4405333 100644
--- a/primitives/box_test.go
+++ b/primitives/box_test.go
@@ -948,6 +948,7 @@ func (c *mockCanvas) PopClip() { c.popClipCo
func (c *mockCanvas) PushTransform(_ geometry.Point) { c.pushTransformCount++ }
func (c *mockCanvas) PopTransform() { c.popTransformCount++ }
func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/primitives/cross_align_test.go b/primitives/cross_align_test.go
new file mode 100644
index 0000000..355405b
--- /dev/null
+++ b/primitives/cross_align_test.go
@@ -0,0 +1,78 @@
+package primitives_test
+
+import (
+ "testing"
+
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/primitives"
+ "github.com/gogpu/ui/widget"
+)
+
+func TestVBox_CrossAxisCenter(t *testing.T) {
+ ctx := widget.NewContext()
+
+ // 48px wide spinner in 400px wide VBox → should be centered at X=176.
+ child := primitives.Text("X").FontSize(14)
+
+ box := primitives.VBox(child).
+ CrossAlign(primitives.CrossAxisCenter).
+ Padding(0)
+
+ constraints := geometry.BoxConstraints(400, 400, 0, 400)
+ box.Layout(ctx, constraints)
+
+ bounds := child.Bounds()
+ boxBounds := box.Bounds()
+ childW := bounds.Width()
+ boxW := boxBounds.Width()
+ expectedX := (boxW - childW) / 2
+
+ t.Logf("box bounds: %v (width=%f)", boxBounds, boxW)
+ t.Logf("child bounds: %v (width=%f)", bounds, childW)
+ t.Logf("expectedX: %f", expectedX)
+
+ if bounds.Min.X < expectedX-2 || bounds.Min.X > expectedX+2 {
+ t.Errorf("child X = %f, want ~%f (centered in %fpx VBox, child width=%f)",
+ bounds.Min.X, expectedX, boxW, childW)
+ }
+}
+
+func TestVBox_CrossAxisStart_Default(t *testing.T) {
+ ctx := widget.NewContext()
+
+ child := primitives.Text("Left").FontSize(14)
+
+ box := primitives.VBox(child).Padding(0)
+
+ constraints := geometry.BoxConstraints(400, 400, 0, 400)
+ box.Layout(ctx, constraints)
+
+ bounds := child.Bounds()
+
+ // Default = start = X should be 0 (left-aligned).
+ if bounds.Min.X > 1 {
+ t.Errorf("default cross-align: child X = %f, want 0 (left-aligned)", bounds.Min.X)
+ }
+}
+
+func TestVBox_CrossAxisEnd(t *testing.T) {
+ ctx := widget.NewContext()
+
+ child := primitives.Text("Right").FontSize(14)
+
+ box := primitives.VBox(child).
+ CrossAlign(primitives.CrossAxisEnd).
+ Padding(0)
+
+ constraints := geometry.BoxConstraints(400, 400, 0, 400)
+ box.Layout(ctx, constraints)
+
+ bounds := child.Bounds()
+ childW := bounds.Width()
+ expectedX := 400 - childW
+
+ if bounds.Min.X < expectedX-2 || bounds.Min.X > expectedX+2 {
+ t.Errorf("child X = %f, want ~%f (end-aligned, width=%f)",
+ bounds.Min.X, expectedX, childW)
+ }
+}
diff --git a/primitives/repaint_boundary.go b/primitives/repaint_boundary.go
index 15541a9..3f0fa2b 100644
--- a/primitives/repaint_boundary.go
+++ b/primitives/repaint_boundary.go
@@ -388,12 +388,20 @@ func (rb *RepaintBoundary) Draw(ctx widget.Context, canvas widget.Canvas) {
}
// Cache hit: boundary is clean and we have a cached scene.
+ // Suppress damage tracking during replay — cached content hasn't changed,
+ // so it must not inflate the gg-level damage list (ADR-021 false positive).
if !rb.boundaryDirty && rb.cachedScene != nil {
rb.recordCacheHit(ctx)
rb.consecutiveHits++
rb.evaluatePromotion(w, h)
canvas.PushTransform(bounds.Min)
+ if dc, ok := canvas.(widget.DamageController); ok {
+ dc.SetDamageTracking(false)
+ }
canvas.ReplayScene(rb.cachedScene)
+ if dc, ok := canvas.(widget.DamageController); ok {
+ dc.SetDamageTracking(true)
+ }
canvas.PopTransform()
return
}
diff --git a/theme/cupertino/cupertino_test.go b/theme/cupertino/cupertino_test.go
index 58ef714..fea7e2f 100644
--- a/theme/cupertino/cupertino_test.go
+++ b/theme/cupertino/cupertino_test.go
@@ -95,6 +95,7 @@ func (c *recordCanvas) PopClip() {}
func (c *recordCanvas) PushTransform(_ geometry.Point) {}
func (c *recordCanvas) PopTransform() {}
func (c *recordCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *recordCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *recordCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *recordCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/devtools/painters_test.go b/theme/devtools/painters_test.go
index b54c8c9..8b4a566 100644
--- a/theme/devtools/painters_test.go
+++ b/theme/devtools/painters_test.go
@@ -95,6 +95,7 @@ func (c *recordCanvas) PopClip() {}
func (c *recordCanvas) PushTransform(_ geometry.Point) {}
func (c *recordCanvas) PopTransform() {}
func (c *recordCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *recordCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *recordCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *recordCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/devtools/titlebar_test.go b/theme/devtools/titlebar_test.go
index 22588d8..97e7574 100644
--- a/theme/devtools/titlebar_test.go
+++ b/theme/devtools/titlebar_test.go
@@ -252,5 +252,6 @@ func (c *tbMockCanvas) PopClip() {
func (c *tbMockCanvas) PushTransform(_ geometry.Point) {}
func (c *tbMockCanvas) PopTransform() {}
func (c *tbMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *tbMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *tbMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *tbMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/fluent/fluent_test.go b/theme/fluent/fluent_test.go
index 0759421..91bffb1 100644
--- a/theme/fluent/fluent_test.go
+++ b/theme/fluent/fluent_test.go
@@ -95,6 +95,7 @@ func (c *recordCanvas) PopClip() {}
func (c *recordCanvas) PushTransform(_ geometry.Point) {}
func (c *recordCanvas) PopTransform() {}
func (c *recordCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *recordCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *recordCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *recordCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/button_test.go b/theme/material3/button_test.go
index 447cca3..1b22809 100644
--- a/theme/material3/button_test.go
+++ b/theme/material3/button_test.go
@@ -88,10 +88,11 @@ func (c *recordCanvas) PopClip() {}
func (c *recordCanvas) PushTransform(offset geometry.Point) {}
-func (c *recordCanvas) PopTransform() {}
-func (c *recordCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
-func (c *recordCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
-func (c *recordCanvas) ReplayScene(_ *scene.Scene) {}
+func (c *recordCanvas) PopTransform() {}
+func (c *recordCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *recordCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+func (c *recordCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
+func (c *recordCanvas) ReplayScene(_ *scene.Scene) {}
// Method name constants to satisfy goconst.
const (
diff --git a/theme/material3/collapsible_test.go b/theme/material3/collapsible_test.go
index 99fe630..751c620 100644
--- a/theme/material3/collapsible_test.go
+++ b/theme/material3/collapsible_test.go
@@ -251,5 +251,6 @@ func (c *colMockCanvas) PopClip() {}
func (c *colMockCanvas) PushTransform(_ geometry.Point) {}
func (c *colMockCanvas) PopTransform() {}
func (c *colMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *colMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *colMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *colMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/datatable_test.go b/theme/material3/datatable_test.go
index 772de3f..94dc4aa 100644
--- a/theme/material3/datatable_test.go
+++ b/theme/material3/datatable_test.go
@@ -215,5 +215,6 @@ func (c *tableMockCanvas) PopClip() {}
func (c *tableMockCanvas) PushTransform(_ geometry.Point) {}
func (c *tableMockCanvas) PopTransform() {}
func (c *tableMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *tableMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *tableMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *tableMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/dialog_test.go b/theme/material3/dialog_test.go
index b86a0fd..a57954d 100644
--- a/theme/material3/dialog_test.go
+++ b/theme/material3/dialog_test.go
@@ -230,5 +230,6 @@ func (c *dialogMockCanvas) PopClip() {}
func (c *dialogMockCanvas) PushTransform(_ geometry.Point) {}
func (c *dialogMockCanvas) PopTransform() {}
func (c *dialogMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *dialogMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *dialogMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *dialogMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/docking_test.go b/theme/material3/docking_test.go
index ac4645c..31ee835 100644
--- a/theme/material3/docking_test.go
+++ b/theme/material3/docking_test.go
@@ -214,5 +214,6 @@ func (c *dockingMockCanvas) PopClip() {}
func (c *dockingMockCanvas) PushTransform(_ geometry.Point) {}
func (c *dockingMockCanvas) PopTransform() {}
func (c *dockingMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *dockingMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *dockingMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *dockingMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/gridview_test.go b/theme/material3/gridview_test.go
index 44ab7e7..b9f6b62 100644
--- a/theme/material3/gridview_test.go
+++ b/theme/material3/gridview_test.go
@@ -286,5 +286,6 @@ func (c *gvMockCanvas) PopClip() {}
func (c *gvMockCanvas) PushTransform(_ geometry.Point) {}
func (c *gvMockCanvas) PopTransform() {}
func (c *gvMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *gvMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *gvMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *gvMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/linechart_test.go b/theme/material3/linechart_test.go
index 96635b6..fdffac8 100644
--- a/theme/material3/linechart_test.go
+++ b/theme/material3/linechart_test.go
@@ -192,5 +192,6 @@ func (c *chartMockCanvas) PopClip() {}
func (c *chartMockCanvas) PushTransform(_ geometry.Point) {}
func (c *chartMockCanvas) PopTransform() {}
func (c *chartMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *chartMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *chartMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *chartMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/menu_test.go b/theme/material3/menu_test.go
index 73107bf..a97f81a 100644
--- a/theme/material3/menu_test.go
+++ b/theme/material3/menu_test.go
@@ -237,5 +237,6 @@ func (c *menuMockCanvas) PopClip() {}
func (c *menuMockCanvas) PushTransform(_ geometry.Point) {}
func (c *menuMockCanvas) PopTransform() {}
func (c *menuMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *menuMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *menuMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *menuMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/popover_test.go b/theme/material3/popover_test.go
index 3f73d28..20e1928 100644
--- a/theme/material3/popover_test.go
+++ b/theme/material3/popover_test.go
@@ -186,5 +186,6 @@ func (c *popMockCanvas) PopClip() {}
func (c *popMockCanvas) PushTransform(_ geometry.Point) {}
func (c *popMockCanvas) PopTransform() {}
func (c *popMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *popMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *popMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *popMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/progress_test.go b/theme/material3/progress_test.go
index 1762c42..063dcbe 100644
--- a/theme/material3/progress_test.go
+++ b/theme/material3/progress_test.go
@@ -207,5 +207,6 @@ func (c *cpMockCanvas) PopClip() {}
func (c *cpMockCanvas) PushTransform(_ geometry.Point) {}
func (c *cpMockCanvas) PopTransform() {}
func (c *cpMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *cpMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *cpMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *cpMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/progressbar_test.go b/theme/material3/progressbar_test.go
index f43bb6e..3963c9f 100644
--- a/theme/material3/progressbar_test.go
+++ b/theme/material3/progressbar_test.go
@@ -231,5 +231,6 @@ func (c *pbMockCanvas) PopClip() {}
func (c *pbMockCanvas) PushTransform(_ geometry.Point) {}
func (c *pbMockCanvas) PopTransform() {}
func (c *pbMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *pbMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *pbMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *pbMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/scrollbar_test.go b/theme/material3/scrollbar_test.go
index 5f24f3a..bbbdb3e 100644
--- a/theme/material3/scrollbar_test.go
+++ b/theme/material3/scrollbar_test.go
@@ -180,5 +180,6 @@ func (c *scrollbarMockCanvas) PopClip() {}
func (c *scrollbarMockCanvas) PushTransform(_ geometry.Point) {}
func (c *scrollbarMockCanvas) PopTransform() {}
func (c *scrollbarMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *scrollbarMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *scrollbarMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *scrollbarMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/slider_test.go b/theme/material3/slider_test.go
index 7add87e..11d82a9 100644
--- a/theme/material3/slider_test.go
+++ b/theme/material3/slider_test.go
@@ -364,5 +364,6 @@ func (c *sliderMockCanvas) PopClip() {}
func (c *sliderMockCanvas) PushTransform(_ geometry.Point) {}
func (c *sliderMockCanvas) PopTransform() {}
func (c *sliderMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *sliderMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *sliderMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *sliderMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/splitview_test.go b/theme/material3/splitview_test.go
index 351f163..39fa387 100644
--- a/theme/material3/splitview_test.go
+++ b/theme/material3/splitview_test.go
@@ -245,5 +245,6 @@ func (c *svMockCanvas) PopClip() {}
func (c *svMockCanvas) PushTransform(_ geometry.Point) {}
func (c *svMockCanvas) PopTransform() {}
func (c *svMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *svMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *svMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *svMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/tabview_test.go b/theme/material3/tabview_test.go
index fe43a64..e87ff7c 100644
--- a/theme/material3/tabview_test.go
+++ b/theme/material3/tabview_test.go
@@ -264,5 +264,6 @@ func (c *tabMockCanvas) PopClip() {}
func (c *tabMockCanvas) PushTransform(_ geometry.Point) {}
func (c *tabMockCanvas) PopTransform() {}
func (c *tabMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *tabMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *tabMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *tabMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/toolbar_test.go b/theme/material3/toolbar_test.go
index 86e8a56..1f5b811 100644
--- a/theme/material3/toolbar_test.go
+++ b/theme/material3/toolbar_test.go
@@ -223,5 +223,6 @@ func (c *toolbarMockCanvas) PopClip() {}
func (c *toolbarMockCanvas) PushTransform(_ geometry.Point) {}
func (c *toolbarMockCanvas) PopTransform() {}
func (c *toolbarMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *toolbarMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *toolbarMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *toolbarMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/theme/material3/treeview_test.go b/theme/material3/treeview_test.go
index aaafacf..5e15410 100644
--- a/theme/material3/treeview_test.go
+++ b/theme/material3/treeview_test.go
@@ -262,5 +262,6 @@ func (c *treeMockCanvas) PopClip() {}
func (c *treeMockCanvas) PushTransform(_ geometry.Point) {}
func (c *treeMockCanvas) PopTransform() {}
func (c *treeMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *treeMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *treeMockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *treeMockCanvas) ReplayScene(_ *scene.Scene) {}
diff --git a/transition/transition_test.go b/transition/transition_test.go
index 4eabdac..fc05390 100644
--- a/transition/transition_test.go
+++ b/transition/transition_test.go
@@ -81,9 +81,10 @@ func (c *mockCanvas) PopTransform() {
c.transforms = c.transforms[:len(c.transforms)-1]
}
}
-func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
-func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
-func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
+func (c *mockCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+func (c *mockCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
+func (c *mockCanvas) ReplayScene(_ *scene.Scene) {}
// opacityCanvas extends mockCanvas with OpacityPusher support.
type opacityCanvas struct {
diff --git a/uitest/canvas.go b/uitest/canvas.go
index c63fbe4..7121296 100644
--- a/uitest/canvas.go
+++ b/uitest/canvas.go
@@ -296,6 +296,9 @@ func (c *MockCanvas) TransformOffset() geometry.Point {
return c.currentOffset
}
+// ScreenOriginBase returns the screen-space base offset (always zero for mock).
+func (c *MockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+
// ClipBounds returns a large default clip rectangle.
func (c *MockCanvas) ClipBounds() geometry.Rect {
return geometry.NewRect(0, 0, 10000, 10000)
diff --git a/widget/base.go b/widget/base.go
index 38c33da..46697e1 100644
--- a/widget/base.go
+++ b/widget/base.go
@@ -3,6 +3,7 @@ package widget
import (
"sync"
+ "github.com/gogpu/gg/scene"
"github.com/gogpu/ui/geometry"
)
@@ -52,19 +53,42 @@ type Stopper interface {
// should occur on the main/UI thread. The mutex is provided for cases
// where properties need to be queried from callbacks.
type WidgetBase struct {
- mu sync.RWMutex
- bounds geometry.Rect // Cached layout bounds
- screenOrigin geometry.Point // Window-space origin, set during Draw pass
- focused bool // Whether widget has focus
- visible bool // Whether widget is visible
- enabled bool // Whether widget accepts input
- needsRedraw bool // Whether widget needs re-rendering (retained mode)
- id string // Optional ID for debugging
- children []Widget // Child widgets
- parent Widget // Parent widget (if any)
- bindings []Unbinder // Signal bindings (cleaned up on unmount)
- effects []Stopper // Effects (stopped on unmount)
- mounted bool // Whether widget is currently mounted
+ mu sync.RWMutex
+ bounds geometry.Rect // Cached layout bounds
+ screenOrigin geometry.Point // Window-space origin, set during Draw pass
+ screenOriginValid bool // true after first StampScreenOrigin call
+ focused bool // Whether widget has focus
+ visible bool // Whether widget is visible
+ enabled bool // Whether widget accepts input
+ needsRedraw bool // Whether widget needs re-rendering (retained mode)
+ id string // Optional ID for debugging
+ children []Widget // Child widgets
+ parent Widget // Parent widget (if any)
+ bindings []Unbinder // Signal bindings (cleaned up on unmount)
+ effects []Stopper // Effects (stopped on unmount)
+ mounted bool // Whether widget is currently mounted
+
+ // --- RepaintBoundary property (ADR-024) ---
+ // When isRepaintBoundary is true, this widget owns a scene.Scene that
+ // caches its subtree rendering. Clean boundaries replay cached content
+ // instead of re-executing Draw on every descendant.
+ isRepaintBoundary bool
+ boundaryCacheKey uint64 // Unique ID for dirty-set deduplication
+ cachedScene *scene.Scene // Recorded display list for the subtree
+ sceneDirty bool // Whether the cached scene needs re-recording
+ sceneCacheVersion uint64 // Monotonic counter (increments on re-record)
+ sceneCacheWidth int // Cache dimensions for size-change detection
+ sceneCacheHeight int // Cache dimensions for size-change detection
+ onBoundaryDirty func() // Callback when boundary transitions to dirty
+ suppressDirtyCallback bool // Suppressed during Draw recording (animation defers render)
+
+ // --- Compositor clip (for per-boundary GPU textures) ---
+ // When this boundary is skipped during parent BoundaryRecording (DrawChild),
+ // the parent's current clip rect is stored here in screen-space coordinates.
+ // compositeTextures uses this to skip/clip textures outside the viewport
+ // (e.g., ListView items scrolled outside ScrollView bounds).
+ compositorClip geometry.Rect
+ hasCompositorClip bool
}
// NewWidgetBase creates a new WidgetBase with default settings.
@@ -357,6 +381,17 @@ func (w *WidgetBase) SetScreenOrigin(origin geometry.Point) {
w.mu.Lock()
defer w.mu.Unlock()
w.screenOrigin = origin
+ w.screenOriginValid = true
+}
+
+// IsScreenOriginValid reports whether ScreenOrigin has been set by
+// StampScreenOrigin during a Draw pass. Boundaries with invalid
+// ScreenOrigin (never drawn) should not be composited — their
+// textures would appear at (0,0) instead of the correct position.
+func (w *WidgetBase) IsScreenOriginValid() bool {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.screenOriginValid
}
// ScreenBounds returns the widget's bounds in window (screen) coordinates.
@@ -383,6 +418,38 @@ func (w *WidgetBase) ScreenBounds() geometry.Rect {
return geometry.FromPointSize(w.screenOrigin, size)
}
+// CompositorClip returns the screen-space clip rect for this boundary.
+// Used by compositeTextures to skip textures outside the viewport.
+func (w *WidgetBase) CompositorClip() geometry.Rect {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.compositorClip
+}
+
+// HasCompositorClip returns whether a compositor clip rect has been set.
+func (w *WidgetBase) HasCompositorClip() bool {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.hasCompositorClip
+}
+
+// SetCompositorClip records the screen-space clip rect for this boundary.
+// Called by DrawChild when skipping child boundaries during BoundaryRecording.
+func (w *WidgetBase) SetCompositorClip(clip geometry.Rect) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.compositorClip = clip
+ w.hasCompositorClip = true
+}
+
+// ClearCompositorClip removes the compositor clip rect.
+func (w *WidgetBase) ClearCompositorClip() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.compositorClip = geometry.Rect{}
+ w.hasCompositorClip = false
+}
+
// LocalToGlobal converts a point from local coordinates to global (window) coordinates.
//
// Local coordinates are relative to the widget's top-left corner.
@@ -457,12 +524,21 @@ func (w *WidgetBase) SetNeedsRedraw(v bool) {
w.mu.Lock()
alreadyDirty := w.needsRedraw
w.needsRedraw = v
+ isBoundary := w.isRepaintBoundary
parent := w.parent
w.mu.Unlock()
// Propagate upward only when setting dirty, and only if not already dirty
// (O(1) guard prevents redundant walks).
if v && !alreadyDirty {
+ // Flutter markNeedsPaint: if THIS widget is a RepaintBoundary,
+ // invalidate its own scene and stop — don't propagate to parent.
+ // This is critical for animated widgets (spinner): dirty stays
+ // at the spinner's boundary, parent tree stays clean.
+ if isBoundary {
+ w.InvalidateScene()
+ return
+ }
propagateDirtyUpward(parent)
}
}
@@ -472,11 +548,26 @@ func (w *WidgetBase) SetNeedsRedraw(v bool) {
// RepaintBoundary is reached, it is marked dirty and propagation stops —
// this is the Flutter markNeedsPaint pattern (ADR-007).
//
+// A widget is considered a repaint boundary if:
+// - It has IsRepaintBoundary() == true (ADR-024 WidgetBase property), OR
+// - It implements RepaintBoundaryMarker (legacy primitives.RepaintBoundary wrapper).
+//
// If no RepaintBoundary is found, propagation reaches the root (which is
// correct — the root boundary encompasses the entire window).
func propagateDirtyUpward(w Widget) {
for w != nil {
- // If this ancestor is a RepaintBoundary, mark it dirty and stop.
+ // Check ADR-024 property first: WidgetBase.isRepaintBoundary.
+ type boundaryPropChecker interface {
+ IsRepaintBoundary() bool
+ InvalidateScene()
+ }
+ if bp, ok := w.(boundaryPropChecker); ok && bp.IsRepaintBoundary() {
+ bp.InvalidateScene()
+ return
+ }
+
+ // Legacy check: primitives.RepaintBoundary implements RepaintBoundaryMarker
+ // with its own MarkBoundaryDirty() override.
if rb, ok := w.(RepaintBoundaryMarker); ok {
rb.MarkBoundaryDirty()
return
diff --git a/widget/boundary.go b/widget/boundary.go
new file mode 100644
index 0000000..93c8bd0
--- /dev/null
+++ b/widget/boundary.go
@@ -0,0 +1,188 @@
+package widget
+
+import (
+ "sync/atomic"
+
+ "github.com/gogpu/gg/scene"
+)
+
+// nextBoundaryCacheKey is a monotonic counter for generating unique cache keys.
+// Each widget that becomes a RepaintBoundary gets a unique uint64 ID, used for
+// deduplication in the dirty boundary tracking set. Atomic to be safe for
+// concurrent boundary creation across goroutines.
+var nextBoundaryCacheKey atomic.Uint64
+
+// --- RepaintBoundary property (ADR-024 Phase 1) ---
+//
+// These fields extend WidgetBase to support scene caching without requiring
+// a wrapper widget. Any widget can become a repaint boundary by calling
+// SetRepaintBoundary(true). This is the Flutter RenderObject.isRepaintBoundary
+// pattern: a boolean property on the base class, not a wrapper node.
+
+// SetRepaintBoundary marks this widget as a repaint boundary.
+//
+// When enabled, the widget owns a scene.Scene display list that caches
+// its subtree rendering. Clean boundaries replay their cached scene
+// instead of re-executing Draw on every descendant.
+//
+// This is equivalent to Flutter's RenderObject.isRepaintBoundary and
+// Android's View.setLayerType(LAYER_TYPE_HARDWARE).
+//
+// Calling this with false disables boundary behavior and releases the
+// cached scene.
+func (w *WidgetBase) SetRepaintBoundary(enabled bool) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if w.isRepaintBoundary == enabled {
+ return
+ }
+
+ w.isRepaintBoundary = enabled
+ if enabled {
+ // Assign a unique cache key for this boundary.
+ if w.boundaryCacheKey == 0 {
+ w.boundaryCacheKey = nextBoundaryCacheKey.Add(1)
+ }
+ // Start dirty so first draw records the scene.
+ w.sceneDirty = true
+ } else {
+ // Release cached scene when disabling boundary.
+ w.cachedScene = nil
+ w.sceneDirty = false
+ w.sceneCacheVersion = 0
+ w.sceneCacheWidth = 0
+ w.sceneCacheHeight = 0
+ }
+}
+
+// IsRepaintBoundary reports whether this widget is a repaint boundary.
+//
+// Repaint boundaries own a scene.Scene that caches their subtree rendering.
+// The DrawTree function checks this property and replays the cached scene
+// when the boundary is clean, avoiding re-execution of the child Draw methods.
+func (w *WidgetBase) IsRepaintBoundary() bool {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.isRepaintBoundary
+}
+
+// BoundaryCacheKey returns the unique monotonic ID for this boundary.
+// Returns 0 if the widget is not a repaint boundary.
+func (w *WidgetBase) BoundaryCacheKey() uint64 {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.boundaryCacheKey
+}
+
+// InvalidateScene marks this boundary's cached scene as stale, forcing
+// a re-record on the next draw pass. This is called automatically when
+// descendants call SetNeedsRedraw (upward dirty propagation via
+// propagateDirtyUpward).
+//
+// If this widget is not a repaint boundary, this is a no-op.
+// If the scene is already dirty, this is a no-op (O(1) guard).
+//
+// Triggers the onBoundaryDirty callback to notify the Window.
+func (w *WidgetBase) InvalidateScene() {
+ w.mu.Lock()
+ if !w.isRepaintBoundary {
+ w.mu.Unlock()
+ return
+ }
+ if w.sceneDirty {
+ w.mu.Unlock()
+ return // Already dirty — O(1) guard.
+ }
+ w.sceneDirty = true
+ cb := w.onBoundaryDirty
+ suppress := w.suppressDirtyCallback
+ w.mu.Unlock()
+
+ // During Draw recording (suppressDirtyCallback=true), the boundary dirty
+ // callback is suppressed. Animated widgets call ScheduleAnimationFrame()
+ // explicitly to request deferred render. This prevents the immediate
+ // RequestRedraw chain that forces 60fps for 30fps animations.
+ // External events (hover, click) set dirty OUTSIDE Draw — callback fires
+ // immediately for instant user feedback.
+ if cb != nil && !suppress {
+ cb()
+ }
+}
+
+// SetSuppressDirtyCallback controls whether onBoundaryDirty callback fires
+// during InvalidateScene. Set to true during Draw recording so animated
+// widgets can defer render requests via ScheduleAnimationFrame instead of
+// triggering immediate RequestRedraw.
+func (w *WidgetBase) SetSuppressDirtyCallback(v bool) {
+ w.mu.Lock()
+ w.suppressDirtyCallback = v
+ w.mu.Unlock()
+}
+
+// IsSceneDirty reports whether the boundary's cached scene needs re-recording.
+func (w *WidgetBase) IsSceneDirty() bool {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.sceneDirty
+}
+
+// CachedScene returns the boundary's cached scene, or nil if no cache exists.
+// This is used by DrawTree to replay the scene when the boundary is clean.
+func (w *WidgetBase) CachedScene() *scene.Scene {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.cachedScene
+}
+
+// SetCachedScene stores the recorded scene for this boundary.
+// Called by the render system after recording the subtree.
+func (w *WidgetBase) SetCachedScene(s *scene.Scene) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.cachedScene = s
+}
+
+// ClearSceneDirty resets the sceneDirty flag after the boundary has been
+// re-recorded. Called by the render system after a successful record pass.
+func (w *WidgetBase) ClearSceneDirty() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.sceneDirty = false
+ w.sceneCacheVersion++
+}
+
+// SceneCacheVersion returns a monotonic counter that increments each time
+// the boundary's scene is re-recorded. Used by the compositor to detect
+// when content has actually changed between frames.
+func (w *WidgetBase) SceneCacheVersion() uint64 {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.sceneCacheVersion
+}
+
+// SceneCacheSize returns the cached scene dimensions (width, height).
+// Returns (0, 0) if no cache exists.
+func (w *WidgetBase) SceneCacheSize() (int, int) {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.sceneCacheWidth, w.sceneCacheHeight
+}
+
+// SetSceneCacheSize records the dimensions of the cached scene.
+// If the widget's bounds change, the caller should invalidate the scene.
+func (w *WidgetBase) SetSceneCacheSize(width, height int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.sceneCacheWidth = width
+ w.sceneCacheHeight = height
+}
+
+// SetOnBoundaryDirty sets the callback invoked when this boundary transitions
+// from clean to dirty via upward propagation. Used by the Window to collect
+// dirty boundaries into its set and request a redraw.
+func (w *WidgetBase) SetOnBoundaryDirty(fn func()) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.onBoundaryDirty = fn
+}
diff --git a/widget/boundary_draw.go b/widget/boundary_draw.go
new file mode 100644
index 0000000..1d8c841
--- /dev/null
+++ b/widget/boundary_draw.go
@@ -0,0 +1,222 @@
+package widget
+
+import (
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/geometry"
+)
+
+// SceneRecorder creates a recording Canvas that writes draw commands into a
+// scene.Scene. This is the dependency-injection point for ADR-024 Phase 2:
+// the widget package cannot import internal/render (circular dep), so the
+// app layer registers a factory function that creates SceneCanvas instances.
+//
+// The returned Canvas records all drawing operations into the given scene.
+// After recording, the scene can be replayed via Canvas.ReplayScene.
+//
+// Parameters:
+// - s: the scene.Scene to record into (must not be nil)
+// - width, height: dimensions of the recording canvas
+//
+// Returns a Canvas that records into s, and a cleanup function that must
+// be called after recording is complete (e.g., SceneCanvas.Close).
+type SceneRecorder func(s *scene.Scene, width, height int) (Canvas, func())
+
+// sceneRecorderFactory holds the registered SceneRecorder factory.
+// Set by the app layer during initialization via RegisterSceneRecorder.
+var sceneRecorderFactory SceneRecorder
+
+// RegisterSceneRecorder registers the factory function for creating scene
+// recording canvases. This must be called by the app layer before any
+// boundary draws occur (typically in package init or Window creation).
+//
+// Example (from app package):
+//
+// widget.RegisterSceneRecorder(func(s *scene.Scene, w, h int) (widget.Canvas, func()) {
+// recorder := render.NewSceneCanvas(s, w, h)
+// return recorder, recorder.Close
+// })
+func RegisterSceneRecorder(factory SceneRecorder) {
+ sceneRecorderFactory = factory
+}
+
+// GetSceneRecorderFactory returns the registered SceneRecorder factory.
+// Returns nil if no factory has been registered.
+func GetSceneRecorderFactory() SceneRecorder {
+ return sceneRecorderFactory
+}
+
+// boundaryWidget is the interface that widgets with WidgetBase boundary
+// support must satisfy. All methods are provided by WidgetBase embedding.
+type boundaryWidget interface {
+ Widget
+ IsRepaintBoundary() bool
+ IsSceneDirty() bool
+ CachedScene() *scene.Scene
+ SetCachedScene(*scene.Scene)
+ ClearSceneDirty()
+ SceneCacheSize() (int, int)
+ SetSceneCacheSize(int, int)
+ SetOnBoundaryDirty(func())
+ Bounds() geometry.Rect
+}
+
+// drawBoundaryWidget handles the draw pass for a widget that has
+// isRepaintBoundary == true. It implements the cache-hit/miss logic:
+//
+// - Cache hit (not dirty, scene exists): replay cached scene with damage
+// suppression (clean content hasn't changed, must not inflate damage list).
+// - Cache miss (dirty or first draw): record child Draw into scene, then replay.
+//
+// This is called from drawTreeRecursive when the widget is a boundary.
+// If no SceneRecorder factory is registered, falls back to normal draw.
+func drawBoundaryWidget(w Widget, ctx Context, canvas Canvas, stats *DrawStats) { //nolint:gocyclo,cyclop // boundary recording is inherently complex (cache hit/miss, dirty callback, size change, screen origin, device scale)
+ bw, ok := w.(boundaryWidget)
+ if !ok || sceneRecorderFactory == nil {
+ // Fallback: draw normally without boundary caching.
+ w.Draw(ctx, canvas)
+ return
+ }
+
+ // Wire onBoundaryDirty callback on first Draw so that future
+ // SetNeedsRedraw → propagateDirtyUpward → InvalidateScene
+ // triggers RequestRedraw on the window. Without this, dirty flags
+ // are set but the render loop never wakes up.
+ if bw.CachedScene() == nil && bw.IsSceneDirty() && ctx != nil {
+ capturedBW := bw
+ bw.SetOnBoundaryDirty(func() {
+ ctx.InvalidateRect(capturedBW.Bounds())
+ })
+ }
+
+ // Get widget dimensions from bounds.
+ bounds := bw.Bounds()
+ width := int(bounds.Width())
+ height := int(bounds.Height())
+
+ if width <= 0 || height <= 0 {
+ // Widget has not been laid out yet (zero bounds). Fall back to normal
+ // draw without scene caching. This happens when DrawTo is called before
+ // layout (e.g., in tests or host-managed mode before first Frame).
+ w.Draw(ctx, canvas)
+ return
+ }
+
+ // Check for size change — invalidate cache if dimensions changed.
+ cw, ch := bw.SceneCacheSize()
+ if cw != width || ch != height {
+ bw.SetSceneCacheSize(width, height)
+ bw.SetCachedScene(nil) // Force re-record on size change.
+ }
+
+ // Cache hit: boundary is clean and we have a cached scene.
+ if !bw.IsSceneDirty() && bw.CachedScene() != nil {
+ if stats != nil {
+ stats.CachedWidgets++
+ }
+ // Stamp screen origin even on cache hit so dirty.Collector gets
+ // correct screen positions. Draw is NOT called on cache hit,
+ // so StampScreenOrigin inside Draw never runs.
+ StampScreenOrigin(w, canvas)
+ canvas.PushTransform(bounds.Min)
+ if dc, ok2 := canvas.(DamageController); ok2 {
+ dc.SetDamageTracking(false)
+ }
+ canvas.ReplayScene(bw.CachedScene())
+ if dc, ok2 := canvas.(DamageController); ok2 {
+ dc.SetDamageTracking(true)
+ }
+ canvas.PopTransform()
+
+ // Flutter compositeFrame: even when parent is cache-hit, dirty
+ // child boundaries must still be re-recorded and replayed.
+ // Without this, animated children (spinner) inside a clean
+ // parent boundary would freeze after the first frame.
+ visitDirtyChildBoundaries(w, ctx, canvas, stats)
+ return
+ }
+
+ // Cache miss: record child drawing into a scene.
+ cachedScene := bw.CachedScene()
+ if cachedScene == nil {
+ cachedScene = scene.NewScene()
+ }
+ cachedScene.Reset()
+
+ recorder, cleanup := sceneRecorderFactory(cachedScene, width, height)
+ // Set screen-space base offset so StampScreenOrigin inside Draw computes
+ // correct screen positions for dirty.Collector. Without this, PushTransform
+ // (-bounds.Min) shifts TransformOffset to local coords → ScreenOrigin = (0,0)
+ // → overlay shows dirty regions at wrong positions.
+ // Flutter: PaintingContext carries offset from parent for screen-space mapping.
+ screenBase := canvas.TransformOffset().Add(canvas.ScreenOriginBase()).Add(bounds.Min)
+ type screenBaseSetter interface{ SetScreenOriginBase(geometry.Point) }
+ if sbs, ok := recorder.(screenBaseSetter); ok {
+ sbs.SetScreenOriginBase(screenBase)
+ }
+ // Propagate device scale for HiDPI-aware SVG icon rasterization (ADR-026).
+ if ctx != nil {
+ if ds, ok := recorder.(DeviceScaler); ok {
+ ds.SetDeviceScale(ctx.Scale())
+ }
+ }
+ StampScreenOrigin(w, canvas)
+ recorder.PushTransform(geometry.Pt(-bounds.Min.X, -bounds.Min.Y))
+
+ // Clear dirty BEFORE Draw so we can detect re-dirtying during Draw.
+ // Animated widgets (spinner) call SetNeedsRedraw(true) inside Draw
+ // to request the next animation frame. If we cleared AFTER Draw,
+ // we'd erase that request → animation freezes at 1fps.
+ // Flutter: PaintingContext clears _needsPaint BEFORE calling paint().
+ bw.ClearSceneDirty()
+ ClearRedrawInTree(w)
+
+ // Suppress boundary dirty callback during Draw recording (see ADR-007
+ // AnimationScheduler). Animated widgets defer render via ScheduleAnimationFrame.
+ type dirtySuppressor interface{ SetSuppressDirtyCallback(bool) }
+ if ds, ok := w.(dirtySuppressor); ok {
+ ds.SetSuppressDirtyCallback(true)
+ }
+ w.Draw(ctx, recorder)
+ if ds, ok := w.(dirtySuppressor); ok {
+ ds.SetSuppressDirtyCallback(false)
+ }
+ recorder.PopTransform()
+ cleanup()
+
+ // Store the freshly recorded scene.
+ bw.SetCachedScene(cachedScene)
+
+ // Replay the freshly recorded scene into the parent canvas.
+ canvas.PushTransform(bounds.Min)
+ canvas.ReplayScene(cachedScene)
+ canvas.PopTransform()
+}
+
+// visitDirtyChildBoundaries walks the subtree looking for child boundaries
+// that are dirty and need re-recording. This is called after a parent
+// boundary cache-hit to ensure animated children (spinner) still update.
+//
+// Flutter compositeFrame walks the layer tree; clean layers use addRetained
+// while dirty layers re-compose. This function provides the same guarantee
+// at the DrawTree level: clean parent + dirty child = child still draws.
+func visitDirtyChildBoundaries(w Widget, ctx Context, canvas Canvas, stats *DrawStats) {
+ for _, child := range w.Children() {
+ if child == nil {
+ continue
+ }
+
+ if bw, ok := child.(boundaryWidget); ok && bw.IsRepaintBoundary() {
+ if bw.IsSceneDirty() || bw.CachedScene() == nil {
+ drawBoundaryWidget(child, ctx, canvas, stats)
+ } else {
+ // Child boundary is clean — still check ITS children
+ // for deeper dirty boundaries.
+ visitDirtyChildBoundaries(child, ctx, canvas, stats)
+ }
+ continue
+ }
+
+ // Non-boundary child: recurse looking for boundaries deeper.
+ visitDirtyChildBoundaries(child, ctx, canvas, stats)
+ }
+}
diff --git a/widget/boundary_test.go b/widget/boundary_test.go
new file mode 100644
index 0000000..6a0d5b7
--- /dev/null
+++ b/widget/boundary_test.go
@@ -0,0 +1,326 @@
+package widget
+
+import (
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+)
+
+func TestWidgetBase_SetRepaintBoundary(t *testing.T) {
+ w := NewWidgetBase()
+
+ if w.IsRepaintBoundary() {
+ t.Error("expected IsRepaintBoundary=false by default")
+ }
+
+ w.SetRepaintBoundary(true)
+ if !w.IsRepaintBoundary() {
+ t.Error("expected IsRepaintBoundary=true after SetRepaintBoundary(true)")
+ }
+ if w.BoundaryCacheKey() == 0 {
+ t.Error("expected non-zero cache key after enabling boundary")
+ }
+ if !w.IsSceneDirty() {
+ t.Error("expected sceneDirty=true after enabling boundary (first draw needs record)")
+ }
+
+ // Disable boundary.
+ w.SetRepaintBoundary(false)
+ if w.IsRepaintBoundary() {
+ t.Error("expected IsRepaintBoundary=false after SetRepaintBoundary(false)")
+ }
+ if w.CachedScene() != nil {
+ t.Error("expected cached scene to be nil after disabling boundary")
+ }
+}
+
+func TestWidgetBase_SetRepaintBoundary_Idempotent(t *testing.T) {
+ w := NewWidgetBase()
+ w.SetRepaintBoundary(true)
+ key1 := w.BoundaryCacheKey()
+
+ // Setting again should not change the key.
+ w.SetRepaintBoundary(true)
+ key2 := w.BoundaryCacheKey()
+
+ if key1 != key2 {
+ t.Errorf("cache key changed on repeated SetRepaintBoundary: %d != %d", key1, key2)
+ }
+}
+
+func TestWidgetBase_InvalidateScene(t *testing.T) {
+ w := NewWidgetBase()
+
+ // No-op when not a boundary.
+ w.InvalidateScene()
+ if w.IsSceneDirty() {
+ t.Error("InvalidateScene should be no-op when not a boundary")
+ }
+
+ // Enable boundary, clear dirty, then invalidate.
+ w.SetRepaintBoundary(true)
+ w.ClearSceneDirty() // Simulate successful record.
+ if w.IsSceneDirty() {
+ t.Error("expected sceneDirty=false after ClearSceneDirty")
+ }
+
+ w.InvalidateScene()
+ if !w.IsSceneDirty() {
+ t.Error("expected sceneDirty=true after InvalidateScene")
+ }
+}
+
+func TestWidgetBase_InvalidateScene_Callback(t *testing.T) {
+ w := NewWidgetBase()
+ w.SetRepaintBoundary(true)
+ w.ClearSceneDirty()
+
+ callbackCalled := false
+ w.SetOnBoundaryDirty(func() {
+ callbackCalled = true
+ })
+
+ w.InvalidateScene()
+ if !callbackCalled {
+ t.Error("onBoundaryDirty callback not called on InvalidateScene")
+ }
+
+ // Second call should be no-op (already dirty).
+ callbackCalled = false
+ w.InvalidateScene()
+ if callbackCalled {
+ t.Error("onBoundaryDirty callback should not be called when already dirty")
+ }
+}
+
+func TestWidgetBase_SceneCacheSize(t *testing.T) {
+ w := NewWidgetBase()
+ w.SetRepaintBoundary(true)
+
+ w.SetSceneCacheSize(200, 100)
+ width, height := w.SceneCacheSize()
+ if width != 200 || height != 100 {
+ t.Errorf("SceneCacheSize = (%d, %d), want (200, 100)", width, height)
+ }
+}
+
+func TestWidgetBase_CachedScene(t *testing.T) {
+ w := NewWidgetBase()
+ w.SetRepaintBoundary(true)
+
+ if w.CachedScene() != nil {
+ t.Error("expected nil cached scene initially")
+ }
+
+ sc := scene.NewScene()
+ w.SetCachedScene(sc)
+ if w.CachedScene() != sc {
+ t.Error("expected same scene reference after SetCachedScene")
+ }
+
+ // Disable boundary releases scene.
+ w.SetRepaintBoundary(false)
+ if w.CachedScene() != nil {
+ t.Error("expected nil cached scene after disabling boundary")
+ }
+}
+
+func TestWidgetBase_SceneCacheVersion(t *testing.T) {
+ w := NewWidgetBase()
+ w.SetRepaintBoundary(true)
+
+ v0 := w.SceneCacheVersion()
+ w.ClearSceneDirty()
+ v1 := w.SceneCacheVersion()
+
+ if v1 <= v0 {
+ t.Errorf("SceneCacheVersion should increment on ClearSceneDirty: %d <= %d", v1, v0)
+ }
+}
+
+func TestPropagateDirtyUpward_BoundaryProperty(t *testing.T) {
+ // Create a tree: parent (boundary) → child.
+ parent := &boundaryTestWidget{}
+ parent.SetVisible(true)
+ parent.SetEnabled(true)
+ parent.SetRepaintBoundary(true)
+ parent.ClearSceneDirty() // Simulate previous successful draw.
+
+ child := &boundaryTestWidget{}
+ child.SetVisible(true)
+ child.SetEnabled(true)
+ child.SetParent(parent)
+
+ // Marking child dirty should propagate to parent boundary.
+ child.SetNeedsRedraw(true)
+
+ if !parent.IsSceneDirty() {
+ t.Error("parent boundary should be dirty after child SetNeedsRedraw")
+ }
+}
+
+func TestPropagateDirtyUpward_LegacyBoundary(t *testing.T) {
+ // Create a mock that implements RepaintBoundaryMarker (legacy pattern).
+ boundary := &legacyBoundaryTestWidget{}
+ boundary.SetVisible(true)
+ boundary.SetEnabled(true)
+
+ child := &boundaryTestWidget{}
+ child.SetVisible(true)
+ child.SetEnabled(true)
+ child.SetParent(boundary)
+
+ // Marking child dirty should call legacy MarkBoundaryDirty.
+ child.SetNeedsRedraw(true)
+
+ if !boundary.markedDirty {
+ t.Error("legacy boundary should be marked dirty after child SetNeedsRedraw")
+ }
+}
+
+func TestDrawBoundaryWidget_FallbackOnZeroBounds(t *testing.T) {
+ // Widget with boundary enabled but zero bounds should fall back to normal draw.
+ RegisterSceneRecorder(func(s *scene.Scene, w, h int) (Canvas, func()) {
+ return &noopCanvas{}, func() {}
+ })
+ defer RegisterSceneRecorder(nil)
+
+ root := &drawCountWidget{}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetRepaintBoundary(true)
+ // Don't set bounds — they're zero.
+
+ ctx := NewContext()
+ canvas := &noopCanvas{}
+
+ drawBoundaryWidget(root, ctx, canvas, nil)
+
+ if root.drawCount != 1 {
+ t.Errorf("drawCount = %d, want 1 (fallback on zero bounds)", root.drawCount)
+ }
+}
+
+func TestDrawBoundaryWidget_CacheHit(t *testing.T) {
+ RegisterSceneRecorder(func(s *scene.Scene, w, h int) (Canvas, func()) {
+ return &noopCanvas{}, func() {}
+ })
+ defer RegisterSceneRecorder(nil)
+
+ root := &drawCountWidget{}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetBounds(geometry.NewRect(0, 0, 100, 50))
+ root.SetRepaintBoundary(true)
+
+ ctx := NewContext()
+ canvas := &noopCanvas{}
+
+ // First draw: cache miss (sceneDirty=true).
+ var stats DrawStats
+ drawBoundaryWidget(root, ctx, canvas, &stats)
+ if root.drawCount != 1 {
+ t.Errorf("first draw: drawCount = %d, want 1", root.drawCount)
+ }
+ if stats.CachedWidgets != 0 {
+ t.Errorf("first draw: CachedWidgets = %d, want 0", stats.CachedWidgets)
+ }
+
+ // Second draw: cache hit (sceneDirty=false, scene exists).
+ stats = DrawStats{}
+ drawBoundaryWidget(root, ctx, canvas, &stats)
+ if root.drawCount != 1 {
+ t.Errorf("second draw: drawCount = %d, want 1 (cache hit)", root.drawCount)
+ }
+ if stats.CachedWidgets != 1 {
+ t.Errorf("second draw: CachedWidgets = %d, want 1", stats.CachedWidgets)
+ }
+}
+
+func TestDrawBoundaryWidget_CacheInvalidation(t *testing.T) {
+ RegisterSceneRecorder(func(s *scene.Scene, w, h int) (Canvas, func()) {
+ return &noopCanvas{}, func() {}
+ })
+ defer RegisterSceneRecorder(nil)
+
+ root := &drawCountWidget{}
+ root.SetVisible(true)
+ root.SetEnabled(true)
+ root.SetBounds(geometry.NewRect(0, 0, 100, 50))
+ root.SetRepaintBoundary(true)
+
+ ctx := NewContext()
+ canvas := &noopCanvas{}
+
+ // First draw: cache miss.
+ drawBoundaryWidget(root, ctx, canvas, nil)
+ if root.drawCount != 1 {
+ t.Fatalf("first draw: drawCount = %d, want 1", root.drawCount)
+ }
+
+ // Invalidate scene.
+ root.InvalidateScene()
+
+ // Next draw: cache miss again (invalidated).
+ drawBoundaryWidget(root, ctx, canvas, nil)
+ if root.drawCount != 2 {
+ t.Errorf("after invalidation: drawCount = %d, want 2", root.drawCount)
+ }
+}
+
+// --- test helpers ---
+
+// boundaryTestWidget is a minimal widget for boundary testing.
+type boundaryTestWidget struct {
+ WidgetBase
+}
+
+func (w *boundaryTestWidget) Layout(_ Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(100, 50))
+}
+
+func (w *boundaryTestWidget) Draw(_ Context, _ Canvas) {}
+
+func (w *boundaryTestWidget) Event(_ Context, _ event.Event) bool {
+ return false
+}
+
+// drawCountWidget counts Draw calls for cache hit/miss verification.
+type drawCountWidget struct {
+ WidgetBase
+ drawCount int
+}
+
+func (w *drawCountWidget) Layout(_ Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(100, 50))
+}
+
+func (w *drawCountWidget) Draw(_ Context, _ Canvas) {
+ w.drawCount++
+}
+
+func (w *drawCountWidget) Event(_ Context, _ event.Event) bool {
+ return false
+}
+
+// legacyBoundaryTestWidget implements RepaintBoundaryMarker (legacy pattern).
+type legacyBoundaryTestWidget struct {
+ WidgetBase
+ markedDirty bool
+}
+
+func (w *legacyBoundaryTestWidget) Layout(_ Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(100, 50))
+}
+
+func (w *legacyBoundaryTestWidget) Draw(_ Context, _ Canvas) {}
+
+func (w *legacyBoundaryTestWidget) Event(_ Context, _ event.Event) bool {
+ return false
+}
+
+func (w *legacyBoundaryTestWidget) MarkBoundaryDirty() {
+ w.markedDirty = true
+}
diff --git a/widget/canvas.go b/widget/canvas.go
index 2ec7bd2..a2a381d 100644
--- a/widget/canvas.go
+++ b/widget/canvas.go
@@ -168,13 +168,20 @@ type Canvas interface {
// TransformOffset returns the current cumulative transform offset.
//
// This is the total translation applied by all PushTransform calls
- // currently on the transform stack. It represents the mapping from
- // local widget coordinates to window (screen) coordinates.
- //
- // Used by [StampScreenOrigin] to compute a widget's screen-space
- // position during the Draw pass.
+ // currently on the transform stack.
TransformOffset() geometry.Point
+ // ScreenOriginBase returns the screen-space base offset for this canvas.
+ //
+ // For the main window canvas this is (0,0). For SceneCanvas inside
+ // RepaintBoundary recording, this is the boundary widget's screen position.
+ // Without this, PushTransform(-bounds.Min) for local coords makes
+ // TransformOffset() return local values, and StampScreenOrigin computes
+ // wrong ScreenOrigin → dirty.Collector reports regions at (0,0).
+ //
+ // Flutter equivalent: PaintingContext.offset in RenderObject.paint().
+ ScreenOriginBase() geometry.Point
+
// ClipBounds returns the current clip rectangle.
//
// This is the intersection of all PushClip/PushClipRoundRect regions
@@ -196,6 +203,24 @@ type Canvas interface {
ReplayScene(s *scene.Scene)
}
+// DamageController can suppress damage tracking during rendering.
+// Implemented by render.Canvas for retained-mode optimization.
+// RepaintBoundary uses this to suppress damage during cached scene replay.
+type DamageController interface {
+ SetDamageTracking(enabled bool)
+}
+
+// BoundaryRecorder is implemented by canvases that record into a boundary's
+// scene.Scene. When DrawChild encounters a child that IS a boundary, it skips
+// drawing — the child boundary has its own PictureLayer in the compositor.
+//
+// Flutter equivalent: PaintingContext knows it's recording into a boundary's
+// PictureRecorder. paintChild checks isRepaintBoundary → appendLayer instead
+// of painting into the current recorder.
+type BoundaryRecorder interface {
+ IsBoundaryRecording() bool
+}
+
// LineCap specifies how the endpoints of stroked arcs and lines are drawn.
type LineCap uint8
@@ -227,6 +252,75 @@ type SVGRenderer interface {
RenderSVG(svgXML []byte, bounds geometry.Rect, color Color)
}
+// DeviceScaler is an optional interface for canvases that support DPI-aware
+// SVG icon rasterization (ADR-026). When a canvas implements DeviceScaler,
+// the boundary recording infrastructure sets the display scale factor so
+// that SVG icons are rasterized at physical pixel size (ceil(logical * scale))
+// and drawn with an inverse-scale transform for crisp HiDPI rendering.
+//
+// Use type assertion to set the scale after creating a recording canvas:
+//
+// if ds, ok := recorder.(widget.DeviceScaler); ok {
+// ds.SetDeviceScale(ctx.Scale())
+// }
+//
+// This follows the Qt6/Chromium/IntelliJ pattern where icon assets are
+// rendered at the device's native resolution rather than logical pixel size.
+type DeviceScaler interface {
+ SetDeviceScale(scale float32)
+}
+
+// TextMode controls text rendering strategy selection.
+//
+// Maps 1:1 to gg.TextMode. The default TextModeAuto selects the best strategy
+// automatically based on GPU availability, transform, and font size.
+type TextMode int
+
+const (
+ TextModeAuto TextMode = iota // auto-select based on context
+ TextModeMSDF // GPU MSDF atlas (games, animations)
+ TextModeVector // vector outlines (quality-critical)
+ TextModeBitmap // CPU bitmap (export, static)
+ TextModeGlyphMask // GPU glyph mask (UI labels, <48px)
+)
+
+// textModeNames maps each TextMode to its human-readable name.
+var textModeNames = [...]string{
+ TextModeAuto: "Auto",
+ TextModeMSDF: "MSDF",
+ TextModeVector: "Vector",
+ TextModeBitmap: "Bitmap",
+ TextModeGlyphMask: "GlyphMask",
+}
+
+// String returns the text mode name.
+func (m TextMode) String() string {
+ if int(m) >= 0 && int(m) < len(textModeNames) {
+ return textModeNames[m]
+ }
+ return unknownStr
+}
+
+// TextModeController is an optional interface for canvases that support
+// explicit text rendering mode control.
+//
+// Use type assertion to check availability:
+//
+// if tc, ok := canvas.(widget.TextModeController); ok {
+// tc.SetTextMode(widget.TextModeMSDF)
+// defer tc.SetTextMode(widget.TextModeAuto)
+// }
+//
+// This is primarily useful during zoom/scale operations where the default
+// auto-selection may cause atlas pressure (issue #94).
+//
+// On SceneCanvas (RepaintBoundary recording), SetTextMode is a no-op because
+// scene text uses TagText which handles mode selection at replay time.
+type TextModeController interface {
+ SetTextMode(mode TextMode)
+ TextMode() TextMode
+}
+
// Color represents an RGBA color with float32 components.
//
// Each component is in the range [0, 1], where 0 is minimum intensity
diff --git a/widget/canvas_test.go b/widget/canvas_test.go
index 04e17c8..aa7667f 100644
--- a/widget/canvas_test.go
+++ b/widget/canvas_test.go
@@ -403,3 +403,41 @@ func BenchmarkRGBA8_Constructor(b *testing.B) {
_ = RGBA8(128, 64, 192, 255)
}
}
+
+func TestTextMode_String(t *testing.T) {
+ tests := []struct {
+ mode TextMode
+ want string
+ }{
+ {TextModeAuto, "Auto"},
+ {TextModeMSDF, "MSDF"},
+ {TextModeVector, "Vector"},
+ {TextModeBitmap, "Bitmap"},
+ {TextModeGlyphMask, "GlyphMask"},
+ {TextMode(99), "Unknown"},
+ {TextMode(-1), "Unknown"},
+ }
+ for _, tt := range tests {
+ if got := tt.mode.String(); got != tt.want {
+ t.Errorf("TextMode(%d).String() = %q, want %q", tt.mode, got, tt.want)
+ }
+ }
+}
+
+func TestTextMode_Values(t *testing.T) {
+ if TextModeAuto != 0 {
+ t.Error("TextModeAuto should be 0 (iota)")
+ }
+ if TextModeMSDF != 1 {
+ t.Error("TextModeMSDF should be 1")
+ }
+ if TextModeVector != 2 {
+ t.Error("TextModeVector should be 2")
+ }
+ if TextModeBitmap != 3 {
+ t.Error("TextModeBitmap should be 3")
+ }
+ if TextModeGlyphMask != 4 {
+ t.Error("TextModeGlyphMask should be 4")
+ }
+}
diff --git a/widget/compositor_clip_test.go b/widget/compositor_clip_test.go
new file mode 100644
index 0000000..e0e09bc
--- /dev/null
+++ b/widget/compositor_clip_test.go
@@ -0,0 +1,202 @@
+package widget_test
+
+import (
+ "image"
+ "testing"
+
+ "github.com/gogpu/gg/scene"
+ "github.com/gogpu/ui/event"
+ "github.com/gogpu/ui/geometry"
+ "github.com/gogpu/ui/widget"
+)
+
+// TestCompositorClip_SetGet verifies that CompositorClip can be set and
+// retrieved on WidgetBase.
+func TestCompositorClip_SetGet(t *testing.T) {
+ w := widget.NewWidgetBase()
+
+ if w.HasCompositorClip() {
+ t.Error("HasCompositorClip should be false initially")
+ }
+
+ clip := geometry.NewRect(10, 20, 200, 300)
+ w.SetCompositorClip(clip)
+
+ if !w.HasCompositorClip() {
+ t.Error("HasCompositorClip should be true after SetCompositorClip")
+ }
+ if got := w.CompositorClip(); got != clip {
+ t.Errorf("CompositorClip = %v, want %v", got, clip)
+ }
+}
+
+// TestCompositorClip_ClearCompositorClip verifies the clip can be cleared.
+func TestCompositorClip_ClearCompositorClip(t *testing.T) {
+ w := widget.NewWidgetBase()
+
+ w.SetCompositorClip(geometry.NewRect(0, 0, 100, 100))
+ if !w.HasCompositorClip() {
+ t.Fatal("should have clip after set")
+ }
+
+ w.ClearCompositorClip()
+ if w.HasCompositorClip() {
+ t.Error("should not have clip after clear")
+ }
+}
+
+// TestCompositorClip_ScreenRectIntersectsClip verifies that ScreenBounds
+// can be compared with CompositorClip to determine visibility.
+func TestCompositorClip_ScreenRectIntersectsClip(t *testing.T) {
+ tests := []struct {
+ name string
+ screenPos geometry.Point
+ itemW, itemH float32
+ clip geometry.Rect
+ wantIntersects bool
+ }{
+ {
+ name: "fully inside clip",
+ screenPos: geometry.Pt(20, 220),
+ itemW: 200, itemH: 40,
+ clip: geometry.NewRect(0, 200, 400, 300), // y: 200→500
+ wantIntersects: true,
+ },
+ {
+ name: "fully above clip",
+ screenPos: geometry.Pt(20, 100),
+ itemW: 200, itemH: 40,
+ clip: geometry.NewRect(0, 200, 400, 300), // y: 200→500
+ wantIntersects: false,
+ },
+ {
+ name: "fully below clip",
+ screenPos: geometry.Pt(20, 510),
+ itemW: 200, itemH: 40,
+ clip: geometry.NewRect(0, 200, 400, 300), // y: 200→500
+ wantIntersects: false,
+ },
+ {
+ name: "partially visible top",
+ screenPos: geometry.Pt(20, 180),
+ itemW: 200, itemH: 40,
+ clip: geometry.NewRect(0, 200, 400, 300), // y: 200→500
+ wantIntersects: true, // item y:180-220, clip y:200-500 → overlap
+ },
+ {
+ name: "partially visible bottom",
+ screenPos: geometry.Pt(20, 480),
+ itemW: 200, itemH: 40,
+ clip: geometry.NewRect(0, 200, 400, 300), // y: 200→500
+ wantIntersects: true, // item y:480-520, clip y:200-500 → overlap
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := widget.NewWidgetBase()
+ w.SetBounds(geometry.NewRect(0, 0, tt.itemW, tt.itemH))
+ w.SetScreenOrigin(tt.screenPos)
+ w.SetCompositorClip(tt.clip)
+
+ screenRect := w.ScreenBounds()
+ intersects := screenRect.Intersects(tt.clip)
+
+ if intersects != tt.wantIntersects {
+ t.Errorf("intersects=%v, want %v (screen=%v, clip=%v)",
+ intersects, tt.wantIntersects, screenRect, tt.clip)
+ }
+ })
+ }
+}
+
+// TestDrawChild_StampsCompositorClip verifies that DrawChild stamps
+// the compositor clip from the canvas onto skipped boundary children.
+func TestDrawChild_StampsCompositorClip(t *testing.T) {
+ child := &clipTestWidget{}
+ child.SetVisible(true)
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(0, 100, 200, 148))
+
+ viewportClip := geometry.NewRect(0, 200, 400, 600)
+ canvas := &clipStampCanvas{
+ clipBounds: viewportClip,
+ transformOffset: geometry.Pt(10, 50),
+ screenOriginBase: geometry.Pt(0, 0),
+ isBoundary: true,
+ }
+
+ widget.DrawChild(child, nil, canvas)
+
+ if !child.HasCompositorClip() {
+ t.Fatal("DrawChild should stamp CompositorClip on skipped boundary child")
+ }
+
+ got := child.CompositorClip()
+ // Screen-space clip = canvas ClipBounds (which is already in recording coords)
+ // shifted by screenOriginBase. ClipBounds returns a Rect with Min/Max,
+ // so we build the expected using the same Min/Max shift.
+ base := canvas.screenOriginBase
+ wantClip := geometry.Rect{
+ Min: geometry.Pt(viewportClip.Min.X+base.X, viewportClip.Min.Y+base.Y),
+ Max: geometry.Pt(viewportClip.Max.X+base.X, viewportClip.Max.Y+base.Y),
+ }
+ if got != wantClip {
+ t.Errorf("CompositorClip = %v, want %v", got, wantClip)
+ }
+}
+
+// clipTestWidget is a minimal boundary widget for clip tests.
+type clipTestWidget struct {
+ widget.WidgetBase
+}
+
+func (w *clipTestWidget) Layout(_ widget.Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(200, 48))
+}
+
+func (w *clipTestWidget) Draw(_ widget.Context, _ widget.Canvas) {}
+
+func (w *clipTestWidget) Event(_ widget.Context, _ event.Event) bool { return false }
+
+func (w *clipTestWidget) Children() []widget.Widget { return nil }
+
+// clipStampCanvas simulates BoundaryRecording with a specific clip rect.
+type clipStampCanvas struct {
+ clipBounds geometry.Rect
+ transformOffset geometry.Point
+ screenOriginBase geometry.Point
+ isBoundary bool
+}
+
+// --- Canvas interface ---
+func (c *clipStampCanvas) Clear(widget.Color) {}
+func (c *clipStampCanvas) DrawRect(geometry.Rect, widget.Color) {}
+func (c *clipStampCanvas) FillRectDirect(geometry.Rect, widget.Color) {}
+func (c *clipStampCanvas) StrokeRect(geometry.Rect, widget.Color, float32) {}
+func (c *clipStampCanvas) DrawRoundRect(geometry.Rect, widget.Color, float32) {}
+func (c *clipStampCanvas) StrokeRoundRect(geometry.Rect, widget.Color, float32, float32) {}
+func (c *clipStampCanvas) DrawCircle(geometry.Point, float32, widget.Color) {}
+func (c *clipStampCanvas) StrokeCircle(geometry.Point, float32, widget.Color, float32) {}
+func (c *clipStampCanvas) StrokeArc(geometry.Point, float32, float64, float64, widget.Color, float32) {
+}
+func (c *clipStampCanvas) DrawLine(geometry.Point, geometry.Point, widget.Color, float32) {}
+func (c *clipStampCanvas) DrawText(string, geometry.Rect, float32, widget.Color, bool, widget.TextAlign) {
+}
+func (c *clipStampCanvas) MeasureText(string, float32, bool) float32 { return 0 }
+func (c *clipStampCanvas) DrawImage(image.Image, geometry.Point) {}
+func (c *clipStampCanvas) PushClip(geometry.Rect) {}
+func (c *clipStampCanvas) PushClipRoundRect(geometry.Rect, float32) {}
+func (c *clipStampCanvas) PopClip() {}
+func (c *clipStampCanvas) PushTransform(geometry.Point) {}
+func (c *clipStampCanvas) PopTransform() {}
+func (c *clipStampCanvas) TransformOffset() geometry.Point { return c.transformOffset }
+func (c *clipStampCanvas) ScreenOriginBase() geometry.Point { return c.screenOriginBase }
+func (c *clipStampCanvas) ClipBounds() geometry.Rect { return c.clipBounds }
+func (c *clipStampCanvas) ReplayScene(*scene.Scene) {}
+
+// --- BoundaryRecorder interface ---
+func (c *clipStampCanvas) IsBoundaryRecording() bool { return c.isBoundary }
+
+var _ widget.Canvas = (*clipStampCanvas)(nil)
+var _ widget.BoundaryRecorder = (*clipStampCanvas)(nil)
diff --git a/widget/context.go b/widget/context.go
index 4c36551..2bd064a 100644
--- a/widget/context.go
+++ b/widget/context.go
@@ -333,6 +333,9 @@ type ContextImpl struct {
// Callback for invalidate rect (called when InvalidateRect is called)
onInvalidateRect func(geometry.Rect)
+ // Callback for animation frame scheduling (deferred, not immediate)
+ onScheduleAnimation func()
+
// Overlay manager
overlayManager OverlayManager
@@ -612,6 +615,55 @@ func (c *ContextImpl) SetOnInvalidateRect(callback func(geometry.Rect)) {
c.onInvalidateRect = callback
}
+// AnimationScheduler is an optional interface for deferred animation frame
+// requests. Animated widgets (spinners, progress bars) use this instead of
+// ctx.InvalidateRect() to avoid triggering immediate RequestRedraw.
+//
+// The framework's animation pumper controls the actual frame rate —
+// animated widgets just request "paint me on the next animation tick",
+// not "paint me RIGHT NOW".
+//
+// Flutter equivalent: SchedulerBinding.scheduleFrame() — defers to next
+// vsync, does NOT trigger immediate render. Multiple calls coalesce.
+// Qt equivalent: QTimer-driven update() — deferred to event loop.
+// Android equivalent: Choreographer.postFrameCallback() — next vsync.
+//
+// Usage in animated widgets:
+//
+// if sched, ok := ctx.(widget.AnimationScheduler); ok {
+// sched.ScheduleAnimationFrame()
+// } else {
+// ctx.InvalidateRect(w.Bounds()) // fallback: immediate
+// }
+type AnimationScheduler interface {
+ ScheduleAnimationFrame()
+}
+
+// ScheduleAnimationFrame requests that the render loop stay active for
+// animation. Unlike InvalidateRect, this does NOT trigger an immediate
+// RequestRedraw — it ensures the animation pumper keeps ticking at its
+// configured rate (default 30fps). The next pump tick will render any
+// dirty boundaries.
+func (c *ContextImpl) ScheduleAnimationFrame() {
+ c.mu.RLock()
+ cb := c.onScheduleAnimation
+ c.mu.RUnlock()
+ if cb != nil {
+ cb()
+ return
+ }
+ // Fallback: no animation scheduler wired → use immediate InvalidateRect.
+ // This happens in headless tests and legacy contexts without Window.
+ c.InvalidateRect(geometry.Rect{})
+}
+
+// SetOnScheduleAnimation sets the callback for ScheduleAnimationFrame.
+func (c *ContextImpl) SetOnScheduleAnimation(callback func()) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.onScheduleAnimation = callback
+}
+
// OverlayManager returns the overlay manager, or nil if none is set.
func (c *ContextImpl) OverlayManager() OverlayManager {
c.mu.RLock()
diff --git a/widget/draw.go b/widget/draw.go
index e49e11f..7ada59b 100644
--- a/widget/draw.go
+++ b/widget/draw.go
@@ -90,12 +90,49 @@ func DrawTree(w Widget, ctx Context, canvas Canvas) DrawStats {
return stats
}
+// DrawChild draws a child widget with RepaintBoundary support.
+//
+// Container widgets (BoxWidget, VBox, ListView) should call this instead
+// of child.Draw() directly. If the child has IsRepaintBoundary=true
+// (ADR-024 WidgetBase property), drawing is routed through scene caching
+// via drawBoundaryWidget. Otherwise, child.Draw() is called directly.
+//
+// This is the Flutter PaintingContext.paintChild pattern: the parent
+// checks child.isRepaintBoundary before painting.
+func DrawChild(child Widget, ctx Context, canvas Canvas) {
+ if child == nil {
+ return
+ }
+
+ type boundaryChecker interface {
+ IsRepaintBoundary() bool
+ }
+ if bc, ok := child.(boundaryChecker); ok && bc.IsRepaintBoundary() {
+ // Flutter paintChild: skip child boundaries during parent recording.
+ // Each child boundary has own scene + offscreen texture (depth > 1).
+ // Compositor blits all textures. StampScreenOrigin already called
+ // by container before DrawChild for hitTest correctness.
+ if br, ok2 := canvas.(BoundaryRecorder); ok2 && br.IsBoundaryRecording() {
+ StampScreenOrigin(child, canvas)
+ stampCompositorClip(child, canvas)
+ return
+ }
+ drawBoundaryWidget(child, ctx, canvas, nil)
+ return
+ }
+
+ child.Draw(ctx, canvas)
+}
+
// drawTreeRecursive draws the root widget and collects dirty/clean statistics.
//
// It does NOT recurse into children because Widget.Draw() is responsible for
// drawing its own children (e.g., BoxWidget.Draw draws all children internally).
// If we recursed, children would be drawn twice. Statistics for the full tree
// should be collected separately via [CollectDrawStats].
+//
+// ADR-024: If the widget has isRepaintBoundary == true (WidgetBase property),
+// the draw is handled by drawBoundaryWidget which implements scene caching.
func drawTreeRecursive(w Widget, ctx Context, canvas Canvas, stats *DrawStats) {
if w == nil {
return
@@ -122,10 +159,19 @@ func drawTreeRecursive(w Widget, ctx Context, canvas Canvas, stats *DrawStats) {
// Container widgets stamp their children in their own Draw methods.
StampScreenOrigin(w, canvas)
- // Draw the widget. In Sub-Phase 1, all widgets are drawn because gg
- // clears the pixmap each frame. The widget's own Draw method handles
- // visibility checks and child drawing. Sub-Phase 2 will add pixel
- // caching for clean subtrees.
+ // ADR-024: Check if this widget is a WidgetBase-based repaint boundary.
+ // If so, route through drawBoundaryWidget for scene caching.
+ type boundaryChecker interface {
+ IsRepaintBoundary() bool
+ }
+ if bc, ok := w.(boundaryChecker); ok && bc.IsRepaintBoundary() {
+ drawBoundaryWidget(w, ctx, canvas, stats)
+ stats.DrawnWidgets++
+ return
+ }
+
+ // Normal draw path: the widget's own Draw method handles visibility
+ // checks and child drawing.
w.Draw(ctx, canvas)
stats.DrawnWidgets++
}
diff --git a/widget/draw_benchmark_test.go b/widget/draw_benchmark_test.go
new file mode 100644
index 0000000..11adef7
--- /dev/null
+++ b/widget/draw_benchmark_test.go
@@ -0,0 +1,72 @@
+package widget
+
+import (
+ "testing"
+
+ "github.com/gogpu/ui/geometry"
+)
+
+// BenchmarkDrawTree_FlatTree measures draw tree traversal for a flat widget tree
+// (typical hello example: ~20 widgets).
+func BenchmarkDrawTree_FlatTree(b *testing.B) {
+ root := newDrawTrackingWidget()
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ for i := 0; i < 20; i++ {
+ child := newDrawTrackingWidget()
+ child.SetBounds(geometry.NewRect(0, float32(i*30), 800, 30))
+ root.AddChild(child)
+ }
+
+ canvas := &noopCanvas{}
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ root.SetNeedsRedraw(true)
+ DrawTree(root, nil, canvas)
+ }
+}
+
+// BenchmarkDrawTree_DeepTree measures draw tree traversal for a deep widget tree
+// (typical gallery: ~100+ widgets in nested boxes).
+func BenchmarkDrawTree_DeepTree(b *testing.B) {
+ root := newDrawTrackingWidget()
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ current := root
+ for i := 0; i < 10; i++ {
+ box := newDrawTrackingWidget()
+ box.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ current.AddChild(box)
+ for j := 0; j < 10; j++ {
+ leaf := newDrawTrackingWidget()
+ leaf.SetBounds(geometry.NewRect(0, float32(j*30), 800, 30))
+ box.AddChild(leaf)
+ }
+ current = box
+ }
+
+ canvas := &noopCanvas{}
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ root.SetNeedsRedraw(true)
+ DrawTree(root, nil, canvas)
+ }
+}
+
+// BenchmarkCollectDirtyStats_LargeTree measures dirty stats collection
+// for a large tree (worst case for collector).
+func BenchmarkCollectDirtyStats_LargeTree(b *testing.B) {
+ root := newDrawTrackingWidget()
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ for i := 0; i < 100; i++ {
+ child := newDrawTrackingWidget()
+ child.SetBounds(geometry.NewRect(0, float32(i*10), 800, 10))
+ if i%3 == 0 {
+ child.SetNeedsRedraw(true)
+ }
+ root.AddChild(child)
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ CollectDrawStats(root)
+ }
+}
diff --git a/widget/draw_test.go b/widget/draw_test.go
index bdf1bef..f686857 100644
--- a/widget/draw_test.go
+++ b/widget/draw_test.go
@@ -374,6 +374,7 @@ func (c *noopCanvas) PopClip() {}
func (c *noopCanvas) PushTransform(geometry.Point) {}
func (c *noopCanvas) PopTransform() {}
func (c *noopCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *noopCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
func (c *noopCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 10000, 10000) }
func (c *noopCanvas) ReplayScene(_ *scene.Scene) {}
@@ -405,6 +406,8 @@ func (c *stampCanvas) TransformOffset() geometry.Point {
return c.currentOffset
}
+func (c *stampCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+
func TestStampScreenOrigin_Basic(t *testing.T) {
canvas := &stampCanvas{}
@@ -487,3 +490,555 @@ func (w *statsCapturingWidget) Draw(ctx Context, _ Canvas) {
func (w *statsCapturingWidget) Event(_ Context, _ event.Event) bool { return false }
var _ Widget = (*statsCapturingWidget)(nil)
+
+// --- ADR-024 DrawChild Tests ---
+//
+// DrawChild is the public API for container widgets to draw children
+// with RepaintBoundary support. It checks IsRepaintBoundary and routes
+// through scene caching (drawBoundaryWidget) or direct Draw.
+// This replaces the primitives.NewRepaintBoundary wrapper pattern.
+
+// TestDrawChild_NormalWidgetCallsDraw verifies that DrawChild calls
+// child.Draw() directly when child is NOT a RepaintBoundary.
+func TestDrawChild_NormalWidgetCallsDraw(t *testing.T) {
+ child := newDrawTrackingWidget()
+ child.SetBounds(geometry.NewRect(0, 0, 100, 50))
+
+ DrawChild(child, nil, nil)
+
+ if !child.drawCalled {
+ t.Error("DrawChild should call child.Draw() for non-boundary widget")
+ }
+}
+
+// TestDrawChild_NilChild verifies DrawChild handles nil gracefully.
+func TestDrawChild_NilChild(t *testing.T) {
+ DrawChild(nil, nil, nil) // must not panic
+}
+
+// TestDrawChild_BoundaryFallsBackWithoutRecorder verifies that DrawChild
+// falls back to direct Draw when no SceneRecorder is registered.
+func TestDrawChild_BoundaryFallsBackWithoutRecorder(t *testing.T) {
+ child := newDrawTrackingWidget()
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(10, 20, 100, 50))
+
+ // Without SceneRecorder registered, drawBoundaryWidget falls back to Draw.
+ DrawChild(child, nil, nil)
+
+ if !child.drawCalled {
+ t.Error("DrawChild should fall back to Draw when no SceneRecorder registered")
+ }
+}
+
+// TestDrawChild_BoundaryChecked verifies that DrawChild detects
+// IsRepaintBoundary and routes differently than normal Draw.
+func TestDrawChild_BoundaryChecked(t *testing.T) {
+ normal := newDrawTrackingWidget()
+ normal.SetBounds(geometry.NewRect(0, 0, 100, 50))
+
+ boundary := newDrawTrackingWidget()
+ boundary.SetRepaintBoundary(true)
+ boundary.SetBounds(geometry.NewRect(0, 0, 100, 50))
+
+ if normal.IsRepaintBoundary() {
+ t.Error("normal widget should not be boundary")
+ }
+ if !boundary.IsRepaintBoundary() {
+ t.Error("boundary widget should be boundary")
+ }
+}
+
+// TestBoundary_MarkRedrawInTreeInvalidatesRootScene verifies that
+// MarkRedrawInTree on the root widget also invalidates its scene when
+// root has IsRepaintBoundary=true. Without this, ctx.Invalidate()
+// sets needsRedraw but root boundary replays stale cached scene.
+func TestBoundary_MarkRedrawInTreeInvalidatesRootScene(t *testing.T) {
+ root := newDrawTrackingWidget()
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ root.ClearRedraw()
+ root.ClearSceneDirty()
+
+ // Simulate what ctx.Invalidate does.
+ MarkRedrawInTree(root)
+
+ if !root.NeedsRedraw() {
+ t.Error("root.NeedsRedraw() should be true after MarkRedrawInTree")
+ }
+ if !root.IsSceneDirty() {
+ t.Error("root.IsSceneDirty() should be true after MarkRedrawInTree; " +
+ "ctx.Invalidate → MarkRedrawInTree must also invalidate boundary scene, " +
+ "otherwise root replays stale cached scene on checkbox/radio clicks")
+ }
+}
+
+// TestBoundary_MarkRedrawInTreeDoesNotInvalidateChildBoundaries verifies
+// that MarkRedrawInTree only invalidates the ROOT boundary (no parent),
+// not recursively all child boundaries. Over-invalidation causes full
+// repaint every frame → cyan overlay covers entire window.
+func TestBoundary_MarkRedrawInTreeInvalidatesAllBoundaries(t *testing.T) {
+ root := newDrawTrackingWidget()
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ child := newDrawTrackingWidget()
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(0, 0, 48, 48))
+ child.SetParent(root)
+ root.AddChild(child)
+
+ root.ClearRedraw()
+ root.ClearSceneDirty()
+ child.ClearRedraw()
+ child.ClearSceneDirty()
+
+ MarkRedrawInTree(root)
+
+ if !root.IsSceneDirty() {
+ t.Error("root boundary should be scene-dirty after MarkRedrawInTree")
+ }
+
+ // MarkRedrawInTree is nuclear (layout/resize). ALL boundaries must
+ // invalidate because widget positions may have changed. SetNeedsRedraw
+ // on a boundary widget calls InvalidateScene() (Flutter markNeedsPaint
+ // self-boundary pattern), so child boundaries correctly become dirty.
+ if !child.IsSceneDirty() {
+ t.Error("child boundary should be scene-dirty after MarkRedrawInTree; " +
+ "nuclear redraw invalidates all boundaries (layout may have moved them)")
+ }
+}
+
+// TestDrawChild_BoundaryAtNonZeroPosition verifies that DrawChild works
+// correctly when the boundary widget has bounds NOT at origin (e.g., Y=200
+// in ScrollView content space). The scene must be recorded in LOCAL coords
+// (0,0-based), not absolute coords — otherwise text is culled by the
+// SceneCanvas clip rect.
+func TestDrawChild_BoundaryAtNonZeroPosition(t *testing.T) {
+ child := newDrawTrackingWidget()
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(0, 200, 400, 48)) // Y=200, not at origin
+
+ // DrawChild should still call Draw (fallback without SceneRecorder).
+ DrawChild(child, nil, nil)
+
+ if !child.drawCalled {
+ t.Error("DrawChild should draw widget even at non-zero position")
+ }
+}
+
+// --- ADR-024 RepaintBoundary Dirty Propagation Tests ---
+//
+// These verify that SetNeedsRedraw propagates upward through the parent chain
+// to the nearest WidgetBase RepaintBoundary, invalidating its scene cache.
+// Without this, the boundary replays stale cached scenes after child changes.
+
+// TestBoundary_SetNeedsRedrawPropagesToWidgetBaseBoundary verifies that
+// a child's SetNeedsRedraw(true) propagates to a WidgetBase parent with
+// isRepaintBoundary=true, calling InvalidateScene.
+func TestBoundary_SetNeedsRedrawPropagesToWidgetBaseBoundary(t *testing.T) {
+ parent := newDrawTrackingWidget()
+ parent.SetRepaintBoundary(true)
+
+ child := newDrawTrackingWidget()
+ child.SetParent(parent)
+
+ // Clear initial dirty state.
+ parent.ClearRedraw()
+ parent.ClearSceneDirty() // Reset scene dirty flag.
+ child.ClearRedraw()
+
+ // Child marks itself dirty.
+ child.SetNeedsRedraw(true)
+
+ // Parent boundary scene must be invalidated.
+ if !parent.IsSceneDirty() {
+ t.Error("parent.IsSceneDirty() = false; SetNeedsRedraw on child should " +
+ "propagate to WidgetBase boundary and call InvalidateScene")
+ }
+}
+
+// TestBoundary_SetNeedsRedrawStopsAtFirstBoundary verifies that dirty
+// propagation stops at the NEAREST RepaintBoundary (O(depth) walk).
+func TestBoundary_SetNeedsRedrawStopsAtFirstBoundary(t *testing.T) {
+ root := newDrawTrackingWidget()
+ root.SetRepaintBoundary(true)
+
+ middle := newDrawTrackingWidget()
+ middle.SetRepaintBoundary(true)
+ middle.SetParent(root)
+
+ child := newDrawTrackingWidget()
+ child.SetParent(middle)
+
+ // Clear all.
+ root.ClearRedraw()
+ root.ClearSceneDirty()
+ middle.ClearRedraw()
+ middle.ClearSceneDirty()
+ child.ClearRedraw()
+
+ child.SetNeedsRedraw(true)
+
+ // Middle boundary should be dirty (nearest).
+ if !middle.IsSceneDirty() {
+ t.Error("middle boundary should be scene-dirty (nearest to child)")
+ }
+
+ // Root boundary should NOT be dirty (propagation stops at middle).
+ if root.IsSceneDirty() {
+ t.Error("root boundary should NOT be scene-dirty (propagation stops at middle)")
+ }
+}
+
+// TestBoundary_MarkRedrawLocalDoesNotPropagate verifies that MarkRedrawLocal
+// sets needsRedraw on the widget but does NOT propagate to parent boundary.
+// This is the bug that caused stale scene cache on scroll (fixed in setScroll).
+func TestBoundary_MarkRedrawLocalDoesNotPropagate(t *testing.T) {
+ parent := newDrawTrackingWidget()
+ parent.SetRepaintBoundary(true)
+
+ child := newDrawTrackingWidget()
+ child.SetParent(parent)
+
+ parent.ClearRedraw()
+ parent.ClearSceneDirty()
+ child.ClearRedraw()
+
+ // MarkRedrawLocal only sets local flag.
+ child.MarkRedrawLocal()
+
+ if !child.NeedsRedraw() {
+ t.Error("child.NeedsRedraw() should be true after MarkRedrawLocal")
+ }
+
+ // Parent boundary must NOT be invalidated.
+ if parent.IsSceneDirty() {
+ t.Error("parent.IsSceneDirty() should be false; MarkRedrawLocal must not propagate")
+ }
+}
+
+// TestBoundary_CacheHitWhenClean verifies that drawBoundaryWidget returns
+// cache hit (replays scene) when boundary is NOT scene-dirty.
+func TestBoundary_CacheHitWhenClean(t *testing.T) {
+ w := newDrawTrackingWidget()
+ w.SetRepaintBoundary(true)
+ w.SetBounds(geometry.NewRect(0, 0, 100, 50))
+
+ // Simulate first draw: create and cache a scene.
+ sc := scene.NewScene()
+ w.SetCachedScene(sc)
+ w.SetSceneCacheSize(100, 50)
+ w.ClearSceneDirty()
+
+ // Now check: boundary is clean + has cached scene = cache hit.
+ if w.IsSceneDirty() {
+ t.Error("boundary should be clean")
+ }
+ if w.CachedScene() == nil {
+ t.Error("cached scene should exist")
+ }
+}
+
+// TestBoundary_CacheMissWhenDirty verifies that drawBoundaryWidget forces
+// re-record when boundary IS scene-dirty.
+func TestBoundary_CacheMissWhenDirty(t *testing.T) {
+ w := newDrawTrackingWidget()
+ w.SetRepaintBoundary(true)
+ w.SetBounds(geometry.NewRect(0, 0, 100, 50))
+
+ // Give it a cached scene.
+ sc := scene.NewScene()
+ w.SetCachedScene(sc)
+ w.SetSceneCacheSize(100, 50)
+
+ // Mark dirty.
+ w.InvalidateScene()
+
+ if !w.IsSceneDirty() {
+ t.Error("boundary should be scene-dirty after InvalidateScene")
+ }
+}
+
+// TestBoundary_SizeChangeInvalidatesCache verifies that a size change
+// forces cache miss even if boundary is not scene-dirty.
+func TestBoundary_SizeChangeInvalidatesCache(t *testing.T) {
+ w := newDrawTrackingWidget()
+ w.SetRepaintBoundary(true)
+ w.SetBounds(geometry.NewRect(0, 0, 100, 50))
+ w.SetSceneCacheSize(100, 50)
+ w.SetCachedScene(scene.NewScene())
+ w.ClearSceneDirty()
+
+ // Change bounds (simulate resize).
+ w.SetBounds(geometry.NewRect(0, 0, 200, 100))
+
+ // drawBoundaryWidget checks cw != width || ch != height.
+ cw, ch := w.SceneCacheSize()
+ bounds := w.Bounds()
+ newW := int(bounds.Width())
+ newH := int(bounds.Height())
+
+ if cw == newW && ch == newH {
+ t.Error("cache size should differ from new bounds, triggering re-record")
+ }
+}
+
+// --- Animation Flow Tests (ADR-007 Phase 4) ---
+//
+// These tests verify that animated widgets (spinner) correctly re-dirty
+// themselves during Draw, and that consecutive frames produce fresh renders.
+// The key invariant: a boundary widget that calls SetNeedsRedraw(true)
+// inside Draw MUST remain dirty after drawBoundaryWidget completes.
+
+// animatingWidget simulates a spinner: calls SetNeedsRedraw(true) during Draw.
+type animatingWidget struct {
+ WidgetBase
+ drawCount int
+}
+
+func newAnimatingWidget() *animatingWidget {
+ w := &animatingWidget{}
+ w.SetVisible(true)
+ w.SetEnabled(true)
+ return w
+}
+
+func (w *animatingWidget) Layout(_ Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(48, 48))
+}
+
+func (w *animatingWidget) Draw(ctx Context, canvas Canvas) {
+ w.drawCount++
+ // Spinner pattern: re-dirty self for next frame.
+ w.SetNeedsRedraw(true)
+ if ctx != nil {
+ ctx.InvalidateRect(w.Bounds())
+ }
+}
+
+func (w *animatingWidget) Event(_ Context, _ event.Event) bool { return false }
+func (w *animatingWidget) Children() []Widget { return nil }
+
+// TestAnimatingBoundary_ReDirtiesSelfDuringDraw verifies that an animated
+// boundary widget (spinner) remains dirty after drawBoundaryWidget completes.
+// Without this, spinner freezes after first frame (cache hit forever).
+func TestAnimatingBoundary_ReDirtiesSelfDuringDraw(t *testing.T) {
+ RegisterSceneRecorder(stubSceneRecorder)
+ defer RegisterSceneRecorder(nil)
+
+ w := newAnimatingWidget()
+ w.SetRepaintBoundary(true)
+ w.SetBounds(geometry.NewRect(0, 0, 48, 48))
+
+ ctx := NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ canvas := &stubReplayCanvas{}
+
+ // Frame 1: first draw (cache miss — sceneDirty=true from SetRepaintBoundary).
+ drawBoundaryWidget(w, ctx, canvas, nil)
+
+ if w.drawCount != 1 {
+ t.Fatalf("frame 1: drawCount = %d, want 1", w.drawCount)
+ }
+
+ // After frame 1: widget must be re-dirtied (called SetNeedsRedraw in Draw).
+ if !w.NeedsRedraw() {
+ t.Error("frame 1: NeedsRedraw() = false after Draw; " +
+ "animated widget calls SetNeedsRedraw(true) during Draw, " +
+ "this flag must survive drawBoundaryWidget")
+ }
+ if !w.IsSceneDirty() {
+ t.Error("frame 1: IsSceneDirty() = false after Draw; " +
+ "SetNeedsRedraw on boundary calls InvalidateScene, " +
+ "scene must be dirty for next frame to trigger cache miss")
+ }
+
+ // Frame 2: must be cache miss (sceneDirty=true) — NOT cache hit.
+ drawBoundaryWidget(w, ctx, canvas, nil)
+
+ if w.drawCount != 2 {
+ t.Fatalf("frame 2: drawCount = %d, want 2; "+
+ "animation froze because boundary was cache-hit on second frame", w.drawCount)
+ }
+
+ // Frame 3: still animating.
+ drawBoundaryWidget(w, ctx, canvas, nil)
+
+ if w.drawCount != 3 {
+ t.Fatalf("frame 3: drawCount = %d, want 3", w.drawCount)
+ }
+}
+
+// TestAnimatingBoundary_DoesNotDirtyParent verifies that an animated boundary
+// widget's SetNeedsRedraw during Draw does NOT propagate to parent boundary.
+// Parent boundary must stay clean — only the animated widget re-records.
+func TestAnimatingBoundary_DoesNotDirtyParent(t *testing.T) {
+ RegisterSceneRecorder(stubSceneRecorder)
+ defer RegisterSceneRecorder(nil)
+
+ parent := newDrawTrackingWidget()
+ parent.SetRepaintBoundary(true)
+ parent.SetBounds(geometry.NewRect(0, 0, 800, 600))
+ // Clear initial sceneDirty from SetRepaintBoundary(true) so we can
+ // isolate whether the CHILD's SetNeedsRedraw propagates to parent.
+ parent.ClearSceneDirty()
+
+ child := newAnimatingWidget()
+ child.SetRepaintBoundary(true)
+ child.SetBounds(geometry.NewRect(100, 100, 148, 148))
+ child.SetParent(parent)
+
+ ctx := NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+
+ canvas := &stubReplayCanvas{}
+
+ // First draw of child boundary.
+ drawBoundaryWidget(child, ctx, canvas, nil)
+
+ if child.drawCount != 1 {
+ t.Fatalf("child.drawCount = %d, want 1", child.drawCount)
+ }
+
+ // Child re-dirtied itself (animated).
+ if !child.IsSceneDirty() {
+ t.Error("child boundary should be scene-dirty (re-dirtied by animation)")
+ }
+
+ // Parent must NOT be dirty.
+ if parent.IsSceneDirty() {
+ t.Error("parent boundary should NOT be dirty; " +
+ "animated child's SetNeedsRedraw stops at child boundary, " +
+ "does not propagate to parent (Flutter markNeedsPaint)")
+ }
+}
+
+// TestDrawTree_RootBoundary_ChildBoundaryReached verifies that when root IS
+// a boundary and a child IS also a boundary, DrawTree reaches the child.
+// This is the gallery scenario: root boundary + spinner boundary.
+func TestDrawTree_RootBoundary_ChildBoundaryReached(t *testing.T) {
+ RegisterSceneRecorder(stubSceneRecorder)
+ defer RegisterSceneRecorder(nil)
+
+ // Root boundary contains a container with an animated child boundary.
+ root := newAnimContainerWidget()
+ root.SetRepaintBoundary(true)
+ root.SetBounds(geometry.NewRect(0, 0, 800, 600))
+
+ spinner := newAnimatingWidget()
+ spinner.SetRepaintBoundary(true)
+ spinner.SetBounds(geometry.NewRect(100, 100, 148, 148))
+ spinner.SetParent(root)
+ root.addChild(spinner)
+
+ ctx := NewContext()
+ ctx.SetOnInvalidateRect(func(_ geometry.Rect) {})
+ canvas := &stubReplayCanvas{}
+
+ // Frame 1: both dirty (initial). Root records, child records inside.
+ stats := DrawTree(root, ctx, canvas)
+ _ = stats
+
+ rootDrew := root.drawCalled
+ spinnerDrew := spinner.drawCount > 0
+
+ if !rootDrew {
+ t.Error("frame 1: root.Draw() not called (initial cache miss expected)")
+ }
+ if !spinnerDrew {
+ t.Error("frame 1: spinner.Draw() not called; " +
+ "spinner is a child boundary inside root boundary, " +
+ "must be reached during root's Draw")
+ }
+
+ // Frame 2: spinner re-dirtied itself. Root is clean.
+ // Root should be cache-hit. But spinner inside root must still animate.
+ root.drawCalled = false
+ spinner.drawCount = 0
+
+ stats2 := DrawTree(root, ctx, canvas)
+
+ // KEY TEST: spinner must be drawn on frame 2.
+ // If spinner.drawCount == 0, animation is frozen.
+ if spinner.drawCount == 0 {
+ t.Errorf("frame 2: spinner.Draw() not called; animation frozen. "+
+ "Root is cache-hit (clean), but spinner boundary is dirty. "+
+ "DrawTree must visit child boundaries even when root is cache-hit. "+
+ "Stats: total=%d dirty=%d cached=%d",
+ stats2.TotalWidgets, stats2.DirtyWidgets, stats2.CachedWidgets)
+ }
+}
+
+// --- Test helpers for boundary draw ---
+
+// stubSceneRecorder creates a minimal scene recording canvas for tests.
+func stubSceneRecorder(s *scene.Scene, _, _ int) (Canvas, func()) {
+ return &stubReplayCanvas{}, func() {}
+}
+
+// stubReplayCanvas implements widget.Canvas for boundary draw tests.
+type stubReplayCanvas struct {
+ replayCount int
+}
+
+func (c *stubReplayCanvas) Clear(_ Color) {}
+func (c *stubReplayCanvas) DrawRect(_ geometry.Rect, _ Color) {}
+func (c *stubReplayCanvas) FillRectDirect(_ geometry.Rect, _ Color) {}
+func (c *stubReplayCanvas) StrokeRect(_ geometry.Rect, _ Color, _ float32) {}
+func (c *stubReplayCanvas) DrawRoundRect(_ geometry.Rect, _ Color, _ float32) {}
+func (c *stubReplayCanvas) StrokeRoundRect(_ geometry.Rect, _ Color, _ float32, _ float32) {}
+func (c *stubReplayCanvas) DrawCircle(_ geometry.Point, _ float32, _ Color) {}
+func (c *stubReplayCanvas) StrokeCircle(_ geometry.Point, _ float32, _ Color, _ float32) {}
+func (c *stubReplayCanvas) StrokeArc(_ geometry.Point, _ float32, _, _ float64, _ Color, _ float32) {}
+func (c *stubReplayCanvas) DrawLine(_, _ geometry.Point, _ Color, _ float32) {}
+func (c *stubReplayCanvas) DrawText(_ string, _ geometry.Rect, _ float32, _ Color, _ bool, _ TextAlign) {
+}
+func (c *stubReplayCanvas) MeasureText(_ string, _ float32, _ bool) float32 { return 0 }
+func (c *stubReplayCanvas) DrawImage(_ image.Image, _ geometry.Point) {}
+func (c *stubReplayCanvas) PushClip(_ geometry.Rect) {}
+func (c *stubReplayCanvas) PushClipRoundRect(_ geometry.Rect, _ float32) {}
+func (c *stubReplayCanvas) PopClip() {}
+func (c *stubReplayCanvas) PushTransform(_ geometry.Point) {}
+func (c *stubReplayCanvas) PopTransform() {}
+func (c *stubReplayCanvas) TransformOffset() geometry.Point { return geometry.Point{} }
+func (c *stubReplayCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} }
+func (c *stubReplayCanvas) ClipBounds() geometry.Rect { return geometry.NewRect(0, 0, 9999, 9999) }
+func (c *stubReplayCanvas) ReplayScene(s *scene.Scene) { c.replayCount++ }
+
+var _ Canvas = (*stubReplayCanvas)(nil)
+
+// animContainerWidget is a simple container that draws children via child.Draw().
+type animContainerWidget struct {
+ WidgetBase
+ drawCalled bool
+ kids []Widget
+}
+
+func newAnimContainerWidget() *animContainerWidget {
+ w := &animContainerWidget{}
+ w.SetVisible(true)
+ w.SetEnabled(true)
+ return w
+}
+
+func (w *animContainerWidget) addChild(child Widget) {
+ w.kids = append(w.kids, child)
+ w.AddChild(child)
+}
+
+func (w *animContainerWidget) Layout(_ Context, c geometry.Constraints) geometry.Size {
+ return c.Constrain(geometry.Sz(800, 600))
+}
+
+func (w *animContainerWidget) Draw(ctx Context, canvas Canvas) {
+ w.drawCalled = true
+ for _, child := range w.kids {
+ child.Draw(ctx, canvas)
+ }
+}
+
+func (w *animContainerWidget) Event(_ Context, _ event.Event) bool { return false }
+func (w *animContainerWidget) Children() []Widget { return w.kids }
diff --git a/widget/lifecycle.go b/widget/lifecycle.go
index aef6d93..1d10fa8 100644
--- a/widget/lifecycle.go
+++ b/widget/lifecycle.go
@@ -40,9 +40,14 @@ func MountTree(w Widget, ctx Context) {
lc.Mount(ctx)
}
- // Recurse into children.
+ // Recurse into children, establishing parent chain.
+ // Flutter adoptChild pattern: every child knows its parent so that
+ // propagateDirtyUpward can walk to the nearest RepaintBoundary.
if children := w.Children(); children != nil {
for _, child := range children {
+ if setter, ok := child.(interface{ SetParent(Widget) }); ok {
+ setter.SetParent(w)
+ }
MountTree(child, ctx)
}
}
@@ -73,6 +78,11 @@ func UnmountTree(w Widget) {
lc.Unmount()
}
+ // Clear parent link.
+ if setter, ok := w.(interface{ SetParent(Widget) }); ok {
+ setter.SetParent(nil)
+ }
+
// Clear mounted state.
if base, ok := w.(interface{ SetMounted(bool) }); ok {
base.SetMounted(false)
diff --git a/widget/redraw.go b/widget/redraw.go
index b8a52a2..5479b6c 100644
--- a/widget/redraw.go
+++ b/widget/redraw.go
@@ -44,6 +44,46 @@ func NeedsRedrawInTree(w Widget) bool {
return false
}
+// NeedsRedrawInTreeNonBoundary reports whether any NON-BOUNDARY widget in
+// the subtree needs re-rendering. RepaintBoundary widgets are skipped because
+// they manage their own dirty state independently. This prevents offscreen
+// animated boundaries (spinner scrolled out of view) from forcing expensive
+// root re-recording on every frame.
+func NeedsRedrawInTreeNonBoundary(w Widget) bool {
+ if w == nil {
+ return false
+ }
+
+ type boundaryChecker interface {
+ IsRepaintBoundary() bool
+ }
+ isBoundary := false
+ if bc, ok := w.(boundaryChecker); ok && bc.IsRepaintBoundary() {
+ isBoundary = true
+ }
+
+ // Only count non-boundary widgets as dirty triggers.
+ // Boundaries manage their own dirty state independently.
+ if !isBoundary {
+ type redrawChecker interface {
+ NeedsRedraw() bool
+ }
+ if rc, ok := w.(redrawChecker); ok && rc.NeedsRedraw() {
+ return true
+ }
+ }
+
+ // Always recurse into children (including through boundaries)
+ // to find dirty non-boundary descendants.
+ for _, child := range w.Children() {
+ if NeedsRedrawInTreeNonBoundary(child) {
+ return true
+ }
+ }
+
+ return false
+}
+
// ClearRedrawInTree clears the needsRedraw flag on all widgets in the
// subtree rooted at w.
//
@@ -88,6 +128,25 @@ func MarkRedrawInTree(w Widget) {
rs.SetNeedsRedraw(true)
}
+ // If widget is a PARENTLESS RepaintBoundary (root), invalidate its scene.
+ // Root has no parent → propagateDirtyUpward from SetNeedsRedraw is no-op →
+ // scene stays clean → stale scene replayed. Only invalidate parentless
+ // boundaries, not child boundaries — over-invalidation causes full
+ // repaint every frame (entire cyan overlay).
+ type boundaryInvalidator interface {
+ IsRepaintBoundary() bool
+ InvalidateScene()
+ }
+ if bi, ok := w.(boundaryInvalidator); ok && bi.IsRepaintBoundary() {
+ isRoot := true
+ if pc, ok2 := w.(interface{ Parent() Widget }); ok2 && pc.Parent() != nil {
+ isRoot = false
+ }
+ if isRoot {
+ bi.InvalidateScene()
+ }
+ }
+
// Recurse into children.
for _, child := range w.Children() {
MarkRedrawInTree(child)
diff --git a/widget/stamp.go b/widget/stamp.go
index 937792e..8cc7b4e 100644
--- a/widget/stamp.go
+++ b/widget/stamp.go
@@ -1,6 +1,13 @@
package widget
-import "github.com/gogpu/ui/geometry"
+import (
+ "fmt"
+ "os"
+
+ "github.com/gogpu/ui/geometry"
+)
+
+var debugStamp = os.Getenv("GOGPU_DEBUG_STAMP") == "1"
// StampScreenOrigin computes and records the screen-space origin on a widget
// using the canvas's current transform offset and the widget's local bounds.
@@ -30,12 +37,42 @@ func StampScreenOrigin(child Widget, canvas Canvas) {
}
bg, hasBounds := child.(boundsGetter)
- os, hasOrigin := child.(originSetter)
+ setter, hasOrigin := child.(originSetter)
if !hasBounds || !hasOrigin {
return
}
childBounds := bg.Bounds()
- offset := canvas.TransformOffset()
- os.SetScreenOrigin(offset.Add(childBounds.Min))
+ offset := canvas.TransformOffset().Add(canvas.ScreenOriginBase())
+ screenOrigin := offset.Add(childBounds.Min)
+ if debugStamp {
+ fmt.Fprintf(os.Stderr, "[STAMP] %T bounds=%v canvasOffset=%v → screen=%v\n",
+ child, childBounds, offset, screenOrigin)
+ }
+ setter.SetScreenOrigin(screenOrigin)
+}
+
+// stampCompositorClip records the canvas's current clip rect (in screen space)
+// on a skipped boundary child. compositeTextures uses this to cull textures
+// outside the viewport (e.g., ScrollView clips).
+//
+// The clip rect from the canvas is in the recording coordinate system
+// (local to the parent boundary). We convert to screen space by adding
+// the canvas's screenOriginBase.
+func stampCompositorClip(child Widget, canvas Canvas) {
+ type clipSetter interface {
+ SetCompositorClip(geometry.Rect)
+ }
+ setter, ok := child.(clipSetter)
+ if !ok {
+ return
+ }
+
+ localClip := canvas.ClipBounds()
+ base := canvas.ScreenOriginBase()
+ screenClip := geometry.Rect{
+ Min: localClip.Min.Add(base),
+ Max: localClip.Max.Add(base),
+ }
+ setter.SetCompositorClip(screenClip)
}