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) }