diff --git a/.gitignore b/.gitignore index 772ccbe59c..0896ea0529 100644 --- a/.gitignore +++ b/.gitignore @@ -295,3 +295,6 @@ __pycache__/ # macOS .DS_Store +/output +/.playwright-cli +TestApp.json diff --git a/AGENTS.md b/AGENTS.md index 03e35ad8dd..0f81a5d2ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,23 @@ This repository contains the Svg.Skia library and associated tests. The followin 3. Build the solution using `dotnet build Svg.Skia.slnx -c Release`. 4. Run all tests with `dotnet test Svg.Skia.slnx -c Release`. +## W3C Chrome Overrides +- Files under `tests/Svg.Skia.UnitTests/ChromeReference/W3C/` must be generated from real Google Chrome captures, not copied from the legacy W3C PNG set. +- Use `node scripts/capture_w3c_chrome_overrides.mjs` to regenerate the checked Chrome override set. Pass specific test names as comma-separated arguments to limit capture scope, for example `node scripts/capture_w3c_chrome_overrides.mjs masking-path-04-b,linking-a-09-b`. +- The capture script serves the repository over HTTP and screenshots a `480x360` Chrome-hosted iframe, which matches the standalone viewport policy used by `W3CTestSuiteTests`. +- When a Chrome override exists, keep `W3CTestSuiteTests` pointed at that override instead of the W3C reference PNG. +- Do not reintroduce footer exclusion regions for W3C comparisons. Prefer Chrome overrides plus narrowly-scoped per-test thresholds only when the library is visually aligned with Chrome but still differs at the pixel-raster level. +- If a W3C fixture depends on JavaScript, DOM APIs, or browser-only runtime behavior that Svg.Skia does not implement, keep that row skipped with an explicit reason instead of manufacturing a fake baseline. + +## Testing Workflow +- For W3C work, first run focused rows with `dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-restore --filter "FullyQualifiedName~W3CTestSuiteTests.Tests"`. +- When refreshing baselines, rerun the focused W3C subset that changed before running the full W3C suite. +- Before committing renderer or baseline changes, run: + - `dotnet format Svg.Skia.slnx --no-restore` + - `dotnet build Svg.Skia.slnx -c Release` + - `dotnet test Svg.Skia.slnx -c Release` +- Keep unrelated local state files, especially `samples/TestApp/TestApp.json`, out of commits unless the task explicitly requires them. + ## Commit Guidelines - Use concise commit messages summarizing your change in under 72 characters. - Additional description lines may follow the summary if necessary. diff --git a/CHANGELOG.md b/CHANGELOG.md index b13ad5f02d..d75e70386b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Svg.Skia Changelog +## Unreleased + +* Added SVG 1.1 animation object-model coverage in `Svg.Custom` for `animate`, `set`, `animateMotion`, `animateColor`, `animateTransform`, and `mpath`. +* Added typed `pointer-events` support, geometry-aware hit testing, topmost-element targeting, and routed interaction dispatch with capture, tunnel, bubble, and cursor resolution. +* Added shared animation playback in `SKSvg`, including animation time control, invalidation events, layered redraw, throttling helpers, and native-composition scene extraction. +* Added host animation backends for Avalonia and Uno, including resolved-backend diagnostics and Avalonia retained `NativeComposition` playback with fallback. +* Added an animation benchmark harness in `tests/Svg.Skia.Benchmarks` and exposed animation/backend controls in `samples/TestApp`. + ## 0.3.0 * Updated NuGet packages. diff --git a/Directory.Packages.props b/Directory.Packages.props index bd28e25b2e..679fbbef92 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,6 +33,7 @@ + diff --git a/README.md b/README.md index 6a905d1d66..049b6d4b66 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ ## About *Svg.Skia* can be used as a .NET library or as a CLI application -to render SVG files based on a [static](http://www.w3.org/TR/SVG11/feature#SVG-static) -[SVG Full 1.1](https://www.w3.org/TR/SVG11/) subset to raster images or -to a backend's canvas. +to render SVG files based on the [SVG Full 1.1](https://www.w3.org/TR/SVG11/) +document model to raster images or to a backend's canvas. The `Svg.Skia` is using [SVG](https://github.com/vvvv/SVG) library to load `Svg` object model. @@ -33,6 +32,15 @@ The `Svg.Skia` can be used in same way as the [SkiaSharp.Extended.Svg](https://g The `Svg` library has a more complete implementation of the `Svg` document model than [SkiaSharp.Extended.Svg](https://github.com/mono/SkiaSharp.Extended/tree/main/source/SkiaSharp.Extended.Svg) and the `Svg.Skia` renderer will provide more complete rendering subsystem implementation. +## Highlights + +- `Svg.Custom` now exposes the SVG 1.1 animation object model for `animate`, `set`, `animateMotion`, `animateColor`, `animateTransform`, and `mpath`. +- `Svg.Custom` and `Svg.Skia` now include typed `pointer-events` handling plus geometry-aware topmost hit testing. +- `Svg.Skia` now includes a shared interaction dispatcher, shared animation clock/controller, and host-driven animation playback APIs. +- `Svg.Controls.Skia.Avalonia` and `Svg.Controls.Skia.Uno` now expose animation backend selection, playback rate, frame interval, and resolved-backend diagnostics. +- Avalonia adds an optional `NativeComposition` animation backend with fallback to `RenderLoop` or `DispatcherTimer` when retained composition is unavailable. +- `tests/Svg.Skia.Benchmarks` adds a local BenchmarkDotNet harness for the shared animation renderer, and `samples/TestApp` exposes backend and playback controls for manual verification. + ## NuGet Svg.Skia is delivered as a NuGet package. @@ -202,7 +210,7 @@ The `SKSvg` class provides helpers for retrieving elements or drawables at a given point. The hit-testing methods expect coordinates in picture space: ```C# -using SkiaSharp; +using ShimSkiaSharp; using Svg.Skia; var svg = new SKSvg(); @@ -220,6 +228,10 @@ When drawing on a transformed canvas you can convert canvas coordinates to picture coordinates using `TryGetPicturePoint` and then use the hit-testing methods. +The runtime also exposes `HitTestTopmostElement(...)` for pointer-dispatch +scenarios where only the topmost routed target should be returned, and the hit +test path now respects typed `pointer-events` values. + #### Svg control The `Svg` Avalonia control exposes a `HitTestElements` method that accepts @@ -229,6 +241,47 @@ a point in control coordinates and returns the matching SVG elements: var hits = svgControl.HitTestElements(new Point(x, y)); ``` +### Animation and interaction + +`SKSvg` now exposes a shared animation runtime and a routed interaction layer +that can be hosted from Avalonia, Uno, or custom Skia surfaces. + +```C# +using System; +using ShimSkiaSharp; +using Svg.Skia; + +using var svg = new SKSvg(); + +if (svg.Load("animated.svg") is not null && svg.HasAnimations) +{ + svg.AnimationInvalidated += (_, e) => Console.WriteLine(e.Time); + + svg.SetAnimationTime(TimeSpan.FromSeconds(1)); + svg.AdvanceAnimation(TimeSpan.FromMilliseconds(16)); + svg.ResetAnimation(); +} + +var target = svg.HitTestTopmostElement(new SKPoint(10, 10)); +``` + +The shared runtime surface includes: + +- `HasAnimations` +- `AnimationTime` +- `SetAnimationTime(...)` +- `AdvanceAnimation(...)` +- `ResetAnimation()` +- `AnimationInvalidated` +- `AnimationMinimumRenderInterval` +- `HasPendingAnimationFrame` +- `FlushPendingAnimationFrame()` +- `LastAnimationDirtyTargetCount` + +The shared interaction surface includes `SvgInteractionDispatcher`, +`SvgPointerInput`, routed `Dispatched` events, cursor hints, and optional +compatibility bridging back into `SvgElement` mouse events. + ### Avalonia @@ -248,6 +301,26 @@ Install-Package Svg.Controls.Skia.Avalonia ``` +For animated content, the Skia-backed Avalonia and Uno controls also expose: + +- `AnimationBackend` +- `AnimationFrameInterval` +- `AnimationPlaybackRate` +- `ActualAnimationBackend` +- `AnimationBackendFallbackReason` + +Avalonia supports `Default`, `Manual`, `DispatcherTimer`, `RenderLoop`, and +`NativeComposition`. Uno supports the same host-driven playback model but falls +back from `NativeComposition` because it does not currently expose a working +retained child-visual path. + +```XAML + +``` + #### Image control ```XAML diff --git a/Svg.Skia.slnx b/Svg.Skia.slnx index a111bfa914..8f5b09614b 100644 --- a/Svg.Skia.slnx +++ b/Svg.Skia.slnx @@ -66,7 +66,9 @@ + + @@ -81,11 +83,14 @@ + + + diff --git a/externals/SVG b/externals/SVG index fddd725b48..05f4897776 160000 --- a/externals/SVG +++ b/externals/SVG @@ -1 +1 @@ -Subproject commit fddd725b488611b11a28f32e2fcecc9e275c6297 +Subproject commit 05f48977768989cb889334eceb7804fbb822af0f diff --git a/plan/animation-project-split-plan.md b/plan/animation-project-split-plan.md new file mode 100644 index 0000000000..8d693820dc --- /dev/null +++ b/plan/animation-project-split-plan.md @@ -0,0 +1,76 @@ +# Animation Project Split Plan + +## Goal + +Split the reusable animation runtime out of `src/Svg.Skia` into a dedicated project, similar to `src/Svg.SceneGraph`, while keeping `SKSvg`-specific render orchestration inside `Svg.Skia`. + +## Why + +- reduce `Svg.Skia` assembly scope to Skia/render-host integration +- make the animation runtime reusable by controls and future backends without pulling in all of `SKSvg` +- isolate trim/AOT-sensitive animation logic behind a smaller assembly boundary +- align the architecture with the retained-scene split already introduced by `Svg.SceneGraph` + +## Clean Boundary + +### Move to `src/Svg.Animation` + +- `SvgAnimationClock` +- `SvgAnimationController` +- `SvgAnimationFrameState` +- `SvgAnimationHostBackend` and resolver/capability types +- `SvgNativeCompositionScene`, `SvgNativeCompositionFrame`, `SvgNativeCompositionLayer` +- shared animation invalidation policy extracted from `SKSvg.AnimationLayers.cs` + +### Keep in `src/Svg.Skia` + +- `SKSvg.Model.cs` animation orchestration +- `SKSvg.AnimationLayers.cs` +- `SKSvg.NativeComposition.cs` +- `SKSvg.SceneGraph.cs` retained-scene mutation/render preparation +- SkiaSharp picture conversion, picture registration/disposal, and drawing + +Reason: those files are partial `SKSvg` implementation details and depend directly on `SKSvg` state, `SkiaModel`, and render lifecycle synchronization. + +## Dependency Rules + +### `Svg.Animation` + +- may reference: + - `Svg.Custom` + - `Svg.Model` + - `ShimSkiaSharp` + - `SkiaSharp` +- must not depend on: + - `SKSvg` + - `Svg.Controls.*` + - retained-scene orchestration partials + +### `Svg.Skia` + +- references: + - `Svg.Animation` + - `Svg.SceneGraph` + - existing model/custom projects + +## Required Supporting Changes + +1. Add `InternalsVisibleTo` from `Svg.Custom` to `Svg.Animation`. +2. Add `src/Svg.Animation/Properties/AssemblyInfo.cs` with `InternalsVisibleTo` for `Svg.Skia`. +3. Add the new project to `Svg.Skia.slnx`. +4. Update `Svg.Skia.csproj` to reference `Svg.Animation.csproj`. +5. Physically move animation runtime files from `src/Svg.Skia/Animation` to `src/Svg.Animation`. +6. Replace the inherited-animation-attribute table in `SKSvg.AnimationLayers.cs` with a shared helper owned by `Svg.Animation`. + +## Validation + +1. `dotnet format Svg.Skia.slnx --no-restore` +2. `dotnet build Svg.Skia.slnx -c Release` +3. `dotnet test Svg.Skia.slnx -c Release` + +## Success Criteria + +- `src/Svg.Skia/Animation` no longer contains the reusable runtime files +- `Svg.Skia` builds against the new project boundary +- Avalonia/Uno host backends and tests continue to compile against the moved types +- `SKSvg.AnimationLayers.cs` retains only `SKSvg`-bound logic diff --git a/plan/drawable-removal-scene-graph-cutover-plan.md b/plan/drawable-removal-scene-graph-cutover-plan.md new file mode 100644 index 0000000000..b05255edd2 --- /dev/null +++ b/plan/drawable-removal-scene-graph-cutover-plan.md @@ -0,0 +1,152 @@ +# Drawable Removal And Scene Graph Cutover Plan + +Status date: 2026-04-06 +Branch: `feature/svg-animation-runtime` + +## Purpose + +This document records the retained scene graph migration status, summarizes the major fixes already delivered on this branch, and captures both the removal plan and the completed implementation status for deleting the legacy drawable subsystem. + +## Branch Progress So Far + +The branch already landed a large amount of retained-scene and animation stabilization work before the drawable-removal pass: + +- `4fd9761e` fixed root `viewBox` animation invalidation. +- `8beb0318` fixed Uno animated picture lifetime after animation rebuilds. +- `124c222a`, `25a833fe`, `60e4a19b` fixed embedded SVG image recursion and added W3C coverage. +- `92637cc7`, `c160db8f` fixed retained recursive mask traversal and added regression coverage. +- `68d97490`, `9b0c8a4b` extracted animation parsing helpers and added parsing regressions. +- `0a3e9d29`, `e226d8e9`, `e3504346`, `9e86d693` fixed CI regressions around embedded SVG identity, duplicate image state, hidden-node bounds, and unresolved retained filters. +- `8b7d52eb`, `17c72a04`, `9d701138`, `547c2b05` fixed animation event bridge, timing, playback-rate clamping, and parser edge cases raised during PR review. +- `b7ce74c2` fixed the `animate-elem-38-t.svg` root `viewBox` regression by disabling retained caching/native composition when document-root animation targets are present. +- `d6f4d3fd`, `3fe70d7e` restored retained positioned text rendering for Windows CI and added regression coverage. + +These fixes establish the retained scene graph as the primary `SKSvg` rendering path and close the major correctness gaps found during the migration. + +## Current Architecture + +### Main runtime path + +The main `SKSvg` path is retained-scene-based: + +- `SvgSceneRuntime.TryCompile(...)` +- `SvgSceneDocument` +- `SvgSceneRenderer.Render(...)` +- `SkiaModel.ToSKPicture(...)` + +Key entry points: + +- `src/Svg.Skia/SKSvg.Model.cs` +- `src/Svg.Skia/SKSvg.SceneGraph.cs` +- `src/Svg.SceneGraph/SvgSceneCompiler.cs` +- `src/Svg.SceneGraph/SvgSceneDocument.cs` +- `src/Svg.SceneGraph/SvgSceneRenderer.cs` + +### Legacy drawable path + +The drawable subsystem existed at the start of this pass under: + +- `src/Svg.Model/Drawables/**` +- `src/Svg.Model/Services/HitTestService.cs` +- `src/Svg.Model/Editing/DrawableWalker.cs` +- `src/Svg.Model/Editing/DrawableEditingExtensions.cs` + +That legacy path has now been removed from production code. The retained scene graph is the only runtime renderer and hit-test implementation. + +## Implementation Status After This Pass + +This pass completed the drawable retirement plan: + +- deleted `src/Svg.Model/Drawables/**` +- deleted drawable-only infrastructure: + - `src/Svg.Model/Editing/DrawableWalker.cs` + - `src/Svg.Model/Editing/DrawableEditingExtensions.cs` + - `src/Svg.Model/Services/HitTestService.cs` + - `src/Svg.Model/Services/MarkerService.cs` + - `src/Svg.Model/SvgFilterContext.cs` +- removed drawable-only APIs from shared services: + - `PaintingService.RecordPicture(...)` + - `PaintingService.CreatePicture(...)` + - `PaintingService.GetFillPaint(...)` + - `PaintingService.GetStrokePaint(...)` + - `PaintingService.GetOpacityPaint(SvgElement)` + - `MaskingService.GetSvgElementMask(...)` +- deleted drawable-only unit tests and clone coverage +- tightened the architecture guard so production code now allows zero `Svg.Model.Drawables` references + +Result: + +- retained scene graph is the only runtime renderer +- retained scene graph is the only runtime hit-test implementation +- retained scene graph is the only runtime implementation for filters, masks, and marker generation +- `Svg.Model` now contains only shared helpers and non-drawable services that are still needed by retained code + +### Important dependency constraint + +`Svg.SceneGraph` references `Svg.Model`, not the other way around: + +- `Svg.Model` cannot directly call retained-scene compiler/renderer code today. +- This prevents reusing retained-scene compiler entry points from inside `Svg.Model`. + +However, the deeper analysis for full drawable removal changes the conclusion: + +- full drawable deletion does **not** require `Svg.Model` to render through `Svg.SceneGraph` +- all drawable types are internal implementation details +- the public `SKSvg` runtime already renders, filters, hit-tests, masks, and generates markers through retained-scene code + +That means the lowest-risk path is to retire the legacy drawable renderer instead of preserving it. The dependency boundary still matters for cleanup sequencing, but it is **not** a blocker to deleting drawables completely. + +## Delivered Cleanup + +This cutover also shipped the follow-up cleanup needed to remove lingering drawable-era terminology and duplication: + +- `SvgPatternPaintStateResolver` centralizes pattern inheritance and transform resolution for both `Svg.Model` and the retained scene graph. +- retained-scene internals now use `IsRenderable` terminology consistently instead of `IsDrawable`. +- the zero-reference architecture guard prevents production code from depending on `Svg.Model.Drawables` again. + +## Final State Summary + +The repository is in the intended post-cutover state: + +- retained scene graph is the only `SKSvg` runtime render path +- retained scene graph is the only runtime hit-test path +- retained scene graph is the only runtime implementation for filters, masks, and marker generation +- `Svg.Model` contains only shared helpers and non-drawable services needed by retained code +- production code contains zero `Svg.Model.Drawables` references + +## Validation Status + +The drawable-removal cutover is considered complete because all of the following are true: + +- no production runtime path calls `DrawableFactory.Create(...)` +- no production `src/**` file references `Svg.Model.Drawables` +- build passes for `Svg.Skia.slnx` +- full test suite passes for `Svg.Skia.slnx` + +## Optional Follow-Up + +Any remaining work is cosmetic rather than architectural: + +- continue renaming editor/private members that still use historical `Drawable` wording +- simplify historical notes in this document if later branch history makes them obsolete + +## Validation Plan + +For each removal phase: + +1. Run targeted unit tests for touched subsystems. +2. Run `dotnet build Svg.Skia.slnx -c Release`. +3. Run `dotnet test Svg.Skia.slnx -c Release`. +4. Keep W3C and resvg retained-scene regressions green, especially: + - animated root `viewBox` + - embedded SVG image recursion + - recursive mask payloads + - positioned text + - pattern resource mutation coverage + +## Immediate Next Steps After This Change + +1. Split shared retained helpers out of `PaintingService` so its drawable-only path can be deleted instead of preserved. +2. Delete drawable-only editing and hit-test helpers that have no non-drawable production consumers. +3. Remove `SvgFilterContext`, `MarkerService`, and `GetSvgElementMask(...)` together with `src/Svg.Model/Drawables/**`. +4. Tighten the architecture guard allow-list to zero. diff --git a/plan/pr-summary-svg-animation-runtime.md b/plan/pr-summary-svg-animation-runtime.md new file mode 100644 index 0000000000..990327ea2e --- /dev/null +++ b/plan/pr-summary-svg-animation-runtime.md @@ -0,0 +1,272 @@ +# PR Summary: SVG Animation Runtime, Retained Scene Graph, and Rendering Cutover + +## Overview + +This branch started as shared SVG animation and interaction work, then grew into a larger rendering-architecture change. + +It adds SVG 1.1 animation DOM support in `Svg.Custom`, shared pointer interaction and geometry-aware hit testing, a shared animation runtime in `SKSvg`, host playback backends for Avalonia and Uno, and an Avalonia retained `NativeComposition` path for supported scenes. + +On top of that original scope, the branch introduces a retained scene-graph runtime, migrates rendering/editor/animation flows onto that retained runtime, splits the retained scene graph into its own `Svg.SceneGraph` project, and then splits the shared animation runtime into a new `Svg.Animation` project. The branch also removes several unshipped compatibility shims and moves the rendering pipeline toward the retained scene graph as the single runtime representation for the next major release. + +Base branch: `master` +Merge-base: `076e0a22e3fe0220db951710d7799dc92b42576d` + +Branch diff vs `master`: + +- `163` files changed +- about `25,165` insertions +- about `624` deletions + +## Commit Progression After The Last PR Summary Update + +The earlier PR summary stopped at the first animation/native-composition/doc pass. Since then the branch added: + +- retained scene graph foundation and mutation routing +- retained-scene-first editor workflows +- retained-scene rendering fallback for animation +- removal of the retained compiler drawable bridge +- retained hit-testing and filter/mask parity fixes +- scene-graph major-release cutover work that removes drawable-first public rendering flows +- `Svg.SceneGraph` split into a separate project +- reflection removal from animation and scene compilation helpers +- removal of unshipped obsolete compatibility APIs +- `Svg.Animation` split into a separate project + +Relevant later commits include: + +- `f106e50a` Implement subtree animation invalidation +- `a736db88` Prefer default animation backends +- `20058d0a` Fix animation timing and additive state +- `9e5c315d` Gate native composition to renderable roots +- `7c3b5edf` Add retained scene graph foundation +- `9e8bccf1` Migrate editor workflows to retained nodes +- `65c23848` Route interaction through retained scene +- `796370f3` Use retained scenes for animation fallback +- `6e86c57a` Remove retained compiler drawable bridge +- `41f4b71e` Fix retained mask hit testing +- `537eb2ef` Fix event timing instance handling +- `7865aab6` Prune hidden retained hit-test subtrees +- `1cf14385` Remove drawable editor fallbacks +- `aaa16665` Migrate retained animation runtime APIs +- `0bba357f` Remove animation reflection access +- `60767e4d` Drop obsolete retained API shims +- `5fc313de` Split retained scene graph project +- `754035ca` Cut over rendering to retained scenes +- `eb990cc4` Fix retained filter parity and hit testing +- `e06ca06f` Split animation runtime into `Svg.Animation` + +## Main Functional Changes + +### 1. SVG animation DOM and shared runtime + +The branch adds local SVG 1.1 animation element support in `Svg.Custom` and a shared animation runtime in `SKSvg`. + +This includes: + +- `set`, `animate`, `animateColor`, `animateTransform`, and `animateMotion` +- event-driven begin/end timing +- additive and accumulate handling +- animation clock/time control APIs +- host playback backend resolution for Avalonia and Uno +- animation invalidation and frame-state tracking + +The runtime was later cleaned up by: + +- removing reflection-based animated attribute access in favor of explicit runtime bridges +- fixing timing edge cases such as dotted event ids, repeat handling, spline handling, and zero-duration cases +- moving the shared runtime into a dedicated `Svg.Animation` project + +Key files: + +- `src/Svg.Custom/Animation/*` +- `src/Svg.Animation/Animation/*` +- `src/Svg.Animation/SvgAnimationInvalidation.cs` +- `src/Svg.Skia/SKSvg.AnimationLayers.cs` +- `src/Svg.Controls.Skia.Avalonia/Svg.cs` +- `src/Svg.Controls.Skia.Uno/Svg.cs` + +### 2. Shared interaction and geometry-aware hit testing + +The branch adds typed `pointer-events` support, routed pointer dispatch, geometry-aware hit testing, and retained-scene-first hit testing. + +This includes: + +- topmost-element and topmost-scene-node targeting +- clip-path and mask-aware hit testing +- routed tunnel/target/bubble dispatch +- pressed-target capture behavior +- cursor resolution +- retained-scene hit-test pruning for hidden or suppressed subtrees + +Key files: + +- `src/Svg.Custom/Interaction/SvgPointerEvents.cs` +- `src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs` +- `src/Svg.SceneGraph/SvgSceneHitTestService.cs` +- `tests/Svg.Skia.UnitTests/HitTestTests.cs` + +### 3. Avalonia/Uno host playback and native composition + +The host controls now expose: + +- `AnimationBackend` +- `AnimationFrameInterval` +- `AnimationPlaybackRate` +- `ActualAnimationBackend` +- fallback/capability reporting + +Avalonia additionally gained a retained native-composition path for supported scenes, plus TestApp support for selecting and exercising it. + +This work also includes follow-up fixes for: + +- stale/disposed picture usage +- initial retained visual activation +- clipping of translated retained layers +- descendant opacity preservation +- fallback from unsupported native-composition scenes + +Key files: + +- `src/Svg.Controls.Skia.Avalonia/Svg.cs` +- `src/Svg.Controls.Skia.Avalonia/Composition/SvgCompositionVisualScene.cs` +- `src/Svg.Controls.Skia.Uno/Svg.cs` +- `samples/TestApp/Views/MainView.axaml` +- `samples/TestApp/Views/MainView.axaml.cs` + +### 4. Retained scene graph foundation + +The branch introduces a retained scene graph as a real runtime representation, not just a transient export format. + +The retained layer now covers: + +- scene compilation from SVG DOM +- retained node/resource indexing +- retained rendering +- retained mutation routing +- retained resource ownership for clip, mask, filter, paint, and text payloads +- retained-node hit testing +- retained-scene-based tooling helpers + +The compiler progressively moved away from drawable-bridge fallbacks and toward direct retained compilation for core shapes, structural wrappers, text, masks, filters, `use`, `switch`, and image-backed scenarios. + +Key files: + +- `src/Svg.SceneGraph/SvgSceneCompiler.cs` +- `src/Svg.SceneGraph/SvgSceneDocument.cs` +- `src/Svg.SceneGraph/SvgSceneNode.cs` +- `src/Svg.SceneGraph/SvgSceneResource.cs` +- `src/Svg.SceneGraph/SvgSceneRenderer.cs` +- `src/Svg.SceneGraph/SvgSceneRuntime.cs` + +### 5. Major-release rendering cutover away from drawable-first public rendering APIs + +Because this work targets a future major release, the branch starts removing drawable-first public rendering flows instead of preserving backward-compatible shims. + +This cutover includes: + +- routing `SKSvg` render/model creation through retained scene compilation +- removing `SvgService.ToDrawable(...)` / `SvgService.ToModel(...)` +- switching `Svg.Controls.Avalonia`, source generation, and CLI/sample consumers to retained-scene model generation +- shrinking drawable usage down to remaining internal compatibility seams instead of public rendering APIs + +Key files: + +- `src/Svg.Skia/SKSvg.Model.cs` +- `src/Svg.Model/Services/SvgService.cs` +- `src/Svg.Controls.Avalonia/SvgSource.cs` +- `src/Svg.SourceGenerator.Skia/SvgSourceGenerator.cs` +- `samples/SvgToPng/ViewModels/MainWindowViewModel.cs` +- `samples/svgc/Program.cs` + +### 6. Animation performance and invalidation follow-up + +The branch adds layered animation redraw and then pushes further toward retained-scene incremental behavior. + +This includes: + +- static/animated layer caching +- subtree animation invalidation +- retained-scene-driven animation fallback rendering +- fixes for inherited animated attributes and resource-driven invalidation +- benchmark coverage for frame advancement + +Key files: + +- `src/Svg.Skia/SKSvg.AnimationLayers.cs` +- `tests/Svg.Skia.Benchmarks/SvgAnimationFrameBenchmarks.cs` + +### 7. Project structure cleanup + +The branch splits the new architecture into dedicated projects: + +- `src/Svg.SceneGraph/Svg.SceneGraph.csproj` +- `src/Svg.Animation/Svg.Animation.csproj` + +This keeps: + +- retained-scene runtime/compiler/resource logic in `Svg.SceneGraph` +- shared animation runtime in `Svg.Animation` +- SkiaSharp conversion, host integration, and `SKSvg` façade code in `Svg.Skia` + +It also removes unshipped obsolete compatibility APIs and replaces reflection-based internal access with explicit runtime bridges where possible. + +## Documentation And Planning Artifacts + +The branch adds and updates implementation docs and plans for the new architecture, including: + +- `plan/svg-interaction-animation-phased-implementation.md` +- `plan/svg-retained-scene-graph-rewrite-spec.md` +- `plan/remaining-scene-graph-animation-work-plan.md` +- `plan/scene-graph-major-release-cutover-plan.md` +- `plan/animation-project-split-plan.md` + +User-facing docs were also refreshed earlier in the branch, including: + +- `README.md` +- `CHANGELOG.md` +- `site/articles/guides/interaction-and-animation.md` +- package docs for `Svg.Custom`, `Svg.Skia`, `Svg.Controls.Skia.Avalonia`, and `Svg.Controls.Skia.Uno` + +## Validation + +Branch work included repeated validation with: + +- `dotnet format Svg.Skia.slnx --no-restore` +- `dotnet build Svg.Skia.slnx -c Release` +- `dotnet test Svg.Skia.slnx -c Release` + +Recent validation specifically covered: + +- successful build of the new `Svg.SceneGraph` split +- successful build of the new `Svg.Animation` split +- retained-scene regression coverage for background-image filters, invalid filter suppression, and hit testing +- targeted resvg regression slices for `e-feDiffuseLighting` and malformed `e-feConvolveMatrix` + +Expanded tests include work in: + +- `tests/Svg.Model.UnitTests/*` +- `tests/Svg.Skia.UnitTests/HitTestTests.cs` +- `tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs` +- `tests/Svg.Skia.UnitTests/SvgRetainedSceneGraphTests.cs` +- `tests/Svg.Skia.UnitTests/SKSvgNativeCompositionTests.cs` +- `tests/Svg.Controls.Avalonia.UnitTests/SvgSourceTests.cs` +- `tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs` + +## Architectural Outcome + +The branch is no longer just “animation support.” + +It now delivers: + +- shared SVG animation and interaction support +- retained native composition support in Avalonia +- a retained scene graph runtime with direct DOM compilation +- major-release migration away from drawable-first rendering APIs +- separate `Svg.SceneGraph` and `Svg.Animation` projects + +The remaining long-term direction is clear: + +- `SvgDocument` remains the DOM/source model +- `Svg.SceneGraph` is the retained render/runtime representation +- `Svg.Animation` owns shared animation timing/evaluation/runtime behavior +- `Svg.Skia` becomes the Skia/host integration layer on top of those shared runtimes diff --git a/plan/remaining-scene-graph-animation-work-plan.md b/plan/remaining-scene-graph-animation-work-plan.md new file mode 100644 index 0000000000..14ddd19405 --- /dev/null +++ b/plan/remaining-scene-graph-animation-work-plan.md @@ -0,0 +1,171 @@ +# Remaining Scene Graph And Animation Work + +## Current Summary + +At this point, the core retained scene graph exists and the original six-phase animation plan is done. What is left is mostly removal of the remaining legacy seams and fallback paths. + +**Scene Graph** +- Replace the last per-element drawable extraction bridge for unsupported visual/resource pipelines with direct retained display-list generation, as called out in [svg-retained-scene-graph-rewrite-spec.md](/Users/wieslawsoltes/GitHub/Svg.Skia/plan/svg-retained-scene-graph-rewrite-spec.md). +- Reduce load-time duplication where retained compilation still temporarily generates drawables only for compatibility payload extraction. +- Remove the remaining compatibility proxies at public/editor boundaries. +- Migrate the last editor commands that still lean on drawables: + - selected-element export + - align/distribute helpers + - remaining path/shape manipulation helpers that still use drawable geometry/order +- After parity is proven on those edge cases, remove the older non-retained fallback paths entirely. + +**Animation** +- The planned shared animation runtime is complete per [svg-interaction-animation-phased-implementation.md](/Users/wieslawsoltes/GitHub/Svg.Skia/plan/svg-interaction-animation-phased-implementation.md). What remains is follow-up work beyond that plan. +- Eliminate the remaining fallback full-frame rebuilds for non-drawable resource pipelines during animation: + - gradients + - clip-path resources + - filter graphs +- Route those animation/resource changes fully through retained nodes/resources so animated frames do not fall back to the older drawable/picture rebuild path. +- Remove the transient animation-layer caching architecture once retained-scene mutations cover those cases well enough. +- Optional later step: map compatible SVG animation cases onto true host-native animation objects instead of only using host-native scheduling. + +**Practical order** +1. Finish the remaining editor/public drawable-dependent commands. +2. Remove the last retained-compiler drawable bridges. +3. Move resource-driven animation updates fully onto retained node/resource mutations. +4. Delete the old fallback rebuild paths. +5. Only then consider deeper host-native animation object mapping. + +So the short answer is: the remaining work is not “build a scene graph” anymore. It is “finish removing the old drawable-first assumptions everywhere they still leak through,” especially in resource-heavy animation and a few editor commands. + +## Detailed Implementation Plan + +### Phase 1: Editor And Public API Drawable-Seam Removal + +Goals: +- make retained scene nodes the primary geometry/render source for editor commands +- keep drawable usage only as a temporary fallback +- preserve current user-visible behavior while removing drawable-first assumptions + +Tasks: +1. Export selected element from retained scene state. + - prefer `SvgSceneNode` plus `SKSvg.CreateRetainedSceneNodePicture(...)` + - fall back to drawable snapshot only when no retained node is available + - preserve current PNG export API and behavior +2. Move align/distribute helpers from drawable bounds to explicit retained bounds. + - align/distribute should operate on `SKRect` bounds rather than `DrawableBase` + - workspace should gather bounds from retained scene nodes first +3. Audit remaining editor commands that still depend on drawable geometry/order. + - path/shape manipulation helpers + - selection/export helpers + - any command using `DrawableBase.TransformedBounds` directly +4. Add regression coverage for retained-scene-first editor operations. + - export through retained node when drawable is absent + - align/distribute translation updates driven by retained bounds + +Exit criteria: +- editor export works with retained scene node only +- align/distribute no longer require drawable instances +- tests prove retained-scene-first behavior + +### Phase 2: Final Retained Compiler Bridge Removal + +Goals: +- eliminate remaining per-element drawable extraction during retained compilation +- reduce load duplication and stop using transient drawable generation for compatibility-only payload extraction + +Tasks: +1. inventory remaining `DrawableBridge` compilation cases +2. replace each remaining bridge with direct retained display-list generation +3. move compatibility-only payload builders into retained compiler/runtime services +4. extend parity coverage for the previously bridged cases + +Exit criteria: +- retained compiler does not require per-element drawable extraction for supported rendering/resource cases +- compilation strategy map shows only intentional temporary fallback categories, if any + +### Phase 3: Retained Resource-Driven Animation Updates + +Goals: +- animate resource-backed changes through retained nodes/resources directly +- stop falling back to full-frame drawable/picture rebuild for resource changes + +Tasks: +1. route gradient, clip-path, and filter mutations through retained resource dependency updates +2. ensure animation frame deltas can invalidate and rebuild only affected retained resources/nodes +3. preserve hit testing and host extraction correctness under retained resource mutation +4. expand parity coverage for animated resource-backed documents + +Exit criteria: +- animated resource changes use retained mutation routing instead of fallback full-frame rebuild + +### Phase 4: Legacy Fallback Retirement + +Goals: +- remove the older non-retained fallback render/update paths after retained parity is proven + +Tasks: +1. delete now-redundant drawable-first fallback paths in shared rendering/update code +2. remove temporary compatibility proxies where downstream consumers have retained replacements +3. update docs/specs to mark retained graph as the only authoritative runtime path + +Exit criteria: +- common render, interaction, and animation paths run entirely from retained scene state + +### Phase 5: Optional Native Animation Object Mapping + +Goals: +- explore mapping compatible retained SVG animation cases onto host-native animation objects +- keep shared retained runtime as the authoritative semantics layer + +Tasks: +1. identify safe subsets for native object mapping +2. prototype retained-node to host animation-object translation +3. add capability/fallback reporting without changing shared semantic ownership + +Exit criteria: +- optional native mapping exists only where parity and fallback behavior are explicit + +## Current Implementation Target + +This turn should finish the remaining Phases 1 through 4: +- remove the remaining editor root-drawable seams and keep retained nodes as the primary editor selection/render source +- delete the retained compiler's dead drawable-bridge path +- move the remaining animation fallback render path onto retained scene state +- update validation and status documentation to match the retained-scene-first runtime + +Validation for this slice: +- `dotnet format Svg.Skia.slnx --no-restore` +- `dotnet build Svg.Skia.slnx -c Release --no-restore` +- `dotnet test Svg.Skia.slnx -c Release --no-build` + +## Implementation Status + +Phases 1 through 4 are now effectively implemented in the current working tree for the shared runtime and editor workflows. + +Completed: +- selected-element export now prefers retained scene node rendering and falls back to drawable snapshot export only if no retained node export is available +- align/distribute and additional editor selection workflows now gather bounds and transforms from retained scene nodes first instead of depending on root drawable lookup +- layer loading no longer walks the root drawable tree to resolve editor layer metadata; retained scene nodes are the authoritative layer geometry source +- the retained compiler no longer keeps the drawable-bridge compilation path alive for unsupported element fallback; non-rendering containers compile directly as retained nodes +- dead drawable-bridge helper code has been removed from the retained compiler, reducing load-time duplication and eliminating the last internal drawable extraction path there +- animation frames that fall outside animation-layer caching now render from the retained scene document instead of calling the older drawable-first `RenderSvgDocument(...)` path +- regression coverage now proves retained-scene animation fallback for paint-server-backed animation targets and direct retained compilation for non-rendering container nodes + +Primary files: +- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Editor.Skia.Avalonia/SvgEditorWorkspace.axaml.cs` +- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Editor.Svg/LayerService.cs` +- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Skia/SKSvg.Model.cs` +- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Skia/SKSvg.SceneGraph.cs` +- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Skia/SceneGraph/SvgSceneCompiler.cs` +- `/Users/wieslawsoltes/GitHub/Svg.Skia/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs` +- `/Users/wieslawsoltes/GitHub/Svg.Skia/tests/Svg.Skia.UnitTests/SvgRetainedSceneGraphTests.cs` + +What remains after this file's original scope: +- the deepest future work is broader host-native animation object translation beyond the currently safe retained subset +- retained-first public APIs are now primary, and drawable-returning helpers are explicit legacy compatibility shims + +## Additional Status Update + +Completed after the original write-up: +- editor path/selection APIs no longer keep drawable fallbacks; retained scene nodes are the only geometry/edit source for those workflows +- retained-node and retained-element picture/model helpers are now the primary public API for fragment extraction +- drawable-returning public helpers such as drawable hit testing and retained-scene drawable creation are now legacy compatibility shims and are marked obsolete in favor of retained scene node APIs +- canvas-space retained scene hit testing now has direct scene-node overloads, so callers no longer need drawable proxy APIs for transformed hit tests +- retained resource-backed animation documents now participate in animation-layer caching, and the benchmark suite reflects resource-backed retained caching instead of the older fallback expectation +- Avalonia native composition now keeps the existing retained visual content when only layer visual state changes and the picture instance is unchanged, reducing unnecessary compositor content re-submission for the safe retained-native subset diff --git a/plan/scene-graph-major-release-cutover-plan.md b/plan/scene-graph-major-release-cutover-plan.md new file mode 100644 index 0000000000..ea21dc3ab5 --- /dev/null +++ b/plan/scene-graph-major-release-cutover-plan.md @@ -0,0 +1,118 @@ +# Scene Graph Major-Release Cutover Plan + +## Goal + +Make the retained scene graph the only runtime rendering model for Svg.Skia. + +For the major release: + +- `SvgDocument` remains the parsed DOM/source model. +- `Svg.SceneGraph` becomes the only renderable runtime representation. +- `Svg.Skia` becomes a scene-to-Skia adapter and host/runtime integration layer. +- drawable-based runtime conversion APIs are removed from the public surface. +- old compatibility seams are removed instead of preserved. + +## Status + +Implemented in the current branch: + +1. `SKSvg` runtime rendering paths now compile and render through `Svg.SceneGraph`. +2. `SvgService.ToDrawable(...)` and `SvgService.ToModel(...)` are removed. +3. `Svg.Controls.Avalonia`, `Svg.SourceGenerator.Skia`, `svgc`, and `SvgToPng` now use retained-scene model creation. +4. `Svg.SceneGraph` has its own project and owns retained runtime compilation/model creation. +5. drawable-based editor hit-selection fallback is removed. +6. `Svg.Model.Drawables` and drawable-only helpers are demoted to internal implementation instead of public runtime API. + +Remaining work after this cutover is optional follow-up, not required for the major-release architectural switch: + +1. delete drawable-only tests if the repo no longer wants to carry internal drawable implementation coverage +2. optionally move the remaining internal drawable implementation out of `Svg.Model` entirely if the team wants a stricter package split +3. optionally evolve native composition from retained-layer hosting into true host-native animation object mapping where parity can be preserved + +## Current Gap Summary + +The retained scene graph is already the primary runtime for rendering, hit testing, editor integration, incremental animation work, and native composition. The remaining cutover gaps are concentrated in legacy convenience APIs and a small number of drawable-era helper paths. + +The main seams to remove are: + +1. `SKSvg` still exposes drawable-shaped runtime state and static drawable-based helper paths. +2. `SvgService` still publishes `ToDrawable(...)` and `ToModel(...)`. +3. Some tooling and samples still depend on `SvgService.ToModel(...)` or drawable snapshots. +4. The scene graph still carries a stale `DrawableBridge` compilation strategy marker even though retained compilation is now the only intended strategy. + +## Required End State + +### Public/runtime architecture + +1. `Svg.Model.Drawables` is no longer part of the supported runtime API surface. +2. `SKSvg` no longer exposes `Drawable`. +3. `SvgService` no longer exposes drawable-based render conversion methods. +4. All runtime rendering paths use: + +`DOM -> Svg.SceneGraph -> Shim picture -> SkiaSharp picture` + +### Packaging split + +1. `Svg.SceneGraph` owns retained compilation, retained resources, retained hit testing, retained rendering, and retained model creation. +2. `Svg.Skia` owns SkiaSharp conversion, host animation/composition integration, and framework-facing helpers. +3. Consumers that need shim pictures should use retained-scene APIs instead of drawable conversion APIs. + +### Tooling/sample cutover + +1. `Svg.Controls.Avalonia` uses retained scene model creation. +2. `Svg.SourceGenerator.Skia` uses retained scene model creation. +3. `svgc` uses retained scene model creation. +4. `SvgToPng` uses retained scene model creation. + +## Implementation Plan + +### Phase 1: Introduce retained-scene render helpers + +1. Add a public retained-scene runtime helper in `Svg.SceneGraph` for: + - compiling an `SvgFragment` or `SvgDocument` + - creating a shim `SKPicture` +2. Ensure fragment rendering handles non-document fragments without requiring drawable fallback. + +### Phase 2: Remove drawable-based runtime entry points + +1. Reimplement `SKSvg` static helper paths on top of retained scene rendering. +2. Reimplement `SKSvg` document rendering on top of retained scene rendering only. +3. Remove `SKSvg.Drawable`. +4. Remove drawable assignments from animation-layer and render paths. + +### Phase 3: Migrate remaining consumers + +1. Update `Svg.Controls.Avalonia` to use retained-scene picture creation. +2. Update `Svg.SourceGenerator.Skia` to use retained-scene picture creation. +3. Update `svgc` to use retained-scene picture creation. +4. Update `SvgToPng` to use retained-scene picture creation and remove stored drawable state. + +### Phase 4: Remove drawable conversion APIs + +1. Delete `SvgService.ToDrawable(...)`. +2. Delete `SvgService.ToModel(...)`. +3. Remove unused drawable-related imports from `SvgService` and `SKSvg`. + +### Phase 5: Final architecture cleanup + +1. Remove the stale `DrawableBridge` compilation strategy state. +2. Keep only retained compilation strategy metadata. +3. Run full format/build/test validation and fix fallout. + +## Benefits + +1. One runtime truth instead of parallel drawable and retained render trees. +2. Lower memory overhead by avoiding duplicated intermediate render objects. +3. Faster loading by removing `DOM -> drawable -> picture` conversion. +4. Cleaner incremental updates because retained nodes/resources remain the only mutable render state. +5. Cleaner public API for the major release. +6. Better future portability because retained scene graph code stays SkiaSharp-independent. + +## Completion Criteria + +This cutover is complete when: + +1. no runtime-facing API in `SKSvg` exposes drawable state +2. no public `SvgService` API returns drawable-based render models +3. all remaining repo consumers use retained-scene model creation +4. full solution build and tests pass diff --git a/plan/svg-interaction-animation-phased-implementation.md b/plan/svg-interaction-animation-phased-implementation.md new file mode 100644 index 0000000000..f532f8aa72 --- /dev/null +++ b/plan/svg-interaction-animation-phased-implementation.md @@ -0,0 +1,528 @@ +# SVG Interaction and Animation Phased Implementation Plan + +## Goal + +Add a shared, renderer-level interaction and animation architecture to `Svg.Skia` that: + +- works across UI frameworks +- keeps SVG semantics in shared code +- uses framework-specific input, frame scheduling, invalidation, and optional native animation backends only at the edge + +This plan is intentionally split into phases so interaction can ship before the full SVG animation runtime. + +## Implementation status + +- Phase 1 interaction foundation has been implemented. +- Phase 2 event routing has been implemented. +- Phase 3 hit-test correctness has been implemented. +- Phase 4 shared animation runtime core has been implemented. +- Phase 5 performance and incremental redraw has been implemented for the current shared-renderer scope. +- Phase 6 optional native host animation backends has been implemented for host-side scheduling and backend selection. +- Post-phase follow-up work is now tracked in this document, with the first incremental redraw follow-up slice and the initial benchmark harness both implemented. +- The first implementation slice is in place in: + - `src/Svg.Skia/SKSvg.Interaction.cs` + - `src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs` + - `src/Svg.Controls.Skia.Avalonia/Svg.cs` + - `src/Svg.Controls.Skia.Uno/Svg.cs` + - `tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs` +- The shared animation runtime slice is now in place in: + - `src/Svg.Custom/Animation/SvgDocument.Animation.cs` + - `src/Svg.Skia/Animation/SvgAnimationClock.cs` + - `src/Svg.Skia/Animation/SvgAnimationController.cs` + - `src/Svg.Skia/Animation/SvgAnimationFrameState.cs` + - `src/Svg.Skia/Animation/SvgAnimationHostBackend.cs` + - `src/Svg.Skia/SKSvg.Model.cs` + - `src/Svg.Controls.Skia.Avalonia/Svg.cs` + - `src/Svg.Controls.Skia.Uno/Svg.cs` + - `tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs` + - `tests/Svg.Skia.UnitTests/SvgAnimationHostBackendResolverTests.cs` +- Current behavior delivered by this slice: + - topmost leaf hit testing for event targeting + - shared pointer dispatcher + - shared event stream independent of framework-specific event args + - optional bridge into `SvgElement` mouse events for ID-bearing elements + - Avalonia and Uno adapter wiring + - tunnel, target, and bubble routing through the SVG ancestry path + - pressed-target capture routing for shared move, wheel, and release dispatch until pointer release + - routed event bubbling from target to SVG ancestors + - handled-state short-circuiting for shared routed dispatch + - cursor hint resolution from SVG `cursor` attributes through the shared contract + - native cursor application in the Avalonia and Uno host wrappers + - typed `SvgPointerEvents` parsing/inheritance in `Svg.Custom` + - geometry-aware point hit testing for path-backed drawables instead of bounds-only checks + - pointer-event-specific fill/stroke/all target resolution through shared hit-testing + - clip-path and conservative mask-aware hit rejection during point targeting + - manual shared animation clock and controller in `Svg.Skia` + - DOM-clone-based animated document evaluation from the `Svg.Custom` animation object model + - typed target-property application through the generated `Svg` property metadata + - initial interpolation support for `set`, `animate`, `animateColor`, `animateTransform`, and `animateMotion` + - event-based SMIL `begin` and `end` timing tied to shared pointer dispatch + - `animateMotion` evaluation from `path`, `mpath`, `values`, and `from`/`to`/`by`-derived motion paths + - additive and accumulate handling for scalar, transform, color, and motion animation cases covered by the shared runtime + - `SKSvg` render-state rebuild on animation time changes + - shared animation invalidation contract consumed by the Avalonia and Uno wrappers + - cached frame-state evaluation in the shared animation controller + - dirty-target diffing between the last rendered animation frame and the next evaluated frame + - persistent animated-document reuse instead of per-frame DOM deep-copy churn + - no-op render suppression when different clock times resolve to the same effective SVG state + - explicit pending-frame and minimum-render-interval hooks for host-level throttling + - shared requested/actual host animation backend resolution with capability and fallback reporting + - Avalonia host playback backends using `DispatcherTimer` and `TopLevel.RequestAnimationFrame(...)` + - Uno host playback backends using `DispatcherQueueTimer` and `CompositionTarget.Rendering` + - animated-source cache isolation so replayed cached documents restart from a clean shared-runtime state +- The scoped phased plan is fully implemented. + +## Post-phase follow-up roadmap + +The scoped six-phase plan is complete. The remaining animation work is follow-up optimization and native-integration exploration beyond the original phased scope. + +### Order + +1. subtree and incremental picture invalidation in `SKSvg` +2. benchmark and profiling harness for animation-frame cost +3. limited true host-native composition mapping only after the shared runtime and incremental renderer are stable + +### Current follow-up slice + +- Implemented: + - incremental redraw in `SKSvg` using cached static content plus rebuilt animated top-level animated roots where the drawable/model pipeline can safely support it + - recursive subtree-level shim and `SkiaSharp.SKPicture` invalidation within animated top-level roots, reusing unchanged descendant subtree pictures instead of re-recording the whole animated top-level root on every effective frame + - benchmark and profiling harness for animation-frame cost in the shared `Svg.Skia` renderer + - retained native-composition mapping for the supported Avalonia host path, using one composition child visual per top-level SVG child and updating animated layers without falling back to the regular `Render(...)` path +- Delivered in the follow-up slices: + - stop rebuilding the full document picture on every effective animation frame in the common renderer path when the document can be safely split into static and animated top-level layers + - stop rebuilding the full animated top-level subtree picture when only a descendant target changes, by caching descendant subtree pictures and rebuilding only the dirty path to the animated root + - keep hit testing and interaction semantics aligned with the animated drawable state + - preserve the current shared SVG runtime as the source of truth + - add a local BenchmarkDotNet harness in `tests/Svg.Skia.Benchmarks` that compares layered animation-frame updates against defs-backed fallback rebuilds, with and without drawing + - expose a shared native-composition scene/frame extraction contract from `SKSvg` + - attach Avalonia retained visuals through the compositor child-visual API when the animated document can be represented as top-level native-composition layers +- Current limitation outside the shared runtime: + - Uno still falls back to the shared render-loop or dispatcher backends because the restored Uno package surface does not currently provide a working child-visual attachment path on the active target platforms +- Explicit non-goals for the implemented follow-up slices: + - no attempt to translate arbitrary SVG nodes into Avalonia or Uno composition objects + - no broad retained-scene-graph rewrite of the existing drawable system + - no full retained scene-graph rewrite of every drawable node; subtree invalidation remains layered on top of the existing drawable/picture pipeline + - no platform-specific reimplementation of SVG timing semantics + +## Scope + +### In scope + +- shared event contract driven by `SKSvg` hit testing +- framework adapters for Avalonia and Uno SVG controls +- shared animation runtime design for SVG 1.1 animation elements already modeled in `Svg.Custom` +- phased implementation strategy, validation plan, and performance gates + +### Out of scope for the first implementation slice + +- full SVG 1.1 bubbling/capturing event model +- DOM scripting execution from attribute strings +- full `pointer-events` paint/fill/stroke semantics +- SMIL timing engine completeness +- native-framework-specific accelerated animation backends + +## Current State + +### Event handling + +- `SKSvg` already exposes renderer-space hit testing in `src/Svg.Skia/SKSvg.HitTest.cs`. +- `HitTestService` returns all matches in tree order, not a single event target, in `src/Svg.Model/Services/HitTestService.cs`. +- Most drawables still use bounds-only hit testing in `src/Svg.Model/Drawables/DrawableBase.cs`. +- `SvgElement` exposes mouse events and the dormant `ISvgEventCaller` registration contract in `externals/SVG/Source/SvgElement.cs`. +- The repo does not currently wire any framework pointer events into that contract. + +### Rendering + +- `SKSvg` is a static picture model and snapshot replay engine in `src/Svg.Skia/SKSvg.Model.cs`. +- `AnimatedVectorDrawable` is explicitly unsupported in `src/Svg.Model/Services/VectorDrawableConverter.cs`. +- The Avalonia and Uno controls each render the SVG as a single custom surface, not as a framework-native visual tree. + +### Animation object model + +- `Svg.Custom` now has the SVG 1.1 animation element object model. +- There is still no runtime timeline evaluator, target-property resolver, or frame invalidation loop. + +## Architecture + +## 1. Shared interaction layer + +Add a shared interaction subsystem under `src/Svg.Skia/Interaction`: + +- `SvgPointerInput` +- `SvgPointerEventArgs` +- `SvgPointerDeviceType` +- `SvgMouseButton` +- `SvgPointerEventType` +- `SvgInteractionDispatcher` + +Responsibilities: + +- map picture-space pointer input to a single topmost SVG target +- maintain hover and pressed state +- emit a shared cross-framework event stream +- optionally bridge into `SvgElement.RegisterEvents(...)` for existing CLR event subscribers + +This layer must not reference Avalonia or Uno. + +## 2. Renderer-side target resolution + +Extend `SKSvg` with topmost hit testing: + +- `HitTestTopmostDrawable(SKPoint)` +- `HitTestTopmostElement(SKPoint)` +- canvas-matrix overloads + +Rules: + +- reverse child traversal to match draw order +- prefer the deepest painted leaf over ancestor containers +- honor `display`, `visibility`, and `pointer-events:none` +- leave full SVG `pointer-events` semantics for a later phase + +## 3. Framework adapters + +Each host framework keeps raw input and invalidation at the control layer: + +- Avalonia: override pointer methods on `Avalonia.Svg.Skia.Svg` +- Uno: subscribe to control pointer events on `Uno.Svg.Skia.Svg` because the current host base type does not expose the same virtual pointer override surface + +Adapters translate framework pointer events into `SvgPointerInput` and feed the shared dispatcher. + +They remain responsible for: + +- pointer capture +- coordinate conversion from control to picture space +- invalidation +- framework cursor integration later + +## 4. Shared animation runtime + +The animation runtime should live in shared code, not inside Avalonia or Uno: + +- `SvgAnimationClock` +- `SvgAnimationController` +- target resolution from animation element to animated element/property +- timing state evaluation +- typed interpolation pipeline +- animated-value overlay over the static DOM model + +The framework host only supplies: + +- current time +- frame scheduling +- invalidation + +This keeps SVG timing semantics portable and testable. + +## 5. Optional framework-native animation backends + +Later, specific animation classes may map onto host-native engines when the target is compatible: + +- Avalonia transitions/composition +- Uno XAML storyboards +- Uno/WinUI composition where supported + +This is an optimization layer only. The shared SVG runtime remains the source of truth. + +## Phase Breakdown + +## Phase 1: Interaction foundation + +### Deliverables + +- shared pointer contract +- topmost element hit testing in `SKSvg` +- shared dispatcher with hover/press/click tracking +- bridge into `SvgElement.RegisterEvents(...)` for ID-bearing elements +- Avalonia and Uno control integration at the host control layer +- unit tests for target resolution and dispatcher behavior + +### Behavioral constraints + +- leaf-target only, no bubbling yet +- `pointer-events:none` honored +- no full SVG `pointer-events` painted/fill/stroke matrix yet +- one active pointer state per dispatcher instance +- mouse-style event bridging only, matching current upstream `SvgElement` events + +### Implementation details + +- use reverse traversal of `DrawableContainer.ChildrenDrawables` +- do not target ancestor containers directly in phase 1 +- register upstream `SvgElement` event actions only for elements with non-empty `ID` +- surface a shared dispatcher event stream even when no SVG `ID` exists +- reset interaction state when source content changes + +### Validation + +- topmost hit target for overlapping siblings +- child beats ancestor container +- `pointer-events:none` skips the front element +- move enters/leaves correctly +- press/release same target produces click +- existing `SvgElement.Click`, `MouseDown`, `MouseUp`, `MouseOver`, `MouseOut`, `MouseMove`, `MouseScroll` can fire through the bridge for ID-bearing elements + +## Phase 2: Event routing semantics + +### Deliverables + +- ancestor event path building +- bubble route +- optional tunnel/capture route if needed +- shared event cancellation model +- cursor contract + +### Status + +- Implemented: + - target-to-root ancestry path routing + - bubble routing for shared events and the compatibility `SvgElement` bridge + - tunnel routing for shared routed events ahead of target/bubble dispatch + - pressed-target capture routing in the shared dispatcher until pointer release + - `Handled` short-circuiting in shared routed events + - shared cursor hint resolution via inherited `cursor` attributes + - framework-native cursor application in the Avalonia and Uno wrappers + +### Implementation details + +- compute ancestry from target element to document root +- preserve leaf target while routing through ancestors +- add `Handled` support to shared event args +- expose cursor hints without hard-coding framework cursor types into shared code + +### Validation + +- group/anchor ancestors receive routed events +- handled state stops downstream propagation where configured + +## Phase 3: Hit-test correctness + +### Deliverables + +- geometry-aware hit testing beyond bounds for more drawable types +- better stroke/fill hit rules +- clip and mask awareness where practical +- typed `pointer-events` model in `Svg.Custom` + +### Implementation details + +- move from bounds-only to geometry-aware checks incrementally per drawable type +- support `visiblePainted`, `visibleFill`, `visibleStroke`, `painted`, `fill`, `stroke`, `all` +- keep a conservative fallback path for unsupported shapes + +### Validation + +- path stroke hit precision +- fill-only vs stroke-only cases +- clipped shapes not receiving hits outside clip + +### Status + +- Implemented: + - typed `SvgPointerEvents` enum/converter/property in `Svg.Custom` + - shared pointer-event-aware target evaluation in `HitTestService.HitTestPointer(...)` + - geometry-aware point hit testing for `DrawablePath`-based shapes + - `use` and marker wrappers delegating hit testing to their referenced drawables + - clip-path rejection and conservative mask rejection for point targeting + - focused model and renderer tests for `pointer-events`, stroke-only hits, and clipped hits +- Current limitations within the phase: + - text hit testing still uses conservative bounds instead of glyph-outline geometry + - rectangle hit-test APIs remain bounds-based; the geometry-aware work in this phase is for point targeting + - mask handling is geometry-based, not alpha-precise + - hidden elements that would require `pointer-events` modes ignoring visibility are still limited by the current drawable-creation path + +## Phase 4: Shared animation runtime core + +### Deliverables + +- animation clock abstraction +- animation timeline evaluator +- target-property resolution +- animated-value overlay state +- invalidation contract back into controls/render hosts + +### Implementation details + +- treat the DOM as base state and runtime animation values as overlays +- start with discrete, numeric, color, transform, and motion interpolation +- parse shared timing fields from animation elements already added in `Svg.Custom` +- rebuild or refresh render state from animated overlays per frame + +### Validation + +- `set` +- `animate` +- `animateColor` +- `animateTransform` +- `animateMotion` +- timing attributes: `begin`, `dur`, `end`, `repeatCount`, `repeatDur`, `fill` + +### Status + +- Implemented: + - manual `SvgAnimationClock` + - shared `SvgAnimationController` + - source-document cloning via `SvgDocument.DeepCopy()` + - animated target resolution by cloned DOM address + - runtime application through the generated typed attribute setters + - `SKSvg.SetAnimationTime(...)`, `AdvanceAnimation(...)`, and `AnimationInvalidated` + - host invalidation wiring in the Avalonia and Uno controls + - event-based `begin` and `end` timing support using the shared interaction event stream + - `animateMotion` support for `path`, `mpath`, `values`, and synthesized `from`/`to`/`by` motion paths + - additive and accumulate handling for the phase-4 scalar, color, transform, and motion scope +- Phase result: + - Phase 4 is complete for the planned shared-runtime scope. + +## Phase 5: Performance and incremental redraw + +### Deliverables + +- dirty-target tracking +- selective rebuild strategy +- cached interpolation state +- animation throttling hooks + +### Implementation details + +- separate target/property evaluation from full picture rebuild when possible +- keep frame scheduling outside the shared runtime +- add profiling around rebuild cost, hit testing, and animation tick cost + +### Validation + +- frame-to-frame allocations +- large SVG animation scenarios +- host invalidation cadence + +### Status + +- Implemented: + - cached `SvgAnimationFrameState` evaluation and reuse in `SvgAnimationController` + - dirty-target counting and diff-based attribute application + - persistent animated `SvgDocument` reuse inside `SKSvg` + - no-op rebuild suppression for equivalent animation states + - `AnimationMinimumRenderInterval`, `HasPendingAnimationFrame`, `FlushPendingAnimationFrame()`, and `LastAnimationDirtyTargetCount` + - recursive subtree-level picture invalidation for animated top-level roots, with nested shim-picture composition and cached descendant `SkiaSharp.SKPicture` reuse + - benchmark and profiling harness for shared animation-frame cost + - unit coverage for equivalent-frame suppression, pending throttled frames, and reversion when an animation becomes inactive +- Remaining limitations within the phase: + - subtree invalidation now covers top-level animated roots, nested animated subtrees within those roots, and rendered wrapper/generated drawables such as `use`, `switch`, and marker-hosted drawables, but it is still not a general retained scene-graph renderer + - the renderer now stops dirty-path rebuilds at the selected cache root inside the animated scope instead of always walking back to the animated top-level root, but it still recomposes the touched top-level animated scope on top of the static layer + - non-drawable resource pipelines such as gradients, clip-path resources, and filter graphs still use the fallback full-frame rebuild path because they do not map cleanly onto retained drawable subtrees + +### Follow-up implementation direction + +- first reduce animation-frame work by caching static document content and re-recording only the animated document regions that contain active animation targets +- then refine the animated-layer path by caching descendant subtree pictures and only rebuilding the dirty path through the existing drawable graph +- keep the animated drawable tree authoritative for hit testing so pointer routing follows animated geometry +- treat the first redraw optimization as top-down and conservative; correctness takes priority over the finest possible invalidation granularity + +## Phase 6: Optional native host animation backends + +### Deliverables + +- Avalonia adapter experiment +- Uno/WinUI adapter experiment +- capability matrix and fallback rules + +### Implementation details + +- host-native backends in this implementation only provide scheduling and invalidation; SVG timing/value evaluation stays in the shared `SKSvg` runtime +- the exposed contract is shared across wrappers: + - `SvgAnimationHostBackend` + - `SvgAnimationHostBackendCapabilities` + - `SvgAnimationHostBackendResolution` + - `SvgAnimationHostBackendResolver` +- Avalonia uses: + - `DispatcherTimer` for timer-driven playback + - `TopLevel.RequestAnimationFrame(...)` for render-loop playback when attached to a `TopLevel` +- Uno uses: + - `DispatcherQueueTimer` + - `CompositionTarget.Rendering` +- wrapper defaults remain non-animated until opted in: + - requested backend defaults to `Manual` + - `Default` picks `RenderLoop`, then `DispatcherTimer`, then `Manual` +- wrappers expose requested backend, frame interval, playback rate, actual backend, fallback reason, and capability matrix +- render-loop availability is attachment-sensitive; detached controls fall back to `Manual` rather than running timers against an inactive host +- animated cache entries are cloned per playback session so cached SVG content does not retain advanced animation time between reloads +- this phase does not map SVG elements onto framework-native visual/composition trees; that remains a future optimization layer if ever needed + +### Validation + +- build and test coverage for backend resolution and fallback rules in shared tests +- identical animated rendering semantics versus the shared runtime because all backends drive the same `SKSvg.AdvanceAnimation(...)` pipeline +- safe fallback to `Manual` or `DispatcherTimer` when the requested backend is unavailable + +### Status + +- Implemented: + - shared backend-selection contract in `src/Svg.Skia/Animation/SvgAnimationHostBackend.cs` + - Avalonia host playback integration in `src/Svg.Controls.Skia.Avalonia/Svg.cs` + - Uno host playback integration in `src/Svg.Controls.Skia.Uno/Svg.cs` + - backend fallback-rule coverage in `tests/Svg.Skia.UnitTests/SvgAnimationHostBackendResolverTests.cs` +- Remaining limitations within the phase: + - host-native backends currently schedule the shared runtime; they do not translate SVG nodes into framework-native animation objects + - wrapper-level capability reporting is attachment-time state, not a static platform-wide capability table + +## File and Type Plan + +### Shared interaction + +- `src/Svg.Skia/SKSvg.Interaction.cs` +- `src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs` + +### Host adapters + +- `src/Svg.Controls.Skia.Avalonia/Svg.cs` +- `src/Svg.Controls.Skia.Uno/Svg.cs` + +### Shared animation runtime + +- `src/Svg.Skia/Animation/` new folder in later phases + +### Tests + +- `tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs` +- later: animation runtime tests in `tests/Svg.Skia.UnitTests` +- later: host smoke tests where practical + +## Public API Direction + +### Phase 1 public additions + +- `SKSvg.HitTestTopmostDrawable(...)` +- `SKSvg.HitTestTopmostElement(...)` +- `SvgInteractionDispatcher` +- `SvgPointerInput` +- `SvgPointerEventArgs` +- `SvgPointerDeviceType` +- `SvgMouseButton` +- `SvgPointerEventType` +- `Avalonia.Svg.Skia.Svg.Interaction` +- `Uno.Svg.Skia.Svg.Interaction` + +### Later public additions + +- routed event path APIs +- cursor contract +- animation clock/controller/runtime types +- host invalidation/tick abstraction + +## Risk Areas + +- current hit test precision is not yet good enough for full `pointer-events` parity +- `SvgElement.RegisterEvents(...)` assumes IDs and a mouse-centric event surface +- `Use`, markers, clipping, masks, and text spans need targeted semantic decisions +- a full animation engine can become expensive if every tick forces a whole-picture rebuild + +## Immediate Implementation Slice + +Implement phase 1 now: + +1. add the plan document +2. add topmost hit testing to `SKSvg` +3. add the shared interaction dispatcher and event bridge +4. wire Avalonia and Uno controls to feed the dispatcher +5. add focused unit tests +6. build and test the solution diff --git a/plan/svg-retained-scene-graph-rewrite-spec.md b/plan/svg-retained-scene-graph-rewrite-spec.md new file mode 100644 index 0000000000..80f3374f9d --- /dev/null +++ b/plan/svg-retained-scene-graph-rewrite-spec.md @@ -0,0 +1,499 @@ +# SVG Retained Scene-Graph Rewrite + +## Status +- In progress +- Scope: shared `Svg.Skia` retained renderer architecture, loading pipeline rewrite, incremental updates, and host integration strategy +- This document defines the target architecture and the phased implementation plan for replacing the current transient `SvgElement -> Drawable -> SKPicture` pipeline with a retained scene graph + +## Current Branch Status + +The retained-scene rewrite is no longer only a proposed architecture. This branch now includes a working retained scene document, a DOM-first compiler path, retained resource indexing, and a mutation router that can update the live retained graph in place. + +- `SvgSceneDocument`, `SvgSceneNode`, `SvgSceneCompiler`, and `SvgSceneRenderer` exist in shared `Svg.Skia` +- `SKSvg` exposes a lazy `RetainedSceneGraph` plus `CreateRetainedSceneGraphModel()` and `CreateRetainedSceneGraphPicture()` +- retained scene compilation is driven from the SVG DOM tree, not from the live root drawable tree +- retained resources are indexed and dependency-tracked for gradients, patterns, markers, masks, clip paths, paint servers, and related reference-style resources +- scene mutation routing can update the current retained graph in place from DOM element mutations and animation frame deltas +- animation frame integration now attempts retained-scene mutation first and only falls back to full retained invalidation when the mutation path cannot safely service the update +- parity tests cover indexing, one-to-many address mapping for `use`, retained rendering parity, retained-graph reuse across animation updates, and dependency-driven updates for `use` and resource-backed content + +This is still not the final architecture. The retained graph now has the correct structural direction, but the runtime is still in transition: + +- core path-based primitives (`path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`) now compile local retained visuals directly without transient per-element drawable generation +- structural wrappers such as `g`, `a`, and nested `svg` fragments now compile wrapper state directly as retained nodes without transient drawable extraction +- text, masked, and filtered visual nodes now compile and render as retained scene nodes without per-element transient drawable ownership in the scene graph +- retained text compilation now covers nested `tspan`, `textPath`, and `tref` content with retained geometry estimation and parity-tested retained playback +- retained resources are indexed, dependency-tracked, and now own clip, mask, and filter runtime payload generation directly inside `Svg.Skia`, with retained `feImage` evaluation and retained-source filter inputs instead of transient drawable snapshots +- retained paint runtime now owns fill, stroke, opacity, gradients, and patterns inside `Svg.Skia`, including retained pattern tile rendering instead of drawable snapshot recording +- direct retained marker expansion is now in place for path-based marker elements, including inherited group marker settings, without going through `MarkerService` and `MarkerDrawable` +- the retained renderer is available and update-aware, but it is not yet the single mandatory primary render path for every host feature +- hit testing and native host extraction are now driven from retained scene state, including retained visibility, pointer-event, background, transform, and opacity metadata, with lightweight drawable proxies only at legacy API boundaries +- the retained scene root now compiles as a direct retained fragment instead of relying on an implicit drawable-backed root bridge +- `SvgSceneDocument` and `SKSvg` now expose retained-scene-first lookup, mutation, node-rendering, and hit-testing APIs for editor/tooling consumers +- retained mutation coverage now explicitly includes mask-resource and filter-resource dependent updates, and parity coverage now includes masked rich-text documents + +## Problem Statement + +`Svg.Skia` currently renders by: + +1. Parsing SVG into the mutable `SvgDocument` DOM +2. Converting DOM subtrees into transient `DrawableBase` trees +3. Recording transient drawables into shim `SKPicture` models +4. Converting shim pictures into `SkiaSharp.SKPicture` + +That architecture works, but it has structural limits: + +- loading cost is front-loaded into repeated object graph construction +- updates to the SVG model often rebuild too much state +- animation and interaction have to layer invalidation on top of a transient renderer +- resource resolution, draw ordering, and host-native composition have no single retained source of truth +- the fast path today is cache-oriented, not graph-oriented + +The retained rewrite replaces the transient renderer with a retained scene graph that owns: + +- renderable node topology +- resolved geometry, paint, clipping, masking, filter, and text state +- resource dependency tracking +- address-based lookup for granular model updates +- subtree invalidation and regeneration +- host-facing composition extraction + +## Goals + +- State-of-the-art retained rendering architecture for SVG in shared `Svg.Skia` +- Fast initial load through staged compilation and reusable caches +- Very fast granular updates from SVG DOM mutations to scene graph mutations +- Eliminate full-picture rebuilds for common subtree updates +- Single canonical retained graph for: + - static rendering + - animation + - hit testing + - native composition extraction + - editor/inspection tooling +- Preserve visual correctness relative to the current renderer during transition +- Keep the implementation framework-neutral, with Avalonia/Uno/native host adapters layered on top + +## Non-Goals + +- Rewriting the external SVG DOM library in the first wave +- Shipping a second permanent renderer beside the retained graph +- Cutting corners with host-specific rendering logic in the shared core +- Treating animation caching as the long-term architecture + +## Target Architecture + +### Layers + +1. DOM Layer +- `SvgDocument` and `SvgElement` remain the authoring and compatibility object model + +2. Compilation Layer +- Converts DOM and resolved resources into retained scene nodes and retained resources +- Tracks dependencies between DOM addresses and scene nodes/resources + +3. Retained Scene Layer +- Mutable graph of retained render nodes and retained resources +- Owns node identity, invalidation, subtree rebuild, and render ordering + +4. Rendering Layer +- Traverses retained scene nodes into: + - shim `SKPicture` + - `SkiaSharp.SKPicture` + - native-composition layer extraction + +5. Host Layer +- Avalonia, Uno, and future hosts consume retained scene outputs and invalidation signals + +### Core Principles + +- The retained graph is the source of truth, not a cache +- Resources are first-class graph objects +- Node identity is stable across updates +- Dirty propagation is structural and dependency-aware +- Subtree rebuilds are explicit and bounded +- Rendering is pure playback from retained state +- Host-native composition is an extraction of retained state, not a parallel animation system + +## Retained Scene Data Model + +### Retained Document + +`SvgSceneDocument` + +- root scene node +- cull bounds / viewport / viewBox metadata +- lookup by SVG element address key +- lookup by SVG id +- resource registry +- dependency graph +- revision counter +- invalidation queue + +### Retained Nodes + +`SvgSceneNode` + +- stable scene node id +- source SVG element reference and address key +- node kind +- parent pointer +- ordered children +- optional local display list +- transform state +- clip/overflow state +- mask/filter/opacity state +- geometry bounds +- transformed bounds +- dirty flags +- version / generation counters + +### Retained Resources + +Resources become retained objects, not incidental data hidden inside drawables: + +- clip paths +- masks +- gradients +- patterns +- paint servers +- filters and filter subgraphs +- resolved images +- resolved text assets / glyph runs + +Each resource has: + +- stable resource id +- dependency list +- reverse dependency list +- dirty flags +- resolved runtime payload + +### Display Lists + +Each scene node owns a local display list containing only node-local visuals. + +- Parent wrapper state is not baked into child display lists +- Parent clip/mask/filter/opacity scopes are replayed structurally by the retained renderer +- Child ordering remains explicit in the scene graph + +## Loading Pipeline Rewrite + +### Current + +`SvgDocument -> DrawableFactory -> Drawable tree -> Snapshot -> SKPicture` + +### Target + +`SvgDocument -> Scene compilation -> Retained scene/resource graph -> Render` + +### Compilation Stages + +1. Parse DOM +- existing SVG DOM parser remains the front door + +2. Resolve document metadata +- viewport, viewBox, units, external asset resolution context + +3. Pre-index DOM +- stable address keys +- id map +- element type map +- mutation routing tables + +4. Compile retained resources +- gradients, patterns, clip paths, masks, filters, markers, symbol/use expansions + +5. Compile retained nodes +- scene node tree with local display lists and structural visual state + +6. Build dependency graph +- scene node -> resource dependencies +- resource -> dependent scene nodes +- DOM address -> scene node/resource mappings + +7. Produce retained document +- immutable-at-boundary / mutable-internally retained graph + +### Loading Optimizations + +- parser-side pre-indexing of ids and addresses +- stable URI and asset resolution caches +- resource interning for repeated paint/filter structures +- local display list memoization for unchanged subtrees +- direct compilation to scene nodes without round-tripping through transient drawables in the final architecture + +## Update Model + +### Update Sources + +- animation frame mutations +- interaction-driven mutations +- editor changes to DOM attributes +- resource changes +- external asset reloads + +### Update Flow + +1. DOM mutation arrives with element address and changed attribute set +2. mutation router maps changed addresses to retained nodes/resources +3. dependency graph expands affected region +4. dirty flags propagate to: +- directly affected nodes/resources +- dependent resources +- dependent ancestor layout/clip scopes as required +5. compiler regenerates only dirty subtrees/resources +6. renderer replays only dirty retained outputs + +### Dirty Granularity Rules + +- inherited style changes dirty dependent descendant nodes +- transform changes dirty subtree bounds and composition extraction +- resource mutations dirty all dependents +- text mutations dirty text shaping and affected layout nodes +- filter changes dirty dependent filter graph and target nodes + +## Rendering Model + +### Retained Renderer Responsibilities + +- replay node-local display lists +- manage transform stack +- manage clip/overflow stack +- manage filter/opacity/mask scopes +- preserve draw order +- produce subtree pictures on demand +- support incremental render targets + +### Render Targets + +- full shim `SKPicture` +- full `SkiaSharp.SKPicture` +- subtree shim picture +- subtree native picture +- extracted host-native composition layer list + +### Native Composition + +Native composition becomes a direct extraction pass over retained nodes: + +- select renderable retained layer roots +- extract stable bounds, opacity, transforms, and local display lists +- keep scene-node identity stable across frames +- reissue only changed retained layers + +## Hit Testing and Interaction + +The retained graph becomes the authoritative hit-test graph: + +- geometry-aware hit testing per retained node +- retained clip and mask constraints +- z-order and event route derivation directly from retained node order +- pointer-events and visibility logic evaluated against retained node state + +## Animation Integration + +Animation does not rebuild transient drawables. + +Instead: + +1. animation runtime resolves attribute values +2. mutation router applies updates to retained scene state +3. dirty regions propagate through retained nodes/resources +4. only impacted retained outputs are regenerated + +## Transition Strategy + +The rewrite must be staged, but the target is a full replacement. + +### Stage A +- land retained scene core types +- land compiler bridge from current drawable output +- land retained renderer +- wire `SKSvg` to build and expose retained scene documents + +### Stage B +- use retained scene as the primary render path behind a feature toggle +- validate image parity against current output +- feed native composition and hit testing from retained nodes + +### Stage C +- remove transient animation-layer caching architecture +- route animation invalidation through retained nodes/resources + +### Stage D +- remove drawable-tree-first rendering path +- compile retained scene directly from DOM/resources + +## Implementation Status Against Plan + +### Phase 1: Retained Scene Foundation + +Implemented in this branch: + +- retained node, resource, and document types +- retained scene renderer +- `SKSvg` retained graph creation and exposure +- indexing by address and id +- scene-node identity and root replacement support + +### Phase 2: Renderer Parity Path + +Implemented for representative coverage: + +- retained-render parity tests for full-scene playback +- retained parity coverage for `use`, markers, generated drawable children, and animation-frame refresh +- retained parity coverage for rich text (`tspan`, `textPath`, `tref`) and resource-backed pattern paints +- retained parity coverage for masked rich-text documents + +Still open: + +- broader parity expansion around the hardest combined filter-plus-text edge cases + +### Phase 3: Incremental Static Updates + +Implemented in this branch: + +- address-based mutation routing +- retained dependency graph +- in-place subtree recompilation and node replacement +- id-based and resource-based reverse dependency expansion +- public retained-scene lookup and mutation entry points for address-key, id, and node-targeted tooling flows + +### Phase 4: Animation Rewrite + +Implemented for retained-scene integration: + +- animation frame deltas are routed into retained scene mutations +- retained scene documents are reused across animation updates when routing succeeds + +Still open: + +- fully remove the older non-retained fallback paths once retained coverage is proven across all remaining edge cases + +### Phase 5: Resource Graph Rewrite + +Implemented in this branch for the retained runtime path: + +- retained resource indexing +- resource dependency tracking +- mutation expansion from resource changes to dependent scene roots +- retained-owned clip, mask, and filter runtime payload resolution +- retained-owned fill/stroke/opacity paint resolution +- retained-owned gradient and pattern paint server evaluation, including retained pattern tile playback +- retained runtime payload refresh for temporary mask subtrees + +Still open: + +- eliminate remaining fallback full rebuilds for the most complex resource pipelines +- continue replacing legacy helper usage in residual utility paths where retained-owned evaluators are not yet complete + +### Phase 6: Direct DOM-to-Scene Compilation + +Implemented structurally in this branch: + +- retained graph traversal is DOM-first +- compilation roots and resource roots are derived from DOM element identity and addresses +- direct retained local-visual compilation is in place for core path-based primitives +- retained nodes now record compilation strategy so the shrinking drawable bridge is explicit and testable +- the retained document root now compiles directly instead of entering the retained graph through the drawable bridge +- degenerate path-based visual elements now stay on the direct retained path instead of dropping back to drawable-backed placeholder nodes +- retained no-bridge coverage now includes selected W3C shape and DOM documents that previously exposed the last direct-compilation seam + +Still open: + +- replace the remaining per-element drawable extraction bridge for the last unsupported visual/resource pipelines with direct retained display-list generation +- reduce load-time duplication caused by temporary drawable generation for compatibility-only payload extraction paths + +### Phase 7: Host and Tooling Convergence + +Implemented for runtime plumbing: + +- point hit testing and topmost hit testing are evaluated against retained scene metadata rather than live DOM state +- native composition extraction is driven from retained scene nodes and retained node state rather than drawable-tree traversal +- retained-scene-first node/resource lookup, node rendering, and mutation APIs are available on `SvgSceneDocument` and `SKSvg` +- retained picture parity coverage now includes selected external W3C text, masking, and filter documents in addition to the synthetic in-repo stress cases +- editor-facing layer/tooling entry points now bind retained scene nodes directly for layer inspection and retained bounds overlays instead of depending only on drawable lookup +- `SKSvg` now exposes retained-scene element lookup and retained-node hit-test wrappers so downstream tools can stay on retained state instead of rebuilding legacy proxies first +- editor selection bounds, resize/skew/rotate handle setup, path-edit transforms, polygon/polyline edit transforms, and related workspace selection state now resolve retained scene nodes first and only fall back to drawables when retained nodes are unavailable +- editor path-selection activation and document-tree path/poly edit entry points now start from retained scene node transforms instead of drawable transforms, with regression coverage for retained edit matrices + +Still ahead in the plan: + +- remove the remaining compatibility proxies at legacy public API boundaries once downstream callers can move to retained-scene APIs +- migrate remaining editor commands that still require drawable snapshots or drawable ordering helpers, such as selected-element export, align/distribute layout helpers, and path/shape manipulation helpers that still depend on drawable-specific geometry extraction + +## Full Implementation Plan + +### Phase 1: Retained Scene Foundation + +- add retained node/resource/document types +- add scene compiler bridge from drawables +- add retained renderer that replays structural state and local display lists +- expose retained scene on `SKSvg` +- add lookup and dirty-marking infrastructure + +### Phase 2: Renderer Parity Path + +- add parity tests comparing retained renderer output to current output +- make retained renderer available for static full-document rendering +- verify masks, filters, text, markers, `use`, nested fragments, and clipping + +### Phase 3: Incremental Static Updates + +- add DOM mutation router +- add dependency graph +- add subtree recompilation and retained output regeneration +- add address-based and id-based scene updates + +### Phase 4: Animation Rewrite + +- route animation runtime into retained mutations +- remove drawable rebuilds from animated frame generation +- replace top-level layer caching with retained subtree invalidation + +### Phase 5: Resource Graph Rewrite + +- compile gradients/patterns/filters/masks into retained resources +- add granular dependency invalidation +- remove fallback full rebuilds for resource changes + +### Phase 6: Direct DOM-to-Scene Compilation + +- bypass transient drawable tree for retained compilation +- keep drawables only as compatibility/editor helpers if still needed +- optimize load time and memory footprint + +### Phase 7: Host and Tooling Convergence + +- feed native composition from retained graph only +- feed hit testing from retained graph only +- expose scene graph inspection hooks for editor/tooling + +## Risks + +- visual parity regressions in mask/filter/text edge cases +- scene node local-display-list decomposition mistakes +- accidental duplication of wrapper state between node and local display list +- temporary memory pressure during transition while both pipelines exist + +## Success Criteria + +- retained scene graph is the primary shared renderer +- static and animated updates use granular subtree invalidation +- no full transient drawable rebuild for common attribute animations +- native composition extraction is based on retained scene nodes +- model updates are address-routed and subtree-bounded +- load time and steady-state frame time improve relative to the current pipeline + +## Implemented Rewrite Slice In This Branch + +This branch now contains the first meaningful retained-scene rewrite tranche: + +- retained scene graph core types +- retained scene renderer for full-scene playback +- DOM-first retained compiler with generated-child bridging where required +- retained resource registry and reverse dependency graph +- in-place mutation routing for DOM edits and animation frame updates +- focused parity/update tests for representative static, generated, `use`, and resource-backed cases + +That means the rewrite has moved past a pure bridge prototype. The current implementation is already exercising the retained graph as a live mutable rendering structure, while still keeping selective compatibility bridges where the final direct retained compiler is not finished yet. diff --git a/samples/SvgToPng/ViewModels/Item.cs b/samples/SvgToPng/ViewModels/Item.cs index f8b1693ed4..4694a91b15 100644 --- a/samples/SvgToPng/ViewModels/Item.cs +++ b/samples/SvgToPng/ViewModels/Item.cs @@ -23,9 +23,6 @@ public class Item : IDisposable [IgnoreDataMember] public SvgDocument Document { get; set; } - [IgnoreDataMember] - public SKDrawable Drawable { get; set; } - [IgnoreDataMember] public SKPicture Picture { get; set; } @@ -44,7 +41,6 @@ public class Item : IDisposable public void Reset() { Document = null; - Drawable = null; Picture = null; Code = null; SkiaPicture?.Dispose(); diff --git a/samples/SvgToPng/ViewModels/MainWindowViewModel.cs b/samples/SvgToPng/ViewModels/MainWindowViewModel.cs index fe0b76c8e4..ac9fdffb66 100644 --- a/samples/SvgToPng/ViewModels/MainWindowViewModel.cs +++ b/samples/SvgToPng/ViewModels/MainWindowViewModel.cs @@ -125,12 +125,9 @@ private void LoadSvg(Item item, Action statusOpen, Action status { var stopwatchToPicture = Stopwatch.StartNew(); - var references = new HashSet { item.Document.BaseUri }; - item.Drawable = SvgService.ToDrawable(item.Document, _assetLoader, references, out var bounds); - if (item.Drawable is { } && bounds is { }) + item.Picture = SvgSceneRuntime.CreateModel(item.Document, _assetLoader); + if (item.Picture is { }) { - item.Picture = item.Drawable.Snapshot(bounds.Value); - item.SkiaPicture = _skiaModel.ToSKPicture(item.Picture); if (item.Picture?.Commands is { }) diff --git a/samples/TestApp/Models/Configuration.cs b/samples/TestApp.Shared/Models/Configuration.cs similarity index 81% rename from samples/TestApp/Models/Configuration.cs rename to samples/TestApp.Shared/Models/Configuration.cs index b04315437d..ee27c49dce 100644 --- a/samples/TestApp/Models/Configuration.cs +++ b/samples/TestApp.Shared/Models/Configuration.cs @@ -2,7 +2,7 @@ namespace TestApp.Models; -public class Configuration +public sealed class Configuration { public List? Paths { get; set; } public string? Query { get; set; } diff --git a/samples/TestApp.Shared/Services/ITestAppStorageService.cs b/samples/TestApp.Shared/Services/ITestAppStorageService.cs new file mode 100644 index 0000000000..68ef97ff96 --- /dev/null +++ b/samples/TestApp.Shared/Services/ITestAppStorageService.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace TestApp.Services; + +public interface ITestAppStorageService +{ + Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default); + + Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default); + + Task> PickSvgPathsAsync(CancellationToken cancellationToken = default); + + Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default); +} + +public sealed class TestAppSaveStreamResult +{ + public TestAppSaveStreamResult(Stream stream, string name) + { + Stream = stream; + Name = name; + } + + public Stream Stream { get; } + + public string Name { get; } +} + +internal sealed class NullTestAppStorageService : ITestAppStorageService +{ + public static NullTestAppStorageService Instance { get; } = new(); + + public Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task> PickSvgPathsAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(System.Array.Empty()); + + public Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default) + => Task.FromResult(null); +} diff --git a/samples/TestApp.Shared/Services/PathLauncher.cs b/samples/TestApp.Shared/Services/PathLauncher.cs new file mode 100644 index 0000000000..a24e3cb25d --- /dev/null +++ b/samples/TestApp.Shared/Services/PathLauncher.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace TestApp.Services; + +public static class PathLauncher +{ + public static void OpenInExplorer(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start("explorer", path); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", path); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", path); + } + } + + public static void OpenInTextEditor(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start("notepad", path); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", path); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", "-t " + path); + } + } +} diff --git a/samples/TestApp.Shared/Services/TestAppExportService.cs b/samples/TestApp.Shared/Services/TestAppExportService.cs new file mode 100644 index 0000000000..5dc3091fe8 --- /dev/null +++ b/samples/TestApp.Shared/Services/TestAppExportService.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Threading.Tasks; +using SkiaSharp; +using Svg.Skia; + +namespace TestApp.Services; + +public static class TestAppExportService +{ + public static Task ExportAsync( + Stream stream, + string name, + SKPicture? picture, + string backgroundColor = "#00FFFFFF", + float scaleX = 1f, + float scaleY = 1f) + { + if (picture is null) + { + return Task.CompletedTask; + } + + if (!SKColor.TryParse(backgroundColor, out var skBackgroundColor)) + { + return Task.CompletedTask; + } + + switch (Path.GetExtension(name).ToLowerInvariant()) + { + case ".png": + picture.ToImage( + stream, + skBackgroundColor, + SKEncodedImageFormat.Png, + 100, + scaleX, + scaleY, + SKColorType.Rgba8888, + SKAlphaType.Premul, + SKColorSpace.CreateSrgb()); + break; + case ".jpg": + case ".jpeg": + picture.ToImage( + stream, + skBackgroundColor, + SKEncodedImageFormat.Jpeg, + 100, + scaleX, + scaleY, + SKColorType.Rgba8888, + SKAlphaType.Premul, + SKColorSpace.CreateSrgb()); + break; + case ".pdf": + picture.ToPdf(stream, skBackgroundColor, scaleX, scaleY); + break; + case ".xps": + picture.ToXps(stream, skBackgroundColor, scaleX, scaleY); + break; + } + + return Task.CompletedTask; + } +} diff --git a/samples/TestApp.Shared/TestApp.Shared.csproj b/samples/TestApp.Shared/TestApp.Shared.csproj new file mode 100644 index 0000000000..4062b0cd8c --- /dev/null +++ b/samples/TestApp.Shared/TestApp.Shared.csproj @@ -0,0 +1,12 @@ + + + net10.0 + False + enable + TestApp + + + + + + diff --git a/samples/TestApp.Shared/ViewModels/AsyncRelayCommand.cs b/samples/TestApp.Shared/ViewModels/AsyncRelayCommand.cs new file mode 100644 index 0000000000..419679ffe1 --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/AsyncRelayCommand.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Input; + +namespace TestApp.ViewModels; + +public sealed class AsyncRelayCommand : ICommand +{ + private readonly Func _executeAsync; + private readonly Func? _canExecute; + private bool _isExecuting; + + public AsyncRelayCommand(Func executeAsync, Func? canExecute = null) + { + _executeAsync = executeAsync; + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true); + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) + { + return; + } + + try + { + _isExecuting = true; + NotifyCanExecuteChanged(); + await _executeAsync(); + } + finally + { + _isExecuting = false; + NotifyCanExecuteChanged(); + } + } + + public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/samples/TestApp.Shared/ViewModels/FileItemViewModel.cs b/samples/TestApp.Shared/ViewModels/FileItemViewModel.cs new file mode 100644 index 0000000000..df4f7221cf --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/FileItemViewModel.cs @@ -0,0 +1,38 @@ +using System; +using System.Windows.Input; +using TestApp.Services; + +namespace TestApp.ViewModels; + +public sealed class FileItemViewModel : ViewModelBase +{ + private string _name; + private string _path; + + public FileItemViewModel(string name, string path, Action remove) + { + _name = name; + _path = path; + RemoveCommand = new RelayCommand(() => remove(this)); + OpenInExplorerCommand = new RelayCommand(() => PathLauncher.OpenInExplorer(_path)); + OpenInNotepadCommand = new RelayCommand(() => PathLauncher.OpenInTextEditor(_path)); + } + + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string Path + { + get => _path; + set => SetProperty(ref _path, value); + } + + public ICommand RemoveCommand { get; } + + public ICommand OpenInExplorerCommand { get; } + + public ICommand OpenInNotepadCommand { get; } +} diff --git a/samples/TestApp.Shared/ViewModels/ITestAppSvgViewAdapter.cs b/samples/TestApp.Shared/ViewModels/ITestAppSvgViewAdapter.cs new file mode 100644 index 0000000000..f244e9a8fe --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/ITestAppSvgViewAdapter.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Svg; +using Svg.Skia; +using ShimPoint = ShimSkiaSharp.SKPoint; +using SkiaPicture = SkiaSharp.SKPicture; + +namespace TestApp.ViewModels; + +public interface ITestAppSvgViewAdapter +{ + SkiaPicture? Picture { get; } + + SKSvg? SkSvg { get; } + + double AnimationPlaybackRate { get; set; } + + SvgAnimationHostBackend ActualAnimationBackend { get; } + + string? AnimationBackendFallbackReason { get; } + + bool TryGetPicturePoint(double x, double y, out ShimPoint picturePoint); + + IEnumerable HitTestElements(double x, double y); + + void InvalidateView(); +} diff --git a/samples/TestApp.Shared/ViewModels/MainWindowViewModel.cs b/samples/TestApp.Shared/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..eaa8e704a7 --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows.Input; +using SkiaSharp; +using TestApp.Models; +using TestApp.Services; + +namespace TestApp.ViewModels; + +public sealed class MainWindowViewModel : ViewModelBase +{ + private readonly ITestAppStorageService _storageService; + private readonly ObservableCollection _items = new(); + private readonly ObservableCollection _filteredItems = new(); + private readonly ReadOnlyObservableCollection _readOnlyFilteredItems; + private readonly RelayCommand _resetQueryCommand; + private readonly AsyncRelayCommand _loadConfigurationCommand; + private readonly AsyncRelayCommand _saveConfigurationCommand; + private readonly RelayCommand _clearConfigurationCommand; + private readonly AsyncRelayCommand _addItemCommand; + private FileItemViewModel? _selectedItem; + private string? _itemQuery; + + public MainWindowViewModel() + : this(NullTestAppStorageService.Instance) + { + } + + public MainWindowViewModel(ITestAppStorageService storageService) + { + _storageService = storageService; + _readOnlyFilteredItems = new ReadOnlyObservableCollection(_filteredItems); + SvgView = new SvgInteractionViewModel(); + + _resetQueryCommand = new RelayCommand(() => ItemQuery = string.Empty, () => !string.IsNullOrWhiteSpace(ItemQuery)); + _loadConfigurationCommand = new AsyncRelayCommand(LoadConfigurationExecuteAsync); + _saveConfigurationCommand = new AsyncRelayCommand(SaveConfigurationExecuteAsync, () => _items.Count > 0); + _clearConfigurationCommand = new RelayCommand(ClearConfiguration); + _addItemCommand = new AsyncRelayCommand(AddItemExecuteAsync); + + RefreshFilteredItems(); + } + + public FileItemViewModel? SelectedItem + { + get => _selectedItem; + set => SetProperty(ref _selectedItem, value); + } + + public string? ItemQuery + { + get => _itemQuery; + set + { + if (SetProperty(ref _itemQuery, value)) + { + RefreshFilteredItems(); + _resetQueryCommand.NotifyCanExecuteChanged(); + } + } + } + + public ReadOnlyObservableCollection FilteredItems => _readOnlyFilteredItems; + + public SvgInteractionViewModel SvgView { get; } + + public ICommand ResetQueryCommand => _resetQueryCommand; + + public ICommand LoadConfigurationCommand => _loadConfigurationCommand; + + public ICommand SaveConfigurationCommand => _saveConfigurationCommand; + + public ICommand ClearConfigurationCommand => _clearConfigurationCommand; + + public ICommand AddItemCommand => _addItemCommand; + + public void Drop(IEnumerable paths) + { + foreach (var path in paths) + { + if (!File.Exists(path) && !Directory.Exists(path)) + { + continue; + } + + if (Directory.Exists(path)) + { + Drop(Directory.EnumerateFiles(path, "*.svg", new EnumerationOptions { RecurseSubdirectories = true })); + Drop(Directory.EnumerateFiles(path, "*.svgz", new EnumerationOptions { RecurseSubdirectories = true })); + continue; + } + + switch (Path.GetExtension(path).ToLowerInvariant()) + { + case ".svg": + case ".svgz": + AddItem(path); + break; + case ".json": + using (var stream = File.OpenRead(path)) + { + LoadConfiguration(stream); + } + break; + } + } + } + + public void LoadConfiguration(Stream stream) + { + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + var configuration = JsonSerializer.Deserialize(json); + + SelectedItem = null; + _items.Clear(); + + if (configuration?.Paths is not null) + { + foreach (var path in configuration.Paths) + { + AddItem(path); + } + } + + ItemQuery = configuration?.Query; + SvgView.NotifySelectionChanged(); + RefreshFilteredItems(); + } + + public void SaveConfiguration(Stream stream) + { + var configuration = new Configuration + { + Paths = _items.Select(x => x.Path).ToList(), + Query = ItemQuery + }; + + using var writer = new StreamWriter(stream); + writer.Write(JsonSerializer.Serialize(configuration)); + } + + public async Task ExportAsync(SKPicture? picture) + { + if (SelectedItem is null || picture is null) + { + return; + } + + var exportTarget = await _storageService.OpenExportWriteStreamAsync(Path.GetFileNameWithoutExtension(SelectedItem.Path)); + if (exportTarget is null) + { + return; + } + + await using var stream = exportTarget.Stream; + await TestAppExportService.ExportAsync(stream, exportTarget.Name, picture); + } + + private async Task LoadConfigurationExecuteAsync() + { + await using var stream = await _storageService.OpenConfigurationReadStreamAsync(); + if (stream is not null) + { + LoadConfiguration(stream); + } + } + + private async Task SaveConfigurationExecuteAsync() + { + await using var stream = await _storageService.OpenConfigurationWriteStreamAsync("TestApp.json"); + if (stream is not null) + { + SaveConfiguration(stream); + } + } + + private async Task AddItemExecuteAsync() + { + var paths = await _storageService.PickSvgPathsAsync(); + foreach (var path in paths) + { + AddItem(path); + } + } + + private void ClearConfiguration() + { + ItemQuery = null; + SelectedItem = null; + _items.Clear(); + RefreshFilteredItems(); + SvgView.NotifySelectionChanged(); + _saveConfigurationCommand.NotifyCanExecuteChanged(); + } + + private void AddItem(string path) + { + if (_items.Any(x => string.Equals(x.Path, path, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + var item = new FileItemViewModel(Path.GetFileName(path), path, RemoveItem); + _items.Add(item); + RefreshFilteredItems(); + _saveConfigurationCommand.NotifyCanExecuteChanged(); + } + + private void RemoveItem(FileItemViewModel item) + { + if (SelectedItem == item) + { + SelectedItem = null; + } + + _items.Remove(item); + RefreshFilteredItems(); + _saveConfigurationCommand.NotifyCanExecuteChanged(); + } + + private void RefreshFilteredItems() + { + var filtered = string.IsNullOrWhiteSpace(ItemQuery) + ? _items.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + : _items.Where(x => x.Name.Contains(ItemQuery, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase); + + _filteredItems.Clear(); + foreach (var item in filtered) + { + _filteredItems.Add(item); + } + } +} diff --git a/samples/TestApp.Shared/ViewModels/ObservableObject.cs b/samples/TestApp.Shared/ViewModels/ObservableObject.cs new file mode 100644 index 0000000000..c152ae5a46 --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/ObservableObject.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace TestApp.ViewModels; + +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/samples/TestApp.Shared/ViewModels/RelayCommand.cs b/samples/TestApp.Shared/ViewModels/RelayCommand.cs new file mode 100644 index 0000000000..fbde56163f --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/RelayCommand.cs @@ -0,0 +1,24 @@ +using System; +using System.Windows.Input; + +namespace TestApp.ViewModels; + +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; + + public void Execute(object? parameter) => _execute(); + + public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/samples/TestApp.Shared/ViewModels/SvgInteractionViewModel.cs b/samples/TestApp.Shared/ViewModels/SvgInteractionViewModel.cs new file mode 100644 index 0000000000..b8638c9cc4 --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/SvgInteractionViewModel.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Svg.Skia; +using ShimPoint = ShimSkiaSharp.SKPoint; +using SkiaColors = SkiaSharp.SKColors; +using SkiaPaint = SkiaSharp.SKPaint; +using SkiaPaintStyle = SkiaSharp.SKPaintStyle; + +namespace TestApp.ViewModels; + +public sealed class SvgInteractionViewModel : ViewModelBase, IDisposable +{ + private readonly ObservableCollection _hitResults = new(); + private readonly ReadOnlyObservableCollection _readOnlyHitResults; + private readonly IList _hitTestPoints = new List(); + private ITestAppSvgViewAdapter? _view; + private SKSvg? _currentSkSvg; + private bool _showHitBounds; + private string _animationStatusText = "No animation"; + private string _animationClockText = "00:00.000"; + private string _animationBackendInfoText = "Default"; + private string? _animationBackendFallbackReason; + private bool _canPlay; + private bool _canPause; + private bool _canRestart; + private double _resumeAnimationPlaybackRate = 1.0; + + public SvgInteractionViewModel() + { + _readOnlyHitResults = new ReadOnlyObservableCollection(_hitResults); + } + + public ReadOnlyObservableCollection HitResults => _readOnlyHitResults; + + public bool ShowHitBounds + { + get => _showHitBounds; + set + { + if (SetProperty(ref _showHitBounds, value)) + { + SubscribeOnDraw(); + _view?.InvalidateView(); + } + } + } + + public string AnimationStatusText + { + get => _animationStatusText; + private set => SetProperty(ref _animationStatusText, value); + } + + public string AnimationClockText + { + get => _animationClockText; + private set => SetProperty(ref _animationClockText, value); + } + + public string AnimationBackendInfoText + { + get => _animationBackendInfoText; + private set => SetProperty(ref _animationBackendInfoText, value); + } + + public string? AnimationBackendFallbackReason + { + get => _animationBackendFallbackReason; + private set => SetProperty(ref _animationBackendFallbackReason, value); + } + + public bool CanPlay + { + get => _canPlay; + private set => SetProperty(ref _canPlay, value); + } + + public bool CanPause + { + get => _canPause; + private set => SetProperty(ref _canPause, value); + } + + public bool CanRestart + { + get => _canRestart; + private set => SetProperty(ref _canRestart, value); + } + + public void Attach(ITestAppSvgViewAdapter view) + { + _view = view; + SubscribeOnDraw(); + UpdateAnimationUi(); + } + + public void Detach() + { + if (_currentSkSvg is not null) + { + _currentSkSvg.OnDraw -= SkSvgOnDraw; + } + + _currentSkSvg = null; + _view = null; + } + + public void NotifySelectionChanged() + { + _hitResults.Clear(); + _hitTestPoints.Clear(); + SubscribeOnDraw(); + _view?.InvalidateView(); + UpdateAnimationUi(); + } + + public void HandlePointerPressed(double x, double y) + { + _hitResults.Clear(); + _hitTestPoints.Clear(); + + if (_view?.SkSvg is null) + { + return; + } + + if (_view.TryGetPicturePoint(x, y, out var picturePoint)) + { + _hitTestPoints.Add(picturePoint); + var element = _view.HitTestElements(x, y).FirstOrDefault(); + if (element is not null) + { + _hitResults.Add(element.ID ?? element.GetType().Name); + } + } + + SubscribeOnDraw(); + _view.InvalidateView(); + } + + public void Tick() + { + if (!ReferenceEquals(_currentSkSvg, _view?.SkSvg)) + { + SubscribeOnDraw(); + } + + if (_view?.AnimationPlaybackRate > 0) + { + _resumeAnimationPlaybackRate = _view.AnimationPlaybackRate; + } + + UpdateAnimationUi(); + } + + public void PlayAnimation() + { + if (_view?.SkSvg?.HasAnimations != true) + { + UpdateAnimationUi(); + return; + } + + if (_view.AnimationPlaybackRate <= 0) + { + _view.AnimationPlaybackRate = _resumeAnimationPlaybackRate > 0 ? _resumeAnimationPlaybackRate : 1.0; + } + + UpdateAnimationUi(); + } + + public void PauseAnimation() + { + if (_view is null) + { + return; + } + + if (_view.AnimationPlaybackRate > 0) + { + _resumeAnimationPlaybackRate = _view.AnimationPlaybackRate; + } + + _view.AnimationPlaybackRate = 0; + UpdateAnimationUi(); + } + + public void RestartAnimation() + { + _view?.SkSvg?.ResetAnimation(); + AutoStartAnimationIfNeeded(); + _view?.InvalidateView(); + UpdateAnimationUi(); + } + + public void Dispose() + { + Detach(); + } + + private void SubscribeOnDraw() + { + if (_currentSkSvg is not null) + { + _currentSkSvg.OnDraw -= SkSvgOnDraw; + } + + _currentSkSvg = _view?.SkSvg; + + if (_currentSkSvg is not null) + { + _currentSkSvg.OnDraw += SkSvgOnDraw; + } + + AutoStartAnimationIfNeeded(); + UpdateAnimationUi(); + } + + private void AutoStartAnimationIfNeeded() + { + if (_view?.SkSvg?.HasAnimations != true || _view.AnimationPlaybackRate > 0) + { + return; + } + + _view.AnimationPlaybackRate = _resumeAnimationPlaybackRate > 0 ? _resumeAnimationPlaybackRate : 1.0; + } + + private void UpdateAnimationUi() + { + var skSvg = _view?.SkSvg; + var hasAnimations = skSvg?.HasAnimations == true; + var animationTime = skSvg?.AnimationTime ?? TimeSpan.Zero; + var isPaused = (_view?.AnimationPlaybackRate ?? 0) <= 0; + + CanPlay = hasAnimations && isPaused; + CanPause = hasAnimations && !isPaused; + CanRestart = hasAnimations; + + AnimationStatusText = !hasAnimations + ? "No animation" + : isPaused + ? "Paused" + : "Playing"; + + AnimationClockText = animationTime.ToString(@"mm\:ss\.fff"); + AnimationBackendInfoText = _view?.ActualAnimationBackend.ToString() ?? "Default"; + AnimationBackendFallbackReason = _view?.AnimationBackendFallbackReason; + } + + private void SkSvgOnDraw(object? sender, SKSvgDrawEventArgs e) + { + if (!_showHitBounds || sender is not SKSvg skSvg) + { + return; + } + + var hits = new HashSet(); + + foreach (var point in _hitTestPoints) + { + foreach (var node in skSvg.HitTestSceneNodes(point)) + { + hits.Add(node); + } + } + + using var paint = new SkiaPaint + { + IsAntialias = true, + Style = SkiaPaintStyle.Stroke, + Color = SkiaColors.Cyan + }; + + foreach (var hit in hits.Take(1)) + { + e.Canvas.DrawRect(skSvg.SkiaModel.ToSKRect(hit.TransformedBounds), paint); + } + } +} diff --git a/samples/TestApp.Shared/ViewModels/ViewModelBase.cs b/samples/TestApp.Shared/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000000..8aa861548c --- /dev/null +++ b/samples/TestApp.Shared/ViewModels/ViewModelBase.cs @@ -0,0 +1,5 @@ +namespace TestApp.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/samples/TestApp/App.axaml.cs b/samples/TestApp/App.axaml.cs index fb59392826..1a4bb0432c 100644 --- a/samples/TestApp/App.axaml.cs +++ b/samples/TestApp/App.axaml.cs @@ -4,6 +4,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using TestApp.Services; using TestApp.ViewModels; using TestApp.Views; @@ -20,7 +21,7 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { - var mainWindowViewModel = new MainWindowViewModel(); + var mainWindowViewModel = new MainWindowViewModel(new StorageService()); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { diff --git a/samples/TestApp/Program.cs b/samples/TestApp/Program.cs index c6f59442c2..d38b8a6f3b 100644 --- a/samples/TestApp/Program.cs +++ b/samples/TestApp/Program.cs @@ -1,6 +1,5 @@ using System; using Avalonia; -using Avalonia.ReactiveUI; using Avalonia.Svg.Skia; namespace TestApp; @@ -28,7 +27,6 @@ public static AppBuilder BuildAvaloniaApp() .UsePlatformDetect() .With(new X11PlatformOptions { }) .LogToTrace() - .UseSkia() - .UseReactiveUI(); + .UseSkia(); } } diff --git a/samples/TestApp/Services/StorageService.cs b/samples/TestApp/Services/StorageService.cs index bb5797b4e5..46a4bd4ab4 100644 --- a/samples/TestApp/Services/StorageService.cs +++ b/samples/TestApp/Services/StorageService.cs @@ -1,3 +1,9 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -6,98 +12,147 @@ namespace TestApp.Services; -internal static class StorageService +internal sealed class StorageService : ITestAppStorageService { - public static FilePickerFileType All { get; } = new("All") + private static FilePickerFileType All { get; } = new("All") { Patterns = new[] { "*.*" }, MimeTypes = new[] { "*/*" } }; - public static FilePickerFileType Json { get; } = new("Json") + private static FilePickerFileType Json { get; } = new("Json") { Patterns = new[] { "*.json" }, AppleUniformTypeIdentifiers = new[] { "public.json" }, MimeTypes = new[] { "application/json" } }; - public static FilePickerFileType CSharp { get; } = new("C#") + private static FilePickerFileType CSharp { get; } = new("C#") { Patterns = new[] { "*.cs" }, AppleUniformTypeIdentifiers = new[] { "public.csharp-source" }, MimeTypes = new[] { "text/plain" } }; - public static FilePickerFileType ImagePng { get; } = new("PNG image") + private static FilePickerFileType ImagePng { get; } = new("PNG image") { Patterns = new[] { "*.png" }, AppleUniformTypeIdentifiers = new[] { "public.png" }, MimeTypes = new[] { "image/png" } }; - public static FilePickerFileType ImageJpg { get; } = new("JPEG image") + private static FilePickerFileType ImageJpg { get; } = new("JPEG image") { Patterns = new[] { "*.jpg", "*.jpeg" }, AppleUniformTypeIdentifiers = new[] { "public.jpeg" }, MimeTypes = new[] { "image/jpeg" } }; - public static FilePickerFileType ImageAll { get; } = new("All Images") + private static FilePickerFileType Pdf { get; } = new("PDF document") { - Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.bmp" }, - AppleUniformTypeIdentifiers = new[] { "public.image" }, - MimeTypes = new[] { "image/*" } + Patterns = new[] { "*.pdf" }, + AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" }, + MimeTypes = new[] { "application/pdf" } }; - public static FilePickerFileType ImageSvg { get; } = new("Svg") + private static FilePickerFileType Xps { get; } = new("XPS document") { - Patterns = new[] { "*.svg" }, - AppleUniformTypeIdentifiers = new[] { "public.svg-image" }, - MimeTypes = new[] { "image/svg+xml" } + Patterns = new[] { "*.xps" }, + AppleUniformTypeIdentifiers = new[] { "com.microsoft.xps" }, + MimeTypes = new[] { "application/oxps", "application/vnd.ms-xpsdocument" } }; - public static FilePickerFileType ImageSvgz { get; } = new("Svgz") + public async Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default) { - Patterns = new[] { "*.svgz" }, - // TODO: - AppleUniformTypeIdentifiers = new[] { "public.svg-image" }, - // TODO: - MimeTypes = new[] { "image/svg+xml" } - }; + var storageProvider = GetStorageProvider(); + if (storageProvider is null) + { + return null; + } - public static FilePickerFileType Xaml { get; } = new("Xaml") - { - Patterns = new[] { "*.xaml" }, - // TODO: - AppleUniformTypeIdentifiers = new[] { "public.xaml" }, - // TODO: - MimeTypes = new[] { "application/xaml" } - }; + var result = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open configuration", + FileTypeFilter = new[] { Json, All }, + AllowMultiple = false + }); + + var file = result.FirstOrDefault(); + return file is null ? null : await file.OpenReadAsync(); + } - public static FilePickerFileType Axaml { get; } = new("Axaml") + public async Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default) { - Patterns = new[] { "*.axaml" }, - // TODO: - AppleUniformTypeIdentifiers = new[] { "public.axaml" }, - // TODO: - MimeTypes = new[] { "application/axaml" } - }; + var storageProvider = GetStorageProvider(); + if (storageProvider is null) + { + return null; + } - public static FilePickerFileType Pdf { get; } = new("PDF document") + var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save configuration", + FileTypeChoices = new[] { Json, All }, + SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedFileName), + DefaultExtension = "json", + ShowOverwritePrompt = true + }); + + return file is null ? null : await file.OpenWriteAsync(); + } + + public async Task> PickSvgPathsAsync(CancellationToken cancellationToken = default) { - Patterns = new[] { "*.pdf" }, - AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" }, - MimeTypes = new[] { "application/pdf" } - }; + var storageProvider = GetStorageProvider(); + if (storageProvider is null) + { + return Array.Empty(); + } + + var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open svg files", + AllowMultiple = true, + FileTypeFilter = new[] + { + new FilePickerFileType("Svg Files") + { + Patterns = new[] { "*.svg", "*.svgz" }, + AppleUniformTypeIdentifiers = new[] { "public.svg-image" }, + MimeTypes = new[] { "image/svg+xml", "application/gzip" } + }, + All + } + }); + + return files + .Select(file => file.TryGetLocalPath()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Cast() + .ToList(); + } - public static FilePickerFileType Xps { get; } = new("XPS document") + public async Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default) { - Patterns = new[] { "*.xps" }, - AppleUniformTypeIdentifiers = new[] { "com.microsoft.xps" }, - MimeTypes = new[] { "application/oxps", "application/vnd.ms-xpsdocument" } - }; + var storageProvider = GetStorageProvider(); + if (storageProvider is null) + { + return null; + } + + var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Export svg", + FileTypeChoices = new[] { ImagePng, ImageJpg, CSharp, Pdf, Xps, All }, + SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedFileName), + DefaultExtension = "png", + ShowOverwritePrompt = true + }); + + return file is null ? null : new TestAppSaveStreamResult(await file.OpenWriteAsync(), file.Name); + } - public static IStorageProvider? GetStorageProvider() + private static IStorageProvider? GetStorageProvider() { if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } window }) { diff --git a/samples/TestApp/TestApp.csproj b/samples/TestApp/TestApp.csproj index 07d8b3d706..d2f9f32143 100644 --- a/samples/TestApp/TestApp.csproj +++ b/samples/TestApp/TestApp.csproj @@ -8,16 +8,6 @@ TestApp - - true - $(BaseIntermediateOutputPath)\GeneratedFiles - - - - 13 - true - - @@ -26,23 +16,15 @@ - + - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - diff --git a/samples/TestApp/ViewModels/FileItemViewModel.cs b/samples/TestApp/ViewModels/FileItemViewModel.cs deleted file mode 100644 index f5b28f4503..0000000000 --- a/samples/TestApp/ViewModels/FileItemViewModel.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Windows.Input; -using ReactiveUI; - -namespace TestApp.ViewModels; - -public partial class FileItemViewModel : ViewModelBase -{ - [Reactive] - public partial string Name { get; set; } - - [Reactive] - public partial string Path { get; set; } - - public ICommand RemoveCommand { get; } - - public ICommand OpenInExplorerCommand { get; } - - public ICommand OpenInNotepadCommand { get; } - - public FileItemViewModel(string name, string path, Action remove) - { - _name = name; - _path = path; - - OpenInExplorerCommand = ReactiveCommand.Create(() => - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Process.Start("explorer", _path); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Process.Start("xdg-open", _path); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Process.Start("open", _path); - } - }); - - OpenInNotepadCommand = ReactiveCommand.Create(() => - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Process.Start("notepad", _path); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Process.Start("xdg-open", _path); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Process.Start("open", "-t " + _path); - } - }); - - RemoveCommand = ReactiveCommand.Create(() => - { - remove(this); - }); - } -} diff --git a/samples/TestApp/ViewModels/MainWindowViewModel.cs b/samples/TestApp/ViewModels/MainWindowViewModel.cs deleted file mode 100644 index 362061d8fc..0000000000 --- a/samples/TestApp/ViewModels/MainWindowViewModel.cs +++ /dev/null @@ -1,391 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using System.Windows.Input; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Platform.Storage; -using DynamicData; -using DynamicData.Binding; -using ReactiveUI; -using Svg.Skia; -using TestApp.Models; -using TestApp.Services; - -namespace TestApp.ViewModels; - -public partial class MainWindowViewModel : ViewModelBase -{ - private readonly ObservableCollection? _items; - - [Reactive] - public partial FileItemViewModel? SelectedItem { get; set; } - - [Reactive] - public partial string? ItemQuery { get; set; } - - [Reactive] - public partial ReadOnlyObservableCollection? FilteredItems { get; set; } - - public ICommand ResetQueryCommand { get; } - - public ICommand LoadConfigurationCommand { get; } - - public ICommand SaveConfigurationCommand { get; } - - public ICommand ClearConfigurationCommand { get; } - - public ICommand AddItemCommand { get; } - - public ICommand ExportCommand { get; } - - private List GetConfigurationFileTypes() - { - return new List - { - StorageService.Json, - StorageService.All - }; - } - - private static List GetExportFileTypes() - { - return new List - { - StorageService.ImagePng, - StorageService.ImageJpg, - StorageService.CSharp, - StorageService.Pdf, - StorageService.Xps, - StorageService.All - }; - } - - public MainWindowViewModel() - { - _items = new ObservableCollection(); - - var queryFilter = this.WhenAnyItemQuery() - .Throttle(TimeSpan.FromMilliseconds(100)) - .Select(ItemQueryFilter) - .DistinctUntilChanged(); - - _items - .ToObservableChangeSet() - .Filter(queryFilter) - .Sort(SortExpressionComparer.Ascending(x => x.Name)) - .ObserveOn(RxApp.MainThreadScheduler) - .Bind(out _filteredItems) - .AsObservableList(); - - var resetQueryCanExecute = this.WhenAnyItemQuery() - .Select(x => !string.IsNullOrWhiteSpace(x)) - .ObserveOn(RxApp.MainThreadScheduler); - - ResetQueryCommand = ReactiveCommand.Create(() => ItemQuery = "", resetQueryCanExecute); - - LoadConfigurationCommand = ReactiveCommand.CreateFromTask(async () => await LoadConfigurationExecute()); - - SaveConfigurationCommand = ReactiveCommand.CreateFromTask(async () => await SaveConfigurationExecute()); - - ClearConfigurationCommand = ReactiveCommand.Create(ClearConfigurationExecute); - - AddItemCommand = ReactiveCommand.CreateFromTask(async () => await AddItemExecute()); - - ExportCommand = ReactiveCommand.CreateFromTask(async svg => await ExportExecute(svg)); - } - - private async Task ExportExecute(Avalonia.Svg.Skia.Svg svg) - { - if (_selectedItem is null || svg.Picture is null) - { - return; - } - - var storageProvider = StorageService.GetStorageProvider(); - if (storageProvider is null) - { - return; - } - - var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = "Export svg", - FileTypeChoices = GetExportFileTypes(), - SuggestedFileName = Path.GetFileNameWithoutExtension(_selectedItem.Path), - DefaultExtension = "png", - ShowOverwritePrompt = true - }); - - if (file is not null) - { - try - { - await using var stream = await file.OpenWriteAsync(); - await Export(stream, file.Name, svg, "#00FFFFFF", 1f, 1f); - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - Debug.WriteLine(ex.StackTrace); - } - } - } - - private async Task AddItemExecute() - { - var window = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; - if (window is null) - { - return; - } - - var dlg = new OpenFileDialog - { - AllowMultiple = true, - Filters = new List - { - new() {Name = "Svg Files (*.svg;*.svgz)", Extensions = new List {"svg", "svgz"}}, - new() {Name = "All Files (*.*)", Extensions = new List {"*"}} - } - }; - var result = await dlg.ShowAsync(window); - if (result is { }) - { - var paths = result.ToList(); - foreach (var path in paths) - { - AddItem(path); - } - } - } - - private void ClearConfigurationExecute() - { - ItemQuery = null; - SelectedItem = null; - _items?.Clear(); - } - - private async Task SaveConfigurationExecute() - { - var storageProvider = StorageService.GetStorageProvider(); - if (storageProvider is null) - { - return; - } - - var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = "Save configuration", - FileTypeChoices = GetConfigurationFileTypes(), - SuggestedFileName = Path.GetFileNameWithoutExtension("TestApp.json"), - DefaultExtension = "json", - ShowOverwritePrompt = true - }); - - if (file is not null) - { - try - { - await using var stream = await file.OpenWriteAsync(); - SaveConfiguration(stream); - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - Debug.WriteLine(ex.StackTrace); - } - } - } - - private async Task LoadConfigurationExecute() - { - var storageProvider = StorageService.GetStorageProvider(); - if (storageProvider is null) - { - return; - } - - var result = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = "Open configuration", - FileTypeFilter = GetConfigurationFileTypes(), - AllowMultiple = false - }); - - var file = result.FirstOrDefault(); - - if (file is not null) - { - try - { - await using var stream = await file.OpenReadAsync(); - LoadConfiguration(stream); - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - Debug.WriteLine(ex.StackTrace); - } - } - } - - private Func ItemQueryFilter(string? searchQuery) - { - return item => - { - if (!string.IsNullOrWhiteSpace(searchQuery)) - { - return item.Name.Contains(searchQuery, StringComparison.OrdinalIgnoreCase); - } - - return true; - }; - } - - public void Drop(IEnumerable paths) - { - foreach (var path in paths) - { - if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) - { - var svgPaths = Directory.EnumerateFiles(path, "*.svg", new EnumerationOptions { RecurseSubdirectories = true }); - var svgzPaths = Directory.EnumerateFiles(path, "*.svgz", new EnumerationOptions { RecurseSubdirectories = true }); - Drop(svgPaths); - Drop(svgzPaths); - continue; - } - - var extension = Path.GetExtension(path); - switch (extension.ToLower()) - { - case ".svg": - case ".svgz": - { - AddItem(path); - break; - } - case ".json": - { - using var stream = File.OpenRead(path); - LoadConfiguration(stream); - break; - } - } - } - } - - private void AddItem(string path) - { - if (_items is { }) - { - var item = new FileItemViewModel(Path.GetFileName(path), path, x => _items.Remove(x)); - _items.Add(item); - } - } - - public void LoadConfiguration(Stream stream) - { - using var reader = new StreamReader(stream); - var json = reader.ReadToEnd(); - var configuration = JsonSerializer.Deserialize(json); - - if (configuration?.Paths is { }) - { - SelectedItem = null; - _items?.Clear(); - - foreach (var path in configuration.Paths) - { - AddItem(path); - } - } - - ItemQuery = configuration?.Query; - } - - public void SaveConfiguration(Stream stream) - { - var configuration = new Configuration - { - Paths = _items?.Select(x => x.Path).ToList(), - Query = ItemQuery - }; - - var json = JsonSerializer.Serialize(configuration); - using var writer = new StreamWriter(stream); - writer.Write(json); - } - - public async Task Export(Stream stream, string name, Avalonia.Svg.Skia.Svg? svg, string backgroundColor, float scaleX, float scaleY) - { - if (svg.Picture is null) - { - return; - } - - if (!SkiaSharp.SKColor.TryParse(backgroundColor, out var skBackgroundColor)) - { - return; - } - - var extension = Path.GetExtension(name); - switch (extension.ToLower()) - { - case ".png": - { - svg.Picture?.ToImage( - stream, - skBackgroundColor, - SkiaSharp.SKEncodedImageFormat.Png, - 100, - scaleX, - scaleY, - SkiaSharp.SKColorType.Rgba8888, - SkiaSharp.SKAlphaType.Premul, - SkiaSharp.SKColorSpace.CreateSrgb()); - break; - } - case ".jpg": - case ".jpeg": - { - svg.Picture?.ToImage( - stream, - skBackgroundColor, - SkiaSharp.SKEncodedImageFormat.Jpeg, - 100, - scaleX, - scaleY, - SkiaSharp.SKColorType.Rgba8888, - SkiaSharp.SKAlphaType.Premul, - SkiaSharp.SKColorSpace.CreateSrgb()); - break; - } - case ".pdf": - { - svg.Picture?.ToPdf(stream, skBackgroundColor, scaleX, scaleY); - break; - } - case ".xps": - { - svg.Picture?.ToXps(stream, skBackgroundColor, scaleX, scaleY); - break; - } - } - } - - private static string CreateClassName(string filename) - { - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filename); - string className = fileNameWithoutExtension.Replace("-", "_"); - return $"Svg_{className}"; - } -} diff --git a/samples/TestApp/ViewModels/ViewModelBase.cs b/samples/TestApp/ViewModels/ViewModelBase.cs deleted file mode 100644 index 4a8abcdd35..0000000000 --- a/samples/TestApp/ViewModels/ViewModelBase.cs +++ /dev/null @@ -1,7 +0,0 @@ -using ReactiveUI; - -namespace TestApp.ViewModels; - -public class ViewModelBase : ReactiveObject -{ -} diff --git a/samples/TestApp/Views/MainView.axaml b/samples/TestApp/Views/MainView.axaml index 7d8451e315..1f53613e17 100644 --- a/samples/TestApp/Views/MainView.axaml +++ b/samples/TestApp/Views/MainView.axaml @@ -1,9 +1,12 @@ - @@ -58,8 +61,7 @@ - + IsHitTestVisible="False" /> @@ -103,76 +105,149 @@ Margin="6" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"> - -