From cf28998a46a8b606bb08b56bccc0c84c8718eaff Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Fri, 14 Apr 2023 15:16:31 +0600 Subject: [PATCH] Bitmap effects support --- samples/RenderDemo/Pages/AnimationsPage.xaml | 40 ++++++ src/Avalonia.Base/Media/Effects/BlurEffect.cs | 22 +++ .../Media/Effects/DropShadowEffect.cs | 104 ++++++++++++++ src/Avalonia.Base/Media/Effects/Effect.cs | 93 +++++++++++++ .../Media/Effects/EffectAnimator.cs | 131 ++++++++++++++++++ .../Media/Effects/EffectConverter.cs | 18 +++ .../Media/Effects/EffectExtesions.cs | 56 ++++++++ .../Media/Effects/EffectTransition.cs | 83 +++++++++++ .../Media/Effects/IBlurEffect.cs | 29 ++++ .../Media/Effects/IDropShadowEffect.cs | 84 +++++++++++ src/Avalonia.Base/Media/Effects/IEffect.cs | 26 ++++ .../Platform/IDrawingContextImpl.cs | 6 + src/Avalonia.Base/Rect.cs | 9 ++ .../Composition/CompositingRenderer.cs | 10 +- .../Composition/Expressions/TokenParser.cs | 56 ++++++++ .../Composition/Server/DrawingContextProxy.cs | 15 +- .../ServerCompositionContainerVisual.cs | 68 ++++++++- .../Server/ServerCompositionVisual.cs | 28 +++- src/Avalonia.Base/Visual.cs | 20 ++- src/Avalonia.Base/composition-schema.xml | 6 +- .../DrawingContextImpl.Effects.cs | 50 +++++++ src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 16 ++- .../Media/EffectTests.cs | 73 ++++++++++ .../Avalonia.RenderTests/Media/EffectTests.cs | 43 ++++++ .../Avalonia.Skia.RenderTests.csproj | 3 + .../Effects/DropShadowEffect.expected.png | Bin 0 -> 2270 bytes 26 files changed, 1069 insertions(+), 20 deletions(-) create mode 100644 src/Avalonia.Base/Media/Effects/BlurEffect.cs create mode 100644 src/Avalonia.Base/Media/Effects/DropShadowEffect.cs create mode 100644 src/Avalonia.Base/Media/Effects/Effect.cs create mode 100644 src/Avalonia.Base/Media/Effects/EffectAnimator.cs create mode 100644 src/Avalonia.Base/Media/Effects/EffectConverter.cs create mode 100644 src/Avalonia.Base/Media/Effects/EffectExtesions.cs create mode 100644 src/Avalonia.Base/Media/Effects/EffectTransition.cs create mode 100644 src/Avalonia.Base/Media/Effects/IBlurEffect.cs create mode 100644 src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs create mode 100644 src/Avalonia.Base/Media/Effects/IEffect.cs create mode 100644 src/Skia/Avalonia.Skia/DrawingContextImpl.Effects.cs create mode 100644 tests/Avalonia.Base.UnitTests/Media/EffectTests.cs create mode 100644 tests/Avalonia.RenderTests/Media/EffectTests.cs create mode 100644 tests/TestFiles/Skia/Media/Effects/DropShadowEffect.expected.png diff --git a/samples/RenderDemo/Pages/AnimationsPage.xaml b/samples/RenderDemo/Pages/AnimationsPage.xaml index 3f89a9d5f78..d764af89780 100644 --- a/samples/RenderDemo/Pages/AnimationsPage.xaml +++ b/samples/RenderDemo/Pages/AnimationsPage.xaml @@ -308,6 +308,41 @@ + + @@ -332,6 +367,11 @@ + + + Drop + Shadow + diff --git a/src/Avalonia.Base/Media/Effects/BlurEffect.cs b/src/Avalonia.Base/Media/Effects/BlurEffect.cs new file mode 100644 index 00000000000..47c86e4e425 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/BlurEffect.cs @@ -0,0 +1,22 @@ +using System; +// ReSharper disable CheckNamespace +namespace Avalonia.Media; + +public class BlurEffect : Effect, IBlurEffect, IMutableEffect +{ + public static readonly StyledProperty RadiusProperty = AvaloniaProperty.Register( + nameof(Radius), 5); + + public double Radius + { + get => GetValue(RadiusProperty); + set => SetValue(RadiusProperty, value); + } + + static BlurEffect() + { + AffectsRender(RadiusProperty); + } + + public IImmutableEffect ToImmutable() => new ImmutableBlurEffect(Radius); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs b/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs new file mode 100644 index 00000000000..67be74fe492 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs @@ -0,0 +1,104 @@ +// ReSharper disable once CheckNamespace + +using System; +// ReSharper disable CheckNamespace + +namespace Avalonia.Media; + +public abstract class DropShadowEffectBase : Effect +{ + public static readonly StyledProperty BlurRadiusProperty = + AvaloniaProperty.Register( + nameof(BlurRadius), 5); + + public double BlurRadius + { + get => GetValue(BlurRadiusProperty); + set => SetValue(BlurRadiusProperty, value); + } + + public static readonly StyledProperty ColorProperty = AvaloniaProperty.Register( + nameof(Color)); + + public Color Color + { + get => GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + public static readonly StyledProperty OpacityProperty = + AvaloniaProperty.Register( + nameof(Opacity), 1); + + public double Opacity + { + get => GetValue(OpacityProperty); + set => SetValue(OpacityProperty, value); + } + + static DropShadowEffectBase() + { + AffectsRender(BlurRadiusProperty, ColorProperty, OpacityProperty); + } +} + +public class DropShadowEffect : DropShadowEffectBase, IDropShadowEffect, IMutableEffect +{ + public static readonly StyledProperty OffsetXProperty = AvaloniaProperty.Register( + nameof(OffsetX), 3.5355); + + public double OffsetX + { + get => GetValue(OffsetXProperty); + set => SetValue(OffsetXProperty, value); + } + + public static readonly StyledProperty OffsetYProperty = AvaloniaProperty.Register( + nameof(OffsetY), 3.5355); + + public double OffsetY + { + get => GetValue(OffsetYProperty); + set => SetValue(OffsetYProperty, value); + } + + static DropShadowEffect() + { + AffectsRender(OffsetXProperty, OffsetYProperty); + } + + public IImmutableEffect ToImmutable() + { + return new ImmutableDropShadowEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity); + } +} + +/// +/// This class is compatible with WPF's DropShadowEffect and provides Direction and ShadowDepth properties instead of OffsetX/OffsetY +/// +public class DropShadowDirectionEffect : DropShadowEffectBase, IDirectionDropShadowEffect, IMutableEffect +{ + public static readonly StyledProperty ShadowDepthProperty = + AvaloniaProperty.Register( + nameof(ShadowDepth), 5); + + public double ShadowDepth + { + get => GetValue(ShadowDepthProperty); + set => SetValue(ShadowDepthProperty, value); + } + + public static readonly StyledProperty DirectionProperty = AvaloniaProperty.Register( + nameof(Direction), 315); + + public double Direction + { + get => GetValue(DirectionProperty); + set => SetValue(DirectionProperty, value); + } + + public double OffsetX => Math.Cos(Direction) * ShadowDepth; + public double OffsetY => Math.Sin(Direction) * ShadowDepth; + + public IImmutableEffect ToImmutable() => new ImmutableDropShadowDirectionEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/Effect.cs b/src/Avalonia.Base/Media/Effects/Effect.cs new file mode 100644 index 00000000000..182e8613f8f --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/Effect.cs @@ -0,0 +1,93 @@ +using System; +using Avalonia.Animation; +using Avalonia.Animation.Animators; +using Avalonia.Reactive; +using Avalonia.Rendering.Composition.Expressions; +using Avalonia.Utilities; + +// ReSharper disable once CheckNamespace +namespace Avalonia.Media; + +public class Effect : Animatable, IAffectsRender +{ + /// + /// Marks a property as affecting the brush's visual representation. + /// + /// The properties. + /// + /// After a call to this method in a brush's static constructor, any change to the + /// property will cause the event to be raised on the brush. + /// + protected static void AffectsRender(params AvaloniaProperty[] properties) + where T : Effect + { + var invalidateObserver = new AnonymousObserver( + static e => (e.Sender as T)?.RaiseInvalidated(EventArgs.Empty)); + + foreach (var property in properties) + { + property.Changed.Subscribe(invalidateObserver); + } + } + + /// + /// Raises the event. + /// + /// The event args. + protected void RaiseInvalidated(EventArgs e) => Invalidated?.Invoke(this, e); + + /// + public event EventHandler? Invalidated; + + + static Exception ParseError(string s) => throw new ArgumentException("Unable to parse effect: " + s); + public static IEffect Parse(string s) + { + var span = s.AsSpan(); + var r = new TokenParser(span); + if (r.TryConsume("blur")) + { + if (!r.TryConsume('(') || !r.TryParseDouble(out var radius) || !r.TryConsume(')') || !r.IsEofWithWhitespace()) + throw ParseError(s); + return new ImmutableBlurEffect(radius); + } + + + if (r.TryConsume("drop-shadow")) + { + if (!r.TryConsume('(') || !r.TryParseDouble(out var offsetX) + || !r.TryParseDouble(out var offsetY)) + throw ParseError(s); + double blurRadius = 0; + var color = Colors.Black; + if (!r.TryConsume(')')) + { + if (!r.TryParseDouble(out blurRadius) || blurRadius < 0) + throw ParseError(s); + if (!r.TryConsume(')')) + { + var endOfExpression = s.LastIndexOf(")", StringComparison.Ordinal); + if (endOfExpression == -1) + throw ParseError(s); + + if (!new TokenParser(span.Slice(endOfExpression + 1)).IsEofWithWhitespace()) + throw ParseError(s); + + if (!Color.TryParse(span.Slice(r.Position, endOfExpression - r.Position).TrimEnd(), out color)) + throw ParseError(s); + return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1); + } + } + if (!r.IsEofWithWhitespace()) + throw ParseError(s); + return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1); + } + + throw ParseError(s); + } + + static Effect() + { + EffectAnimator.EnsureRegistered(); + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs new file mode 100644 index 00000000000..70d359911b9 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs @@ -0,0 +1,131 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Data; +using Avalonia.Logging; +using Avalonia.Media; + +// ReSharper disable once CheckNamespace +namespace Avalonia.Animation.Animators; + +public class EffectAnimator : Animator +{ + public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, + IObservable match, Action? onComplete) + { + if (TryCreateAnimator(out var animator) + || TryCreateAnimator(out animator)) + return animator.Apply(animation, control, clock, match, onComplete); + + Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log( + this, + "The animation's keyframe value types set is not supported."); + + return base.Apply(animation, control, clock, match, onComplete); + } + + private bool TryCreateAnimator([NotNullWhen(true)] out IAnimator? animator) + where TAnimator : EffectAnimatorBase, new() where TInterface : class, IEffect + { + TAnimator? createdAnimator = null; + foreach (var keyFrame in this) + { + if (keyFrame.Value is TInterface) + { + createdAnimator ??= new TAnimator() + { + Property = Property + }; + createdAnimator.Add(new AnimatorKeyFrame(typeof(TAnimator), () => new TAnimator(), keyFrame.Cue, + keyFrame.KeySpline) + { + Value = keyFrame.Value + }); + } + else + { + animator = null; + return false; + } + } + + animator = createdAnimator; + return animator != null; + } + + /// + /// Fallback implementation of animation. + /// + public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue) => progress >= 0.5 ? newValue : oldValue; + + private static bool s_Registered; + public static void EnsureRegistered() + { + if(s_Registered) + return; + s_Registered = true; + Animation.RegisterAnimator(prop => + typeof(IEffect).IsAssignableFrom(prop.PropertyType)); + } +} + +public abstract class EffectAnimatorBase : Animator where T : class, IEffect? +{ + public override IDisposable BindAnimation(Animatable control, IObservable instance) + { + if (Property is null) + { + throw new InvalidOperationException("Animator has no property specified."); + } + + return control.Bind((AvaloniaProperty)Property, instance, BindingPriority.Animation); + } + + protected abstract T Interpolate(double progress, T oldValue, T newValue); + public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue) + { + var old = oldValue as T; + var n = newValue as T; + if (old == null || n == null) + return progress >= 0.5 ? newValue : oldValue; + return Interpolate(progress, old, n); + } +} + +public class BlurEffectAnimator : EffectAnimatorBase +{ + private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator(); + + protected override IBlurEffect Interpolate(double progress, IBlurEffect oldValue, IBlurEffect newValue) + { + return new ImmutableBlurEffect( + s_doubleAnimator.Interpolate(progress, oldValue.Radius, newValue.Radius)); + } +} + +public class DropShadowEffectAnimator : EffectAnimatorBase +{ + private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator(); + + protected override IDropShadowEffect Interpolate(double progress, IDropShadowEffect oldValue, + IDropShadowEffect newValue) + { + var blur = s_doubleAnimator.Interpolate(progress, oldValue.BlurRadius, newValue.BlurRadius); + var color = ColorAnimator.InterpolateCore(progress, oldValue.Color, newValue.Color); + var opacity = s_doubleAnimator.Interpolate(progress, oldValue.Opacity, newValue.Opacity); + + if (oldValue is IDirectionDropShadowEffect oldDirection && newValue is IDirectionDropShadowEffect newDirection) + { + return new ImmutableDropShadowDirectionEffect( + s_doubleAnimator.Interpolate(progress, oldDirection.Direction, newDirection.Direction), + s_doubleAnimator.Interpolate(progress, oldDirection.ShadowDepth, newDirection.ShadowDepth), + blur, color, opacity + ); + } + + return new ImmutableDropShadowEffect( + s_doubleAnimator.Interpolate(progress, oldValue.OffsetX, newValue.OffsetX), + s_doubleAnimator.Interpolate(progress, oldValue.OffsetY, newValue.OffsetY), + blur, color, opacity + ); + } +} diff --git a/src/Avalonia.Base/Media/Effects/EffectConverter.cs b/src/Avalonia.Base/Media/Effects/EffectConverter.cs new file mode 100644 index 00000000000..6ec3bace03c --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Avalonia.Media; + +public class EffectConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value) + { + return value is string s ? Effect.Parse(s) : null; + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/EffectExtesions.cs b/src/Avalonia.Base/Media/Effects/EffectExtesions.cs new file mode 100644 index 00000000000..adc287607b8 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectExtesions.cs @@ -0,0 +1,56 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace Avalonia.Media; + +public static class EffectExtensions +{ + static double AdjustPaddingRadius(double radius) + { + if (radius <= 0) + return 0; + return Math.Ceiling(radius) + 1; + } + internal static Thickness GetEffectOutputPadding(this IEffect? effect) + { + if (effect == null) + return default; + if (effect is IBlurEffect blur) + return new Thickness(AdjustPaddingRadius(blur.Radius)); + if (effect is IDropShadowEffect dropShadowEffect) + { + var radius = AdjustPaddingRadius(dropShadowEffect.BlurRadius); + var rc = new Rect(-radius, -radius, + radius * 2, radius * 2); + rc = rc.Translate(new(dropShadowEffect.OffsetX, dropShadowEffect.OffsetY)); + return new Thickness(Math.Max(0, 0 - rc.X), + Math.Max(0, 0 - rc.Y), Math.Max(0, rc.Right), Math.Max(0, rc.Bottom)); + } + + throw new ArgumentException("Unknown effect type: " + effect.GetType()); + } + + /// + /// Converts a effect to an immutable effect. + /// + /// The effect. + /// + /// The result of calling if the effect is mutable, + /// otherwise . + /// + public static IImmutableEffect ToImmutable(this IEffect effect) + { + _ = effect ?? throw new ArgumentNullException(nameof(effect)); + + return (effect as IMutableEffect)?.ToImmutable() ?? (IImmutableEffect)effect; + } + + internal static bool EffectEquals(this IImmutableEffect? immutable, IEffect? right) + { + if (immutable == null && right == null) + return true; + if (immutable != null && right != null) + return immutable.Equals(right); + return false; + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/EffectTransition.cs b/src/Avalonia.Base/Media/Effects/EffectTransition.cs new file mode 100644 index 00000000000..b2e0d073550 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/EffectTransition.cs @@ -0,0 +1,83 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Animation.Animators; +using Avalonia.Animation.Easings; +using Avalonia.Media; + + +// ReSharper disable once CheckNamespace +namespace Avalonia.Animation; + +/// +/// Transition class that handles with type. +/// +public class EffectTransition : Transition +{ + private static readonly BlurEffectAnimator s_blurEffectAnimator = new(); + private static readonly DropShadowEffectAnimator s_dropShadowEffectAnimator = new(); + private static readonly ImmutableBlurEffect s_DefaultBlur = new ImmutableBlurEffect(0); + private static readonly ImmutableDropShadowDirectionEffect s_DefaultDropShadow = new(0, 0, 0, default, 0); + + bool TryWithAnimator( + IObservable progress, + TAnimator animator, + IEffect? oldValue, IEffect? newValue, TInterface defaultValue, [MaybeNullWhen(false)] out IObservable observable) + where TAnimator : EffectAnimatorBase where TInterface : class, IEffect + { + observable = null; + TInterface? oldI = null, newI = null; + if (oldValue is TInterface oi) + { + oldI = oi; + if (newValue is TInterface ni) + newI = ni; + else if (newValue == null) + newI = defaultValue; + else + return false; + } + else if (newValue is TInterface nv) + { + oldI = defaultValue; + newI = nv; + + } + else + return false; + + observable = new AnimatorTransitionObservable>(animator, progress, Easing, oldI, newI); + return true; + + } + + public override IObservable DoTransition(IObservable progress, IEffect? oldValue, IEffect? newValue) + { + if ((oldValue != null || newValue != null) + && ( + TryWithAnimator(progress, s_blurEffectAnimator, + oldValue, newValue, s_DefaultBlur, out var observable) + || TryWithAnimator(progress, s_dropShadowEffectAnimator, + oldValue, newValue, s_DefaultDropShadow, out observable) + )) + return observable; + + return new IncompatibleTransitionObservable(progress, Easing, oldValue, newValue); + } + + private sealed class IncompatibleTransitionObservable : TransitionObservableBase + { + private readonly IEffect? _from; + private readonly IEffect? _to; + + public IncompatibleTransitionObservable(IObservable progress, Easing easing, IEffect? from, IEffect? to) : base(progress, easing) + { + _from = from; + _to = to; + } + + protected override IEffect? ProduceValue(double progress) + { + return progress >= 0.5 ? _to : _from; + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/IBlurEffect.cs b/src/Avalonia.Base/Media/Effects/IBlurEffect.cs new file mode 100644 index 00000000000..716159747c7 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/IBlurEffect.cs @@ -0,0 +1,29 @@ +// ReSharper disable once CheckNamespace + +using Avalonia.Animation.Animators; + +namespace Avalonia.Media; + +public interface IBlurEffect : IEffect +{ + double Radius { get; } +} + +public class ImmutableBlurEffect : IBlurEffect, IImmutableEffect +{ + static ImmutableBlurEffect() + { + EffectAnimator.EnsureRegistered(); + } + + public ImmutableBlurEffect(double radius) + { + Radius = radius; + } + + public double Radius { get; } + + public bool Equals(IEffect? other) => + // ReSharper disable once CompareOfFloatsByEqualityOperator + other is IBlurEffect blur && blur.Radius == Radius; +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs b/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs new file mode 100644 index 00000000000..30d787198c2 --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs @@ -0,0 +1,84 @@ +// ReSharper disable once CheckNamespace + +using System; +using Avalonia.Animation.Animators; + +namespace Avalonia.Media; + +public interface IDropShadowEffect : IEffect +{ + double OffsetX { get; } + double OffsetY { get; } + double BlurRadius { get; } + Color Color { get; } + double Opacity { get; } +} + +internal interface IDirectionDropShadowEffect : IDropShadowEffect +{ + double Direction { get; } + double ShadowDepth { get; } +} + +public class ImmutableDropShadowEffect : IDropShadowEffect, IImmutableEffect +{ + static ImmutableDropShadowEffect() + { + EffectAnimator.EnsureRegistered(); + } + + public ImmutableDropShadowEffect(double offsetX, double offsetY, double blurRadius, Color color, double opacity) + { + OffsetX = offsetX; + OffsetY = offsetY; + BlurRadius = blurRadius; + Color = color; + Opacity = opacity; + } + + public double OffsetX { get; } + public double OffsetY { get; } + public double BlurRadius { get; } + public Color Color { get; } + public double Opacity { get; } + public bool Equals(IEffect? other) + { + return other is IDropShadowEffect d + && d.OffsetX == OffsetX && d.OffsetY == OffsetY + && d.BlurRadius == BlurRadius + && d.Color == Color && d.Opacity == Opacity; + } +} + + +public class ImmutableDropShadowDirectionEffect : IDirectionDropShadowEffect, IImmutableEffect +{ + static ImmutableDropShadowDirectionEffect() + { + EffectAnimator.EnsureRegistered(); + } + + public ImmutableDropShadowDirectionEffect(double direction, double shadowDepth, double blurRadius, Color color, double opacity) + { + Direction = direction; + ShadowDepth = shadowDepth; + BlurRadius = blurRadius; + Color = color; + Opacity = opacity; + } + + public double OffsetX => Math.Cos(Direction) * ShadowDepth; + public double OffsetY => Math.Sin(Direction) * ShadowDepth; + public double Direction { get; } + public double ShadowDepth { get; } + public double BlurRadius { get; } + public Color Color { get; } + public double Opacity { get; } + public bool Equals(IEffect? other) + { + return other is IDropShadowEffect d + && d.OffsetX == OffsetX && d.OffsetY == OffsetY + && d.BlurRadius == BlurRadius + && d.Color == Color && d.Opacity == Opacity; + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Media/Effects/IEffect.cs b/src/Avalonia.Base/Media/Effects/IEffect.cs new file mode 100644 index 00000000000..698dccf1ddd --- /dev/null +++ b/src/Avalonia.Base/Media/Effects/IEffect.cs @@ -0,0 +1,26 @@ +// ReSharper disable once CheckNamespace + +using System; +using System.ComponentModel; + +namespace Avalonia.Media; + +[TypeConverter(typeof(EffectConverter))] +public interface IEffect +{ + +} + +public interface IMutableEffect : IEffect, IAffectsRender +{ + /// + /// Creates an immutable clone of the effect. + /// + /// The immutable clone. + internal IImmutableEffect ToImmutable(); +} + +public interface IImmutableEffect : IEffect, IEquatable +{ + +} \ No newline at end of file diff --git a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs index ffdfa9aac12..1359ad66037 100644 --- a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs +++ b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs @@ -180,6 +180,12 @@ void DrawRectangle(IBrush? brush, IPen? pen, RoundedRect rect, object? GetFeature(Type t); } + public interface IDrawingContextImplWithEffects + { + void PushEffect(IEffect effect); + void PopEffect(); + } + public static class DrawingContextImplExtensions { /// diff --git a/src/Avalonia.Base/Rect.cs b/src/Avalonia.Base/Rect.cs index cc030eea04e..fc5d0fc0433 100644 --- a/src/Avalonia.Base/Rect.cs +++ b/src/Avalonia.Base/Rect.cs @@ -526,6 +526,15 @@ public Rect Union(Rect rect) } } + internal static Rect? Union(Rect? left, Rect? right) + { + if (left == null) + return right; + if (right == null) + return left; + return left.Value.Union(right.Value); + } + /// /// Returns a new with the specified X position. /// diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index df3a70b3e67..814ecdba299 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -252,8 +252,14 @@ private void UpdateCore() comp.Opacity = (float)visual.Opacity; comp.ClipToBounds = visual.ClipToBounds; comp.Clip = visual.Clip?.PlatformImpl; - comp.OpacityMask = visual.OpacityMask; - + + + if (!Equals(comp.OpacityMask, visual.OpacityMask)) + comp.OpacityMask = visual.OpacityMask?.ToImmutable(); + + if (!comp.Effect.EffectEquals(visual.Effect)) + comp.Effect = visual.Effect?.ToImmutable(); + var renderTransform = Matrix.Identity; if (visual.HasMirrorTransform) diff --git a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs index bb7372c375c..8ecc0028ce3 100644 --- a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs +++ b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs @@ -29,6 +29,8 @@ public void SkipWhitespace() } } + public bool NextIsWhitespace() => _s.Length > 0 && char.IsWhiteSpace(_s[0]); + static bool IsAlphaNumeric(char ch) => (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); @@ -238,6 +240,12 @@ public bool TryParseFloat(out float res) len = c + 1; dotCount++; } + else if (ch == '-') + { + if (len != 0) + return false; + len = c + 1; + } else break; } @@ -254,7 +262,55 @@ public bool TryParseFloat(out float res) Advance(len); return true; } + + public bool TryParseDouble(out double res) + { + res = 0; + SkipWhitespace(); + if (_s.Length == 0) + return false; + + var len = 0; + var dotCount = 0; + for (var c = 0; c < _s.Length; c++) + { + var ch = _s[c]; + if (ch >= '0' && ch <= '9') + len = c + 1; + else if (ch == '.' && dotCount == 0) + { + len = c + 1; + dotCount++; + } + else if (ch == '-') + { + if (len != 0) + return false; + len = c + 1; + } + else + break; + } + + var span = _s.Slice(0, len); + +#if NETSTANDARD2_0 + if (!double.TryParse(span.ToString(), NumberStyles.Number, CultureInfo.InvariantCulture, out res)) + return false; +#else + if (!double.TryParse(span, NumberStyles.Number, CultureInfo.InvariantCulture, out res)) + return false; +#endif + Advance(len); + return true; + } + public bool IsEofWithWhitespace() + { + SkipWhitespace(); + return Length == 0; + } + public override string ToString() => _s.ToString(); } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs b/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs index eaa9a70ca0a..1ec1362a4c0 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs @@ -18,7 +18,8 @@ namespace Avalonia.Rendering.Composition.Server; /// they have information about the full render transform (they are not) /// 2) Keeps the draw list for the VisualBrush contents of the current drawing operation. /// -internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingContextWithAcrylicLikeSupport +internal class CompositorDrawingContextProxy : IDrawingContextImpl, + IDrawingContextWithAcrylicLikeSupport, IDrawingContextImplWithEffects { private IDrawingContextImpl _impl; @@ -155,4 +156,16 @@ public void DrawRectangle(IExperimentalAcrylicMaterial material, RoundedRect rec if (_impl is IDrawingContextWithAcrylicLikeSupport acrylic) acrylic.DrawRectangle(material, rect); } + + public void PushEffect(IEffect effect) + { + if (_impl is IDrawingContextImplWithEffects effects) + effects.PushEffect(effect); + } + + public void PopEffect() + { + if (_impl is IDrawingContextImplWithEffects effects) + effects.PopEffect(); + } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs index 19349a5196e..b9e6833d21b 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs @@ -1,4 +1,6 @@ +using System; using System.Numerics; +using Avalonia.Media; using Avalonia.Platform; // Special license applies License.md @@ -13,6 +15,8 @@ namespace Avalonia.Rendering.Composition.Server internal partial class ServerCompositionContainerVisual : ServerCompositionVisual { public ServerCompositionVisualCollection Children { get; private set; } = null!; + private Rect? _transformedContentBounds; + private IImmutableEffect? _oldEffect; protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect currentTransformedClip) { @@ -24,18 +28,76 @@ protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect cu } } - public override void Update(ServerCompositionTarget root) + public override UpdateResult Update(ServerCompositionTarget root) { - base.Update(root); + var (combinedBounds, oldInvalidated, newInvalidated) = base.Update(root); foreach (var child in Children) { if (child.AdornedVisual != null) root.EnqueueAdornerUpdate(child); else - child.Update(root); + { + var res = child.Update(root); + oldInvalidated |= res.InvalidatedOld; + newInvalidated |= res.InvalidatedNew; + combinedBounds = Rect.Union(combinedBounds, res.Bounds); + } } + + // If effect is changed, we need to clean both old and new bounds + var effectChanged = !Effect.EffectEquals(_oldEffect); + if (effectChanged) + oldInvalidated = newInvalidated = true; + + // Expand invalidated bounds to the whole content area since we don't actually know what is being sampled + // We also ignore clip for now since we don't have means to reset it? + if (_oldEffect != null && oldInvalidated && _transformedContentBounds.HasValue) + AddEffectPaddedDirtyRect(_oldEffect, _transformedContentBounds.Value); + + if (Effect != null && newInvalidated && combinedBounds.HasValue) + AddEffectPaddedDirtyRect(Effect, combinedBounds.Value); + + _oldEffect = Effect; + _transformedContentBounds = combinedBounds; IsDirtyComposition = false; + return new(_transformedContentBounds, oldInvalidated, newInvalidated); + } + + void AddEffectPaddedDirtyRect(IImmutableEffect effect, Rect transformedBounds) + { + var padding = effect.GetEffectOutputPadding(); + if (padding == default) + { + AddDirtyRect(transformedBounds); + return; + } + + // We are in a weird position here: bounds are in global coordinates while padding gets applied in local ones + // Since we have optimizations to AVOID recomputing transformed bounds and since visuals with effects are relatively rare + // we instead apply the transformation matrix to rescale the bounds + + + // If we only have translation and scale, just scale the padding + if (CombinedTransformMatrix is + { + M12: 0, M13: 0, M14: 0, + M21: 0, M23: 0, M24: 0, + M31: 0, M32: 0, M34: 0, + M43: 0, M44: 1 + }) + padding = new Thickness(padding.Left * CombinedTransformMatrix.M11, + padding.Top * CombinedTransformMatrix.M22, + padding.Right * CombinedTransformMatrix.M11, + padding.Bottom * CombinedTransformMatrix.M22); + else + { + // Conservatively use the transformed rect size + var transformedPaddingRect = new Rect().Inflate(padding).TransformToAABB(CombinedTransformMatrix); + padding = new(Math.Max(transformedPaddingRect.Width, transformedPaddingRect.Height)); + } + + AddDirtyRect(transformedBounds.Inflate(padding)); } partial void Initialize() diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs index 6fb5ad3741d..6e7ef851838 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs @@ -54,6 +54,9 @@ public void Render(CompositorDrawingContextProxy canvas, Rect currentTransformed canvas.PostTransform = MatrixUtils.ToMatrix(transform); canvas.Transform = Matrix.Identity; + if (Effect != null) + canvas.PushEffect(Effect); + if (Opacity != 1) canvas.PushOpacity(Opacity, boundsRect); if (ClipToBounds && !HandlesClipToBounds) @@ -79,6 +82,9 @@ public void Render(CompositorDrawingContextProxy canvas, Rect currentTransformed canvas.PopClip(); if (Opacity != 1) canvas.PopOpacity(); + + if (Effect != null) + canvas.PopEffect(); } protected virtual bool HandlesClipToBounds => false; @@ -101,10 +107,18 @@ public ref ReadbackData GetReadback(int idx) public Matrix4x4 CombinedTransformMatrix { get; private set; } = Matrix4x4.Identity; public Matrix4x4 GlobalTransformMatrix { get; private set; } - public virtual void Update(ServerCompositionTarget root) + public record struct UpdateResult(Rect? Bounds, bool InvalidatedOld, bool InvalidatedNew) + { + public UpdateResult() : this(null, false, false) + { + + } + } + + public virtual UpdateResult Update(ServerCompositionTarget root) { if (Parent == null && Root == null) - return; + return default; var wasVisible = IsVisibleInFrame; @@ -146,6 +160,11 @@ public virtual void Update(ServerCompositionTarget root) GlobalTransformMatrix = newTransform; var ownBounds = OwnContentBounds; + + // Since padding is applied in the current visual's coordinate space we expand bounds before transforming them + if (Effect != null) + ownBounds = ownBounds.Inflate(Effect.GetEffectOutputPadding()); + if (ownBounds != _oldOwnContentBounds || positionChanged) { _oldOwnContentBounds = ownBounds; @@ -168,7 +187,7 @@ public virtual void Update(ServerCompositionTarget root) _combinedTransformedClipBounds = AdornedVisual?._combinedTransformedClipBounds - ?? Parent?._combinedTransformedClipBounds + ?? (Parent?.Effect == null ? Parent?._combinedTransformedClipBounds : null) ?? new Rect(Root!.Size); if (_transformedClipBounds != null) @@ -208,9 +227,10 @@ public virtual void Update(ServerCompositionTarget root) readback.Matrix = GlobalTransformMatrix; readback.TargetId = Root.Id; readback.Visible = IsHitTestVisibleInFrame; + return new(TransformedOwnContentBounds, invalidateNewBounds, invalidateOldBounds); } - void AddDirtyRect(Rect rc) + protected void AddDirtyRect(Rect rc) { if (rc == default) return; diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 05159eb4ae0..79cc760fc6c 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -48,7 +48,7 @@ public class Visual : StyledElement /// public static readonly StyledProperty ClipProperty = AvaloniaProperty.Register(nameof(Clip)); - + /// /// Defines the property. /// @@ -66,6 +66,12 @@ public class Visual : StyledElement /// public static readonly StyledProperty OpacityMaskProperty = AvaloniaProperty.Register(nameof(OpacityMask)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty EffectProperty = + AvaloniaProperty.Register(nameof(Effect)); /// /// Defines the property. @@ -127,6 +133,8 @@ static Visual() ClipToBoundsProperty, IsVisibleProperty, OpacityProperty, + OpacityMaskProperty, + EffectProperty, HasMirrorTransformProperty); RenderTransformProperty.Changed.Subscribe(RenderTransformChanged); ZIndexProperty.Changed.Subscribe(ZIndexChanged); @@ -233,6 +241,16 @@ public IBrush? OpacityMask get { return GetValue(OpacityMaskProperty); } set { SetValue(OpacityMaskProperty, value); } } + + /// + /// Gets or sets the effect of the control. + /// + public IEffect? Effect + { + get => GetValue(EffectProperty); + set => SetValue(EffectProperty, value); + } + /// /// Gets or sets a value indicating whether to apply mirror transform on this control. diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index 31722974eef..91d718dfd85 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -6,7 +6,8 @@ Avalonia.Rendering.Composition.Animations - + + @@ -27,7 +28,8 @@ - + + diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.Effects.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.Effects.cs new file mode 100644 index 00000000000..babc5472099 --- /dev/null +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.Effects.cs @@ -0,0 +1,50 @@ +using System; +using Avalonia.Media; +using SkiaSharp; + +namespace Avalonia.Skia; + +partial class DrawingContextImpl +{ + + public void PushEffect(IEffect effect) + { + CheckLease(); + using var filter = CreateEffect(effect); + var paint = SKPaintCache.Shared.Get(); + paint.ImageFilter = filter; + Canvas.SaveLayer(paint); + SKPaintCache.Shared.ReturnReset(paint); + } + + public void PopEffect() + { + CheckLease(); + Canvas.Restore(); + } + + SKImageFilter? CreateEffect(IEffect effect) + { + if (effect is IBlurEffect blur) + { + if (blur.Radius <= 0) + return null; + var sigma = SkBlurRadiusToSigma(blur.Radius); + return SKImageFilter.CreateBlur(sigma, sigma); + } + + if (effect is IDropShadowEffect drop) + { + var sigma = drop.BlurRadius > 0 ? SkBlurRadiusToSigma(drop.BlurRadius) : 0; + var alpha = drop.Color.A * drop.Opacity; + if (!_useOpacitySaveLayer) + alpha *= _currentOpacity; + var color = new SKColor(drop.Color.R, drop.Color.G, drop.Color.B, (byte)Math.Max(0, Math.Min(255, alpha))); + + return SKImageFilter.CreateDropShadow((float)drop.OffsetX, (float)drop.OffsetY, sigma, sigma, color); + } + + return null; + } + +} \ No newline at end of file diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 671e4d134c3..f48d45f961c 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -19,7 +19,9 @@ namespace Avalonia.Skia /// /// Skia based drawing context. /// - internal class DrawingContextImpl : IDrawingContextImpl, IDrawingContextWithAcrylicLikeSupport + internal partial class DrawingContextImpl : IDrawingContextImpl, + IDrawingContextWithAcrylicLikeSupport, + IDrawingContextImplWithEffects { private IDisposable?[]? _disposables; private readonly Vector _dpi; @@ -249,6 +251,12 @@ public void DrawGeometry(IBrush? brush, IPen? pen, IGeometryImpl geometry) } } + private static float SkBlurRadiusToSigma(double radius) { + if (radius <= 0) + return 0.0f; + return 0.288675f * (float)radius + 0.5f; + } + private struct BoxShadowFilter : IDisposable { public readonly SKPaint Paint; @@ -262,12 +270,6 @@ private BoxShadowFilter(SKPaint paint, SKImageFilter? filter, SKClipOperation cl ClipOperation = clipOperation; } - private static float SkBlurRadiusToSigma(double radius) { - if (radius <= 0) - return 0.0f; - return 0.288675f * (float)radius + 0.5f; - } - public static BoxShadowFilter Create(SKPaint paint, BoxShadow shadow, double opacity) { var ac = shadow.Color; diff --git a/tests/Avalonia.Base.UnitTests/Media/EffectTests.cs b/tests/Avalonia.Base.UnitTests/Media/EffectTests.cs new file mode 100644 index 00000000000..f3740184381 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/EffectTests.cs @@ -0,0 +1,73 @@ +using System; +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media; + +public class EffectTests +{ + [Fact] + public void Parse_Parses_Blur() + { + var effect = (ImmutableBlurEffect)Effect.Parse("blur(123.34)"); + Assert.Equal(123.34, effect.Radius); + } + + private const uint Black = 0xff000000; + + [Theory, + InlineData("drop-shadow(10 20)", 10, 20, 0, Black), + InlineData("drop-shadow( 10 20 ) ", 10, 20, 0, Black), + InlineData("drop-shadow( 10 20 30 ) ", 10, 20, 30, Black), + InlineData("drop-shadow(10 20 30)", 10, 20, 30, Black), + InlineData("drop-shadow(-10 -20 30)", -10, -20, 30, Black), + InlineData("drop-shadow(10 20 30 #ffff00ff)", 10, 20, 30, 0xffff00ff), + InlineData("drop-shadow ( 10 20 30 #ffff00ff ) ", 10, 20, 30, 0xffff00ff), + InlineData("drop-shadow(10 20 30 red)", 10, 20, 30, 0xffff0000), + InlineData("drop-shadow ( 10 20 30 red ) ", 10, 20, 30, 0xffff0000), + InlineData("drop-shadow(10 20 30 rgba(100, 30, 45, 90%))", 10, 20, 30, 0x90641e2d), + InlineData("drop-shadow(10 20 30 rgba(100, 30, 45, 90%) ) ", 10, 20, 30, 0x90641e2d), + + ] + public void Parse_Parses_DropShadow(string s, double x, double y, double r, uint color) + { + var effect = (ImmutableDropShadowEffect)Effect.Parse(s); + Assert.Equal(x, effect.OffsetX); + Assert.Equal(y, effect.OffsetY); + Assert.Equal(r, effect.BlurRadius); + Assert.Equal(1, effect.Opacity); + } + + [Theory, + InlineData("blur"), + InlineData("blur("), + InlineData("blur()"), + InlineData("blur(123"), + InlineData("blur(aaab)"), + InlineData("drop-shadow(-10 -20 -30)"), + ] + public void Invalid_Effect_Parse_Fails(string b) + { + Assert.Throws(() => Effect.Parse(b)); + } + + [Theory, + InlineData("blur(2.5)", 4, 4, 4, 4), + InlineData("blur(0)", 0, 0, 0, 0), + InlineData("drop-shadow(10 15)", 0, 0, 10, 15), + InlineData("drop-shadow(10 15 5)", 0, 0, 16, 21), + InlineData("drop-shadow(0 0 5)", 6, 6, 6, 6), + InlineData("drop-shadow(3 3 5)", 3, 3, 9, 9) + + + ] + + public static void PaddingIsCorrectlyCalculated(string effect, double left, double top, double right, double bottom) + { + var padding = Effect.Parse(effect).GetEffectOutputPadding(); + Assert.Equal(left, padding.Left); + Assert.Equal(top, padding.Top); + Assert.Equal(right, padding.Right); + Assert.Equal(bottom, padding.Bottom); + } +} \ No newline at end of file diff --git a/tests/Avalonia.RenderTests/Media/EffectTests.cs b/tests/Avalonia.RenderTests/Media/EffectTests.cs new file mode 100644 index 00000000000..9a83b397f4b --- /dev/null +++ b/tests/Avalonia.RenderTests/Media/EffectTests.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Media; +using Xunit; +#pragma warning disable CS0649 + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; + +public class EffectTests : TestBase +{ + public EffectTests() : base(@"Media\Effects") + { + } + + [Fact] + public async Task DropShadowEffect() + { + var target = new Border + { + Width = 200, + Height = 200, + Background = Brushes.White, + Child = new Border() + { + Background = null, + Margin = new Thickness(40), + Effect = new ImmutableDropShadowEffect(20, 30, 5, Colors.Green, 1), + Child = new Border + { + Background = new SolidColorBrush(Color.FromArgb(128, 0, 0, 255)), + BorderBrush = Brushes.Red, + BorderThickness = new Thickness(5) + } + } + }; + + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + +} +#endif \ No newline at end of file diff --git a/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj b/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj index ca9f5ed9744..d149138fe6c 100644 --- a/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj +++ b/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj @@ -6,6 +6,9 @@ + + Media\EffectTests.cs + diff --git a/tests/TestFiles/Skia/Media/Effects/DropShadowEffect.expected.png b/tests/TestFiles/Skia/Media/Effects/DropShadowEffect.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..e826b25c65fb92311970923649d297e044da8abf GIT binary patch literal 2270 zcmchZX;9Ni7QivElERY(3dVq-97H!oexif9^IE+vcZGc-ba9jrce z1TSq81>S=v^XaH+hPDs-4a)q!;j5RiNkJ)zt7L_B$1*gT+3#1&4J>+ymN)OX&=d7) zMExM#{18=pxZ5`d&+BqKKs`YF7YO#t!>y!bzy?*dQqw)-KXk1&%7#~6{9|w~<|`uL z>V4Gof@)ts0>;c~@Qtl;ef%r5@yTxKfq&}1onxP5%iEBI^r?u;Rf^BgBMR@BFn_0N z`1LA8YZZg``HW@j20X3`uvwpNpB)J1K0<5lGc~>E@4h1bJVZ%BI(1H1q}M|hrG zX^ja}R^;~D1BiM0x@H%R?E3~4BXP^WdQ8BUQf!E`fj3)lnI)on$EB1|hmg%pS^54g zl4yP!O>ZH%KP$E+!{1}#YJ~3^fSoA9vq>m*_~cwh5h!eW?dw}E(gBU+5Q+d=&vG`o z$`1Hdvrl+6>DrjQiIc`%u6L$Mn)#x6B<(U5D-Dy~#rT_7->nx3=yL|?tjX4LJn(j1 zZqi45X#JDi=$c#8&*^7VKCFI-n^P4E*X%t8-}azNcF}KF_WL6OSWM!|@X070UwpVQ z=EXwT^2~y2!Epxs91?-ZghB5S>im|j+IBfwNf?G}{Mai)couK!eAzT#a$ zi7FW-Ou0rq&S6(&fn7#}rB}Y5k-BHsK0xcXApB~7e|_H4I?Um5-H31A_ic(ABChI! ze}6k60m>9o-?X*!d2jXBW^kad0Zt9j;EGYfKAkE{+T0G$E08^fHyifTi*$U3p_d^? z`mx$xcvj(Ps+*F#(qhin0o*+ld83I1|S&IqNE9O}mT7vvh zeU!=Wbxwl_%Z$ZD%)HZjY!>M0F{W9NV`B(qn$fabIAj1Y7hll+tqsgNA02K6s05F}v5~yVpu5GRi5f$`e*T_0K+s@Dr_`O-|tN7w``M zjTHDhNb|pac<)#IqocdWNz@bqvQ#tv6Z$w}tM}xTRiE(Oz~eiTgHnbBlupTVNcpPx zb7AL((lzgFUM375=w;JhECy;8$W}30tElr<0>j}27fReesh^W zs48~Pp1fHW*f14X_lQPDQZ2yKT`Py0G5%2o0=!RM{%PCVR!)wY?l4~MJozS6NDo@BK;%iE$oQ89QOpIc-k39BE6 z;ySzqlW=k|S_^W7b8b1l@q$0poOyZXCEr9>E1ir)BF7b$VE9p2s=i5_dZ+=PQ_{Vf z_cCf;kbQs}Wlprt35*_+VDMk_yGZQgIss}K-_{BG20BhTz zkpqBH^eSm0(r6(=c(GBt(Ob+45wYsyY*|Bq(h$Bj7UqjVbDG9@0qP&ioDd=S;M}-l ztalf`9qK6ko<`u?;!juAkGU4O>L0S4`>yNbf0!qak(U76CAit{qlXuD>gR4*4%+X9 z%f{Ow)#l6=|rAcvbGYG zEHLMfsJ~BaQ+$?1Gg5Wi^AzJb9k-o-68x5C{NH9FZEKrf4oOSJf`