Browse Source

Composition-aware geometries and drawing change detection (#21682)

* Composition-aware geometries

* Propagate resource changes in DrawingBrush

* Change detection in Drawing

* Make IRenderDataGeometry a private API
pull/21710/head
Julien Lebosquain 3 months ago
committed by GitHub
parent
commit
ee767d5de1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 21
      src/Avalonia.Base/CombinedGeometry.cs
  2. 58
      src/Avalonia.Base/Media/Drawing.cs
  3. 84
      src/Avalonia.Base/Media/DrawingBrush.cs
  4. 15
      src/Avalonia.Base/Media/DrawingContext.cs
  5. 38
      src/Avalonia.Base/Media/DrawingGroup.cs
  6. 14
      src/Avalonia.Base/Media/DrawingImage.cs
  7. 31
      src/Avalonia.Base/Media/Geometry.cs
  8. 7
      src/Avalonia.Base/Media/GeometryCollection.cs
  9. 5
      src/Avalonia.Base/Platform/IGeometryImpl.cs
  10. 18
      src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs
  11. 20
      src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs
  12. 15
      src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs
  13. 7
      src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Visit.cs
  14. 4
      src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs
  15. 15
      src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs
  16. 5
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSimpleGeometry.cs
  17. 3
      src/Avalonia.Base/composition-schema.xml
  18. 44
      tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs
  19. 84
      tests/Avalonia.Base.UnitTests/Media/GeometryRenderResourceTests.cs
  20. 56
      tests/Avalonia.Base.UnitTests/Rendering/DrawingBrushPropagationTests.cs
  21. 35
      tests/Avalonia.Base.UnitTests/Rendering/DrawingImagePropagationTests.cs
  22. 9
      tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs
  23. 2
      tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamHitTestTests.cs
  24. 5
      tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamTests.cs
  25. 114
      tests/Avalonia.RenderTests/Media/DrawingContentTests.cs
  26. BIN
      tests/TestFiles/Skia/Media/DrawingContent/DrawingBrush_Reflects_Replaced_Inner_Brush.expected.png
  27. BIN
      tests/TestFiles/Skia/Media/DrawingContent/DrawingImage_Reflects_Replaced_Inner_Brush.expected.png

21
src/Avalonia.Base/CombinedGeometry.cs

@ -55,6 +55,11 @@ namespace Avalonia.Media
public static readonly StyledProperty<GeometryCombineMode> GeometryCombineModeProperty =
AvaloniaProperty.Register<CombinedGeometry, GeometryCombineMode>(nameof(GeometryCombineMode));
static CombinedGeometry()
{
AffectsGeometry(Geometry1Property, Geometry2Property, GeometryCombineModeProperty);
}
/// <summary>
/// Initializes a new instance of the <see cref="CombinedGeometry"/> class.
/// </summary>
@ -145,6 +150,22 @@ namespace Avalonia.Media
return new CombinedGeometry(GeometryCombineMode, Geometry1, Geometry2, Transform);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == Geometry1Property || change.Property == Geometry2Property)
{
var (oldValue, newValue) = change.GetOldAndNewValue<Geometry?>();
oldValue?.Changed -= ChildGeometryChanged;
newValue?.Changed += ChildGeometryChanged;
}
}
private void ChildGeometryChanged(object? sender, EventArgs e)
=> InvalidateGeometry();
private protected sealed override IGeometryImpl? CreateDefiningGeometry()
{
var g1 = Geometry1;

58
src/Avalonia.Base/Media/Drawing.cs

@ -1,4 +1,6 @@
namespace Avalonia.Media
using System;
namespace Avalonia.Media
{
/// <summary>
/// Abstract class that describes a 2-D drawing.
@ -7,9 +9,16 @@
{
internal Drawing()
{
}
/// <summary>
/// Raised when the drawing changes in a way that requires its consumer to re-record it: a referenced resource
/// is replaced (e.g. a new brush, pen, geometry or child), or a value that is baked into the recorded drawing
/// commands changes (e.g. a transform, opacity or effect). Mutations _within_ a referenced compositor-aware
/// resource (a brush's color, a geometry's shape) are not signaled here: the compositor propagates those on its own.
/// </summary>
internal event EventHandler? Invalidated;
/// <summary>
/// Draws this drawing to the given <see cref="DrawingContext"/>.
/// </summary>
@ -22,5 +31,48 @@
/// Gets the drawing's bounding rectangle.
/// </summary>
public abstract Rect GetBounds();
private protected void RaiseInvalidated()
=> Invalidated?.Invoke(this, EventArgs.Empty);
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
// Every property of a drawing is baked into the recorded drawing commands or selects which resource is used,
// so any change requires a re-record. Keep subscriptions to nested values whose _own_ changes are also baked
// (and thus can't be propagated by the compositor) in sync.
UpdateValueSubscription(change.OldValue, subscribe: false);
UpdateValueSubscription(change.NewValue, subscribe: true);
RaiseInvalidated();
}
private void UpdateValueSubscription(object? value, bool subscribe)
{
switch (value)
{
// A transform's matrix is baked into the recorded commands, so its value changes (not just its
// replacement) require a re-record.
case Transform transform:
if (subscribe)
transform.Changed += ValueInvalidated;
else
transform.Changed -= ValueInvalidated;
break;
// Effects are baked as immutable, and nested image sources (e.g. a DrawingImage) are inlined,
// so their visual changes require a re-record too.
case IAffectsRender affectsRender:
if (subscribe)
affectsRender.Invalidated += ValueInvalidated;
else
affectsRender.Invalidated -= ValueInvalidated;
break;
}
void ValueInvalidated(object? sender, EventArgs e)
=> RaiseInvalidated();
}
}
}

84
src/Avalonia.Base/Media/DrawingBrush.cs

@ -1,6 +1,4 @@
using System;
using Avalonia.Media.Immutable;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.Server;
@ -19,7 +17,7 @@ namespace Avalonia.Media
/// </summary>
public static readonly StyledProperty<Drawing?> DrawingProperty =
AvaloniaProperty.Register<DrawingBrush, Drawing?>(nameof(Drawing));
/// <summary>
/// Initializes a new instance of the <see cref="DrawingBrush"/> class.
/// </summary>
@ -49,7 +47,7 @@ namespace Avalonia.Media
{
if (Drawing == null)
return null;
using var recorder = new RenderDataDrawingContext(null);
Drawing?.Draw(recorder);
return recorder.GetImmediateSceneBrushContent(this, null, true);
@ -58,38 +56,84 @@ namespace Avalonia.Media
internal override Func<Compositor, ServerCompositionSimpleBrush> Factory =>
static c => new ServerCompositionSimpleContentBrush(c.Server);
private InlineDictionary<Compositor, CompositionRenderData?> _renderDataDictionary;
private protected override void OnReferencedFromCompositor(Compositor c)
private sealed class RenderDataItem(CompositionRenderData data) : IDisposable
{
_renderDataDictionary.Add(c, CreateServerContent(c));
base.OnReferencedFromCompositor(c);
public CompositionRenderData Data { get; } = data;
public bool IsDirty;
public void Dispose() => Data.Dispose();
}
private InlineDictionary<Compositor, RenderDataItem?> _renderDataDictionary;
protected override void OnUnreferencedFromCompositor(Compositor c)
{
if (_renderDataDictionary.TryGetAndRemoveValue(c, out var content))
content?.Dispose();
base.OnUnreferencedFromCompositor(c);
}
private protected override void SerializeChanges(Compositor c, BatchStreamWriter writer)
{
base.SerializeChanges(c, writer);
if (_renderDataDictionary.TryGetValue(c, out var content) && content != null)
writer.WriteObject(new CompositionRenderDataSceneBrushContent.Properties(content.Server, null, true));
else
writer.WriteObject(null);
CompositionRenderDataSceneBrushContent.Properties? content = null;
if (IsOnCompositor(c)) // Should always be true here, but just in case do this check
{
_renderDataDictionary.TryGetValue(c, out var data);
if (data is null || data.IsDirty)
{
var created = CreateServerContent(c);
// Dispose the old render list _after_ creating a new one to avoid unnecessary detach/attach
// sequence for referenced resources
data?.Dispose();
_renderDataDictionary[c] = data = created;
}
if (data is not null)
content = new(data.Data.Server, null, true);
}
writer.WriteObject(content);
}
CompositionRenderData? CreateServerContent(Compositor c)
private void InvalidateContent()
{
if (Drawing == null)
foreach (var item in _renderDataDictionary)
item.Value?.IsDirty = true;
RegisterForSerialization();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
// Replacing the drawing, or a structural change inside it (e.g. a replaced brush, pen or geometry) changes
// the recorded content; mark it dirty so it's re-recorded. Mutations _within_ a referenced compositor-aware
// resource (a brush's color, a geometry's shape) are propagated by the server without re-recording.
if (change.Property == DrawingProperty)
{
var (oldValue, newValue) = change.GetOldAndNewValue<Drawing?>();
oldValue?.Invalidated -= DrawingInvalidated;
newValue?.Invalidated += DrawingInvalidated;
InvalidateContent();
}
base.OnPropertyChanged(change);
void DrawingInvalidated(object? sender, EventArgs e)
=> InvalidateContent();
}
private RenderDataItem? CreateServerContent(Compositor c)
{
if (Drawing is not { } drawing)
return null;
using var recorder = new RenderDataDrawingContext(c);
Drawing?.Draw(recorder);
return recorder.GetRenderResults();
drawing.Draw(recorder);
var renderData = recorder.GetRenderResults();
return renderData is null ? null : new RenderDataItem(renderData);
}
}
}

15
src/Avalonia.Base/Media/DrawingContext.cs

@ -90,8 +90,19 @@ namespace Avalonia.Media
/// <param name="geometry">The geometry.</param>
public void DrawGeometry(IBrush? brush, IPen? pen, Geometry geometry)
{
if ((brush != null || PenIsVisible(pen)) && geometry.PlatformImpl != null)
DrawGeometryCore(brush, pen, geometry.PlatformImpl);
if (brush != null || PenIsVisible(pen))
DrawGeometryCore(brush, pen, geometry);
}
/// <summary>
/// Draws a mutable geometry. Implementations that support compositor-aware resources can keep a reference to
/// the <see cref="Geometry"/> so that subsequent changes to it are propagated without re-recording.
/// The default implementation simply forwards the geometry's current <see cref="Geometry.PlatformImpl"/>.
/// </summary>
protected virtual void DrawGeometryCore(IBrush? brush, IPen? pen, Geometry geometry)
{
if (geometry.PlatformImpl is { } geometryImpl)
DrawGeometryCore(brush, pen, geometryImpl);
}
/// <summary>

38
src/Avalonia.Base/Media/DrawingGroup.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using Avalonia.Metadata;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
@ -35,6 +36,9 @@ namespace Avalonia.Media
private DrawingCollection _children = new DrawingCollection();
public DrawingGroup()
=> _children.CollectionChanged += ChildrenCollectionChanged;
public double Opacity
{
get => GetValue(OpacityProperty);
@ -81,12 +85,46 @@ namespace Avalonia.Media
get => _children;
set
{
if (ReferenceEquals(_children, value))
return;
_children.CollectionChanged -= ChildrenCollectionChanged;
foreach (var child in _children)
child.Invalidated -= ChildInvalidated;
SetAndRaise(ChildrenProperty, ref _children, value);
_children.CollectionChanged += ChildrenCollectionChanged;
foreach (var child in _children)
child.Invalidated += ChildInvalidated;
}
}
public DrawingContext Open() => new DrawingGroupDrawingContext(this);
private void ChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset)
throw new NotSupportedException();
if (e.OldItems is not null)
{
foreach (Drawing child in e.OldItems)
child.Invalidated -= ChildInvalidated;
}
if (e.NewItems is not null)
{
foreach (Drawing child in e.NewItems)
child.Invalidated += ChildInvalidated;
}
RaiseInvalidated();
}
private void ChildInvalidated(object? sender, EventArgs e)
=> RaiseInvalidated();
internal override void DrawCore(DrawingContext context)
{
// Compute local bounds from children only when EffectBounds is not explicitly set.

14
src/Avalonia.Base/Media/DrawingImage.cs

@ -98,10 +98,22 @@ namespace Avalonia.Media
{
base.OnPropertyChanged(change);
if (change.Property == DrawingProperty || change.Property == ViewboxProperty)
if (change.Property == DrawingProperty)
{
var (oldValue, newValue) = change.GetOldAndNewValue<Drawing?>();
oldValue?.Invalidated -= DrawingInvalidated;
newValue?.Invalidated += DrawingInvalidated;
RaiseInvalidated(EventArgs.Empty);
}
else if (change.Property == ViewboxProperty)
{
RaiseInvalidated(EventArgs.Empty);
}
void DrawingInvalidated(object? sender, EventArgs e)
=> RaiseInvalidated(EventArgs.Empty);
}
/// <summary>

31
src/Avalonia.Base/Media/Geometry.cs

@ -4,6 +4,10 @@ using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Reactive;
using Avalonia.Rendering.Composition;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.Server;
using Avalonia.Rendering.Composition.Transport;
namespace Avalonia.Media
@ -12,7 +16,10 @@ namespace Avalonia.Media
/// Defines a geometric shape.
/// </summary>
[TypeConverter(typeof(GeometryTypeConverter))]
public abstract class Geometry : AvaloniaObject
public abstract class Geometry :
AvaloniaObject,
ICompositionRenderResource<ServerCompositionSimpleGeometry>,
ICompositorSerializable
{
/// <summary>
/// Defines the <see cref="Transform"/> property.
@ -168,6 +175,7 @@ namespace Avalonia.Media
_isDirty = true;
_platformImpl = null;
RegisterForSerialization();
Changed?.Invoke(this, EventArgs.Empty);
}
@ -209,6 +217,7 @@ namespace Avalonia.Media
_platformImpl = _platformImpl.WithTransform(transform.Value);
}
RegisterForSerialization();
Changed?.Invoke(this, EventArgs.Empty);
}
@ -297,6 +306,26 @@ namespace Avalonia.Media
segmentGeometry = new PlatformGeometry(segment);
return true;
}
private CompositorResourceHolder<ServerCompositionSimpleGeometry> _resource;
private protected void RegisterForSerialization()
=> _resource.RegisterForInvalidationOnAllCompositors(this);
ServerCompositionSimpleGeometry ICompositionRenderResource<ServerCompositionSimpleGeometry>.GetForCompositor(Compositor c)
=> _resource.GetForCompositor(c);
void ICompositionRenderResource.AddRefOnCompositor(Compositor c)
=> _resource.CreateOrAddRef(c, this, out _, static c => new ServerCompositionSimpleGeometry(c.Server));
void ICompositionRenderResource.ReleaseOnCompositor(Compositor c)
=> _resource.Release(c);
SimpleServerObject? ICompositorSerializable.TryGetServer(Compositor c)
=> _resource.TryGetForCompositor(c);
void ICompositorSerializable.SerializeChanges(Compositor c, BatchStreamWriter writer)
=> ServerCompositionSimpleGeometry.SerializeAllChanges(writer, PlatformImpl);
}
public class GeometryTypeConverter : TypeConverter

7
src/Avalonia.Base/Media/GeometryCollection.cs

@ -15,10 +15,12 @@ namespace Avalonia.Media
this.ForEachItem(
x =>
{
x.Changed += ChildChanged;
Parent?.Invalidate();
},
x =>
{
x.Changed -= ChildChanged;
Parent?.Invalidate();
},
() => throw new NotSupportedException());
@ -31,15 +33,20 @@ namespace Avalonia.Media
this.ForEachItem(
x =>
{
x.Changed += ChildChanged;
Parent?.Invalidate();
},
x =>
{
x.Changed -= ChildChanged;
Parent?.Invalidate();
},
() => throw new NotSupportedException());
}
public GeometryGroup? Parent { get; set; }
private void ChildChanged(object? sender, EventArgs e)
=> Parent?.Invalidate();
}
}

5
src/Avalonia.Base/Platform/IGeometryImpl.cs

@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Media;
using Avalonia.Metadata;
using Avalonia.Rendering.Composition.Drawing;
namespace Avalonia.Platform
{
@ -8,8 +9,10 @@ namespace Avalonia.Platform
/// Defines the platform-specific interface for a <see cref="Geometry"/>.
/// </summary>
[Unstable]
public interface IGeometryImpl
public interface IGeometryImpl : IRenderDataGeometry
{
IGeometryImpl IRenderDataGeometry.GeometryImpl => this;
/// <summary>
/// Gets the geometry's bounding rectangle.
/// </summary>

18
src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs

@ -8,7 +8,7 @@ namespace Avalonia.Rendering.Composition.Server;
internal sealed class ServerCompositionSimpleContentBrush : ServerCompositionSimpleTileBrush, ITileBrush, ISceneBrush
{
private CompositionRenderDataSceneBrushContent.Properties? _content;
internal ServerCompositionSimpleContentBrush(ServerCompositor compositor) : base(compositor)
{
@ -22,6 +22,20 @@ internal sealed class ServerCompositionSimpleContentBrush : ServerCompositionSim
protected override void DeserializeChangesCore(BatchStreamReader reader, TimeSpan committedAt)
{
base.DeserializeChangesCore(reader, committedAt);
_content = reader.ReadObject<CompositionRenderDataSceneBrushContent.Properties?>();
var content = reader.ReadObject<CompositionRenderDataSceneBrushContent.Properties?>();
if (!ReferenceEquals(_content?.RenderData, content?.RenderData))
{
_content?.RenderData.RemoveObserver(this);
content?.RenderData.AddObserver(this);
}
_content = content;
}
public override void Dispose()
{
_content?.RenderData.RemoveObserver(this);
base.Dispose();
}
}

20
src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs

@ -0,0 +1,20 @@
using Avalonia.Metadata;
using Avalonia.Platform;
namespace Avalonia.Rendering.Composition.Drawing;
/// <summary>
/// Common abstraction for a geometry to render.
/// It is implemented by both the immutable platform <see cref="IGeometryImpl"/> (which returns itself)
/// and the compositor-aware <see cref="Server.ServerCompositionSimpleGeometry"/> server resource
/// (which returns its current, mutable backing geometry). This lets the render data stay strongly typed
/// while still resolving to the concrete <see cref="IGeometryImpl"/> that platform drawing contexts require.
/// </summary>
[PrivateApi]
public interface IRenderDataGeometry
{
/// <summary>
/// The underlying immutable platform geometry, or <c>null</c> when there's nothing to draw.
/// </summary>
IGeometryImpl? GeometryImpl { get; }
}

15
src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs

@ -103,6 +103,18 @@ internal class RenderDataDrawingContext : DrawingContext
Stream.DrawGeometry(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, geometry);
}
protected override void DrawGeometryCore(IBrush? brush, IPen? pen, Geometry geometry)
{
if (brush is null && pen is null)
return;
AddResource(brush);
AddResource(pen);
AddResource(geometry);
Stream.DrawGeometry(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, geometry.GetServer(_compositor));
}
protected override void DrawRectangleCore(IBrush? brush, IPen? pen, RoundedRect rrect, BoxShadows boxShadows = default)
{
if (rrect.IsEmpty())
@ -164,8 +176,9 @@ internal class RenderDataDrawingContext : DrawingContext
return;
}
AddResource(clip);
var before = Stream.OpcodeLength;
Stream.PushGeometryClip(clip.PlatformImpl);
Stream.PushGeometryClip(clip.GetServer(_compositor));
PushedScope(before);
}

7
src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.Visit.cs

@ -69,7 +69,7 @@ internal partial class RenderDataStream
(IBrush?)_resources[p.ServerBrush],
(IPen?)_resources[p.ServerPen],
(IPen?)_resources[p.ClientPen],
(IGeometryImpl?)_resources[p.Geometry]);
GetGeometryImpl(_resources[p.Geometry]));
break;
}
case RenderDataOpcode.DrawGlyphRun:
@ -103,7 +103,7 @@ internal partial class RenderDataStream
case RenderDataOpcode.PushGeometryClip:
{
var p = reader.ReadPayload<PushGeometryClipPayload>();
scopes[depth++] = visitor.OnPushGeometryClip((IGeometryImpl?)_resources[p.Geometry]);
scopes[depth++] = visitor.OnPushGeometryClip(GetGeometryImpl(_resources[p.Geometry]));
break;
}
case RenderDataOpcode.PushOpacity:
@ -174,4 +174,7 @@ internal partial class RenderDataStream
rest[i] = reader.Read<BoxShadow>();
return new BoxShadows(first, rest);
}
private static IGeometryImpl? GetGeometryImpl(object? resource)
=> ((IRenderDataGeometry?)resource)?.GeometryImpl;
}

4
src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.cs

@ -93,7 +93,7 @@ internal partial class RenderDataStream : IDisposable
});
}
public void DrawGeometry(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, IGeometryImpl? geometry)
public void DrawGeometry(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, IRenderDataGeometry? geometry)
{
_writer.WritePayload(new DrawGeometryPayload
{
@ -138,7 +138,7 @@ internal partial class RenderDataStream : IDisposable
EnterScope();
}
public void PushGeometryClip(IGeometryImpl? geometry)
public void PushGeometryClip(IRenderDataGeometry? geometry)
{
_writer.WritePayload(new PushGeometryClipPayload { Geometry = _resources.Intern(geometry) });
EnterScope();

15
src/Avalonia.Base/Rendering/Composition/Drawing/ServerResourceHelperExtensions.cs

@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Avalonia.Media;
using Avalonia.Media.Immutable;
using Avalonia.Rendering.Composition.Server;
namespace Avalonia.Rendering.Composition.Drawing;
@ -52,4 +53,16 @@ static class ServerResourceHelperExtensions
resource.GetForCompositor(compositor);
return new ImmutableTransform(transform.Value);
}
}
public static IRenderDataGeometry? GetServer(this Geometry? geometry, Compositor? compositor)
{
if (geometry == null)
return null;
if (compositor == null)
return geometry.PlatformImpl;
if (geometry is ICompositionRenderResource<ServerCompositionSimpleGeometry> resource)
return resource.GetForCompositor(compositor);
ThrowNotCompatible(geometry);
return null;
}
}

5
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSimpleGeometry.cs

@ -0,0 +1,5 @@
using Avalonia.Rendering.Composition.Drawing;
namespace Avalonia.Rendering.Composition.Server;
internal partial class ServerCompositionSimpleGeometry : IRenderDataGeometry;

3
src/Avalonia.Base/composition-schema.xml

@ -82,6 +82,9 @@
<Property Name="MiterLimit" Type="double" />
<Property Name="Thickness" Type="double" />
</Object>
<Object Name="CompositionSimpleGeometry" Internal="true" ServerOnly="true" ServerBase="SimpleServerRenderResource">
<Property Name="GeometryImpl" Type="Avalonia.Platform.IGeometryImpl?" />
</Object>
<Object Name="CompositionSimpleBrush" Internal="true" ServerOnly="true" ServerBase="SimpleServerRenderResource">
<Property Name="Opacity" Type="double" />
<Property Name="TransformOrigin" Type="RelativePoint" />

44
tests/Avalonia.Base.UnitTests/Media/DrawingGroupTests.cs

@ -110,6 +110,50 @@ public class DrawingGroupTests
}
}
[Fact]
public void Invalidated_Is_Raised_When_Child_Is_Added_Or_Removed()
{
var group = new DrawingGroup();
var child = new GeometryDrawing();
var count = 0;
group.Invalidated += (_, _) => count++;
group.Children.Add(child);
Assert.Equal(1, count);
group.Children.Remove(child);
Assert.Equal(2, count);
}
[Fact]
public void Invalidated_Is_Raised_When_Child_Changes()
{
var child = new GeometryDrawing();
var group = new DrawingGroup();
group.Children.Add(child);
var count = 0;
group.Invalidated += (_, _) => count++;
child.Brush = Brushes.Red;
Assert.True(count > 0);
}
[Fact]
public void Removed_Child_Is_No_Longer_Tracked()
{
var child = new GeometryDrawing();
var group = new DrawingGroup();
group.Children.Add(child);
group.Children.Remove(child);
var count = 0;
group.Invalidated += (_, _) => count++;
child.Brush = Brushes.Red;
Assert.Equal(0, count);
}
private class MockDrawingContext : DrawingContext
{
public IEffect? Effect { get; private set; }

84
tests/Avalonia.Base.UnitTests/Media/GeometryRenderResourceTests.cs

@ -0,0 +1,84 @@
using Avalonia.Media;
using Xunit;
namespace Avalonia.Base.UnitTests.Media;
public class GeometryRenderResourceTests
{
[Fact]
public void Changing_Geometry_Property_Raises_Invalidated()
{
var target = new EllipseGeometry(new Rect(0, 0, 10, 10));
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => target.Rect = new Rect(0, 0, 20, 20));
}
[Fact]
public void Changing_Transform_Raises_Invalidated()
{
var target = new EllipseGeometry(new Rect(0, 0, 10, 10));
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => target.Transform = new TranslateTransform(5, 5));
}
[Fact]
public void Changing_Transform_Value_Raises_Invalidated()
{
var transform = new TranslateTransform(5, 5);
var target = new EllipseGeometry(new Rect(0, 0, 10, 10)) { Transform = transform };
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => transform.X = 10);
}
[Fact]
public void Adding_Child_To_GeometryGroup_Raises_Invalidated()
{
var target = new GeometryGroup();
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => target.Children.Add(new EllipseGeometry(new Rect(0, 0, 10, 10))));
}
[Fact]
public void Changing_Child_Of_GeometryGroup_Raises_Invalidated()
{
var child = new EllipseGeometry(new Rect(0, 0, 10, 10));
var target = new GeometryGroup();
target.Children.Add(child);
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => child.Rect = new Rect(0, 0, 20, 20));
}
[Fact]
public void Changing_Geometry1_Of_CombinedGeometry_Raises_Invalidated()
{
var geometry1 = new EllipseGeometry(new Rect(0, 0, 10, 10));
var geometry2 = new RectangleGeometry(new Rect(5, 5, 10, 10));
var target = new CombinedGeometry(GeometryCombineMode.Union, geometry1, geometry2);
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => geometry1.Rect = new Rect(0, 0, 20, 20));
}
[Fact]
public void Changing_CombineMode_Of_CombinedGeometry_Raises_Invalidated()
{
var geometry1 = new EllipseGeometry(new Rect(0, 0, 10, 10));
var geometry2 = new RectangleGeometry(new Rect(5, 5, 10, 10));
var target = new CombinedGeometry(GeometryCombineMode.Union, geometry1, geometry2);
RenderResourceTestHelper.AssertResourceInvalidation(
target,
() => target.GeometryCombineMode = GeometryCombineMode.Intersect);
}
}

56
tests/Avalonia.Base.UnitTests/Rendering/DrawingBrushPropagationTests.cs

@ -0,0 +1,56 @@
using Avalonia.Controls;
using Avalonia.Media;
using Xunit;
namespace Avalonia.Base.UnitTests.Rendering;
public class DrawingBrushPropagationTests : CompositorTestsBase
{
private static Border CreateBorder(DrawingBrush brush) => new()
{
Background = brush,
Width = 20,
Height = 10,
[Canvas.LeftProperty] = 30,
[Canvas.TopProperty] = 50
};
[Fact]
public void Mutating_Geometry_Inside_DrawingBrush_Invalidates_Consumer()
{
using var services = new CompositorCanvas();
var geometry = new RectangleGeometry(new Rect(0, 0, 20, 10));
var brush = new DrawingBrush(new GeometryDrawing { Brush = Brushes.Red, Geometry = geometry });
services.Canvas.Children.Add(CreateBorder(brush));
services.RunJobs();
services.Events.Rects.Clear();
geometry.Rect = new Rect(0, 0, 30, 15);
services.AssertRects(new Rect(30, 50, 20, 10));
}
[Fact]
public void Replacing_Drawing_Invalidates_Consumer()
{
using var services = new CompositorCanvas();
var brush = new DrawingBrush(new GeometryDrawing
{
Brush = Brushes.Red,
Geometry = new RectangleGeometry(new Rect(0, 0, 20, 10))
});
services.Canvas.Children.Add(CreateBorder(brush));
services.RunJobs();
services.Events.Rects.Clear();
brush.Drawing = new GeometryDrawing
{
Brush = Brushes.Blue,
Geometry = new RectangleGeometry(new Rect(0, 0, 20, 10))
};
services.AssertRects(new Rect(30, 50, 20, 10));
}
}

35
tests/Avalonia.Base.UnitTests/Rendering/DrawingImagePropagationTests.cs

@ -0,0 +1,35 @@
using Avalonia.Controls;
using Avalonia.Media;
using Xunit;
namespace Avalonia.Base.UnitTests.Rendering;
public class DrawingImagePropagationTests : CompositorTestsBase
{
private static Image CreateImage(DrawingImage image) => new()
{
Source = image,
Width = 20,
Height = 10,
[Canvas.LeftProperty] = 30,
[Canvas.TopProperty] = 50
};
[Fact]
public void Mutating_Geometry_Inside_DrawingImage_Invalidates_Consumer()
{
using var services = new CompositorCanvas();
var geometry = new RectangleGeometry(new Rect(0, 0, 20, 10));
var image = new DrawingImage(new GeometryDrawing { Brush = Brushes.Red, Geometry = geometry });
services.Canvas.Children.Add(CreateImage(image));
services.RunJobs();
services.Events.Rects.Clear();
geometry.Rect = new Rect(0, 0, 30, 15);
services.AssertRects(
new Rect(30, 50, 20, 10),
new Rect(30, 50, 30, 15));
}
}

9
tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs

@ -120,7 +120,12 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
{
var ctx = new TestContext(_services);
using (ctx.Context.PushTransform(new Matrix()))
ctx.Context.DrawGeometry(Brushes.Black, null, Mock.Of<IGeometryImpl>());
{
var geometry = new Mock<IGeometryImpl>();
geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object);
ctx.Context.DrawGeometry(Brushes.Black, null, geometry.Object);
}
Assert.False(ctx.Context.GetRenderResults()!.HitTest(default));
}
@ -405,6 +410,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
public void Geometry_Node_HitTest_Uses_FillContains_When_Brush_Set()
{
var geomMock = new Mock<IGeometryImpl>();
geomMock.Setup(g => g.GeometryImpl).Returns(geomMock.Object);
geomMock.Setup(g => g.FillContains(new Point(5, 5))).Returns(true);
geomMock.Setup(g => g.FillContains(new Point(50, 50))).Returns(false);
@ -421,6 +427,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
{
var pen = new ImmutablePen(Brushes.Black, 1);
var geomMock = new Mock<IGeometryImpl>();
geomMock.Setup(g => g.GeometryImpl).Returns(geomMock.Object);
geomMock.Setup(g => g.StrokeContains(pen, new Point(5, 5))).Returns(true);
geomMock.Setup(g => g.StrokeContains(pen, new Point(50, 50))).Returns(false);

2
tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamHitTestTests.cs

@ -61,6 +61,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
public void Geometry_Is_Hit_Via_FillContains()
{
var geometry = new Mock<IGeometryImpl>();
geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object);
geometry.Setup(x => x.FillContains(new Point(5, 5))).Returns(true);
geometry.Setup(x => x.FillContains(new Point(50, 50))).Returns(false);
@ -113,6 +114,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
public void Geometry_Clip_Restricts_The_Hit_Region()
{
var geometry = new Mock<IGeometryImpl>();
geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object);
geometry.Setup(x => x.FillContains(new Point(5, 5))).Returns(true);
geometry.Setup(x => x.FillContains(new Point(50, 50))).Returns(false);

5
tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/RenderDataStreamTests.cs

@ -123,9 +123,12 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
var calls = new List<string>();
var context = RecordingContext(calls);
var geometry = new Mock<IGeometryImpl>();
geometry.Setup(g => g.GeometryImpl).Returns(geometry.Object);
using var stream = new RenderDataStream();
stream.PushClip(new RoundedRect(new Rect(0, 0, 10, 10)));
stream.PushGeometryClip(Mock.Of<IGeometryImpl>());
stream.PushGeometryClip(geometry.Object);
stream.Pop();
stream.Pop();
stream.Replay(context.Object);

114
tests/Avalonia.RenderTests/Media/DrawingContentTests.cs

@ -0,0 +1,114 @@
#if AVALONIA_SKIA
using System;
using System.IO;
using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
using Avalonia.Threading;
using Avalonia.UnitTests;
using SkiaSharp;
using Xunit;
namespace Avalonia.Skia.RenderTests;
/// <summary>
/// Verifies that replacing a resource inside a <see cref="Drawing"/> is reflected in what a
/// consuming control renders. The initial frame is intentionally ignored (covered elsewhere); only
/// the frame produced _after_ the change is compared against the reference image.
/// </summary>
public class DrawingContentTests()
: TestBase(@"Media\DrawingContent")
{
private sealed class FuncFramebufferSurface(Func<IFramebufferRenderTarget> cb) : IFramebufferPlatformSurface
{
public IFramebufferRenderTarget CreateFramebufferRenderTarget() => cb();
}
[Fact]
public void DrawingBrush_Reflects_Replaced_Inner_Brush()
{
var drawing = new GeometryDrawing
{
Brush = Brushes.Blue,
Geometry = new RectangleGeometry(new Rect(0, 0, 100, 100))
};
var target = new Border
{
Width = 100,
Height = 100,
Background = new DrawingBrush(drawing) { Stretch = Stretch.Fill }
};
RenderChange(target, () => drawing.Brush = Brushes.Red);
CompareImages(skipImmediate: true);
}
[Fact]
public void DrawingImage_Reflects_Replaced_Inner_Brush()
{
var drawing = new GeometryDrawing
{
Brush = Brushes.Blue,
Geometry = new RectangleGeometry(new Rect(0, 0, 100, 100))
};
var target = new Image
{
Width = 100,
Height = 100,
Stretch = Stretch.Fill,
Source = new DrawingImage(drawing)
};
RenderChange(target, () => drawing.Brush = Brushes.Red);
CompareImages(skipImmediate: true);
}
// Renders the target once, applies the change, renders again and writes the second frame to the
// standard composited output path so it can be compared against the committed reference image.
private void RenderChange(Control target, Action change, [CallerMemberName] string testName = "")
{
var timer = new ManualRenderTimer();
var compositor = new Compositor(
RenderLoop.FromTimer(timer),
null,
true,
new DispatcherCompositorScheduler(), true, Dispatcher.UIThread);
var root = new TestRenderRoot(1, null!);
using var frameBuffer = new SKBitmap(100, 100, SKColorType.Rgba8888, SKAlphaType.Premul);
var renderTarget = new FuncFramebufferRenderTarget(() => new LockedFramebuffer(
frameBuffer.GetAddress(0, 0),
new PixelSize(frameBuffer.Width, frameBuffer.Height),
frameBuffer.RowBytes,
new Vector(96, 96),
PixelFormat.Rgba8888,
AlphaFormat.Premul,
null));
using (var renderer = new CompositingRenderer(root, compositor, () => [new FuncFramebufferSurface(() => renderTarget)]))
{
root.Initialize(renderer, target);
renderer.Start();
Dispatcher.UIThread.RunJobs();
timer.TriggerTick();
change();
Dispatcher.UIThread.RunJobs();
timer.TriggerTick();
}
Directory.CreateDirectory(OutputPath);
var path = Path.Combine(OutputPath, testName + ".composited.out.png");
using var data = frameBuffer.Encode(SKEncodedImageFormat.Png, 100);
using var file = File.Create(path);
data.SaveTo(file);
}
}
#endif

BIN
tests/TestFiles/Skia/Media/DrawingContent/DrawingBrush_Reflects_Replaced_Inner_Brush.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

BIN
tests/TestFiles/Skia/Media/DrawingContent/DrawingImage_Reflects_Replaced_Inner_Brush.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Loading…
Cancel
Save