diff --git a/samples/RenderDemo/Pages/AnimationsPage.xaml b/samples/RenderDemo/Pages/AnimationsPage.xaml index 3f89a9d5f7..d764af8978 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/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs index 549815a036..251a177432 100644 --- a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs +++ b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs @@ -81,7 +81,7 @@ namespace Avalonia.Android.Platform var renderScaling = _topLevel.RenderScaling; var inset = insets.GetInsets( - (DisplayEdgeToEdge ? + (_displayEdgeToEdge ? WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars() | WindowInsetsCompat.Type.DisplayCutout() : 0) | WindowInsetsCompat.Type.Ime()); @@ -91,8 +91,8 @@ namespace Avalonia.Android.Platform return new Thickness(inset.Left / renderScaling, inset.Top / renderScaling, inset.Right / renderScaling, - (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !DisplayEdgeToEdge) || !_usesLegacyLayouts) ? - imeInset.Bottom - navBarInset.Bottom : + (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !_displayEdgeToEdge) || !_usesLegacyLayouts) ? + imeInset.Bottom - (_displayEdgeToEdge ? 0 : navBarInset.Bottom) : inset.Bottom) / renderScaling); } diff --git a/src/Avalonia.Base/Media/Effects/BlurEffect.cs b/src/Avalonia.Base/Media/Effects/BlurEffect.cs new file mode 100644 index 0000000000..47c86e4e42 --- /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 0000000000..67be74fe49 --- /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 0000000000..182e8613f8 --- /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 0000000000..70d359911b --- /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 0000000000..6ec3bace03 --- /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 0000000000..adc287607b --- /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 0000000000..b2e0d07355 --- /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 0000000000..716159747c --- /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 0000000000..30d787198c --- /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 0000000000..698dccf1dd --- /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/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index a40cbf95ad..a609800fb8 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -57,21 +57,21 @@ namespace Avalonia.Media.TextFormatting switch (paragraphProperties.TextWrapping) { case TextWrapping.NoWrap: - { - var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex, - textSourceLength, - paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak); + { + var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex, + textSourceLength, + paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak); textLine.FinalizeLine(); - return textLine; - } + return textLine; + } case TextWrapping.WrapWithOverflow: case TextWrapping.Wrap: - { - return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth, - paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool); - } + { + return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth, + paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool); + } default: throw new ArgumentOutOfRangeException(nameof(paragraphProperties.TextWrapping)); } @@ -568,9 +568,9 @@ namespace Avalonia.Media.TextFormatting return false; } - private static bool TryMeasureLength(IReadOnlyList textRuns, double paragraphWidth, out int measuredLength) + private static int MeasureLength(IReadOnlyList textRuns, double paragraphWidth) { - measuredLength = 0; + var measuredLength = 0; var currentWidth = 0.0; for (var i = 0; i < textRuns.Count; ++i) @@ -583,25 +583,59 @@ namespace Avalonia.Media.TextFormatting { if (shapedTextCharacters.ShapedBuffer.Length > 0) { - var firstCluster = shapedTextCharacters.ShapedBuffer[0].GlyphCluster; - var lastCluster = firstCluster; + var runLength = 0; for (var j = 0; j < shapedTextCharacters.ShapedBuffer.Length; j++) { - var glyphInfo = shapedTextCharacters.ShapedBuffer[j]; + var currentInfo = shapedTextCharacters.ShapedBuffer[j]; + + var clusterWidth = currentInfo.GlyphAdvance; - if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth) + GlyphInfo nextInfo = default; + + while (j + 1 < shapedTextCharacters.ShapedBuffer.Length) { - measuredLength += Math.Max(0, lastCluster - firstCluster); + nextInfo = shapedTextCharacters.ShapedBuffer[j + 1]; + + if (currentInfo.GlyphCluster == nextInfo.GlyphCluster) + { + clusterWidth += nextInfo.GlyphAdvance; + + j++; + + continue; + } + + break; + } - return measuredLength != 0; + var clusterLength = Math.Max(0, nextInfo.GlyphCluster - currentInfo.GlyphCluster); + + if(clusterLength == 0) + { + clusterLength = currentRun.Length - runLength; + } + + if(clusterLength == 0) + { + clusterLength = shapedTextCharacters.GlyphRun.Metrics.FirstCluster + currentRun.Length - currentInfo.GlyphCluster; + } + + if (currentWidth + clusterWidth > paragraphWidth) + { + if (runLength == 0 && measuredLength == 0) + { + runLength = clusterLength; + } + + return measuredLength + runLength; } - lastCluster = glyphInfo.GlyphCluster; - currentWidth += glyphInfo.GlyphAdvance; + currentWidth += clusterWidth; + runLength += clusterLength; } - measuredLength += currentRun.Length; + measuredLength += runLength; } break; @@ -611,7 +645,7 @@ namespace Avalonia.Media.TextFormatting { if (currentWidth + drawableTextRun.Size.Width >= paragraphWidth) { - return measuredLength != 0; + return measuredLength; } measuredLength += currentRun.Length; @@ -628,7 +662,7 @@ namespace Avalonia.Media.TextFormatting } } - return measuredLength != 0; + return measuredLength; } /// @@ -675,9 +709,11 @@ namespace Avalonia.Media.TextFormatting return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties); } - if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength)) + var measuredLength = MeasureLength(textRuns, paragraphWidth); + + if(measuredLength == 0) { - measuredLength = 1; + } var currentLength = 0; @@ -798,6 +834,12 @@ namespace Avalonia.Media.TextFormatting continue; } + //We don't want to surpass the measuredLength with trailing whitespace when we are in a right to left setting. + if(currentPosition > measuredLength && resolvedFlowDirection == FlowDirection.RightToLeft) + { + break; + } + measuredLength = currentPosition; break; diff --git a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs index ffdfa9aac1..1359ad6603 100644 --- a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs +++ b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs @@ -180,6 +180,12 @@ namespace Avalonia.Platform 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 cc030eea04..fc5d0fc043 100644 --- a/src/Avalonia.Base/Rect.cs +++ b/src/Avalonia.Base/Rect.cs @@ -526,6 +526,15 @@ namespace Avalonia } } + 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 df3a70b3e6..814ecdba29 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -252,8 +252,14 @@ public class CompositingRenderer : IRendererWithCompositor 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 bb7372c375..8ecc0028ce 100644 --- a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs +++ b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs @@ -29,6 +29,8 @@ namespace Avalonia.Rendering.Composition.Expressions } } + 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 @@ namespace Avalonia.Rendering.Composition.Expressions len = c + 1; dotCount++; } + else if (ch == '-') + { + if (len != 0) + return false; + len = c + 1; + } else break; } @@ -254,7 +262,55 @@ namespace Avalonia.Rendering.Composition.Expressions 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 eaa9a70ca0..1ec1362a4c 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 @@ internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingCont 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 19349a5196..b9e6833d21 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 @@ namespace Avalonia.Rendering.Composition.Server } } - 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 6fb5ad3741..6e7ef85183 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs @@ -54,6 +54,9 @@ namespace Avalonia.Rendering.Composition.Server 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 @@ namespace Avalonia.Rendering.Composition.Server canvas.PopClip(); if (Opacity != 1) canvas.PopOpacity(); + + if (Effect != null) + canvas.PopEffect(); } protected virtual bool HandlesClipToBounds => false; @@ -101,10 +107,18 @@ namespace Avalonia.Rendering.Composition.Server 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 @@ namespace Avalonia.Rendering.Composition.Server 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 @@ namespace Avalonia.Rendering.Composition.Server _combinedTransformedClipBounds = AdornedVisual?._combinedTransformedClipBounds - ?? Parent?._combinedTransformedClipBounds + ?? (Parent?.Effect == null ? Parent?._combinedTransformedClipBounds : null) ?? new Rect(Root!.Size); if (_transformedClipBounds != null) @@ -208,9 +227,10 @@ namespace Avalonia.Rendering.Composition.Server 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/Rendering/SceneGraph/CustomDrawOperation.cs b/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs index ff2616bfe4..8f5ccb4e51 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs @@ -13,7 +13,7 @@ namespace Avalonia.Rendering.SceneGraph Custom = custom; } - public override bool HitTest(Point p) => Custom.HitTest(p); + public override bool HitTestTransformed(Point p) => Custom.HitTest(p); public override void Render(IDrawingContextImpl context) { diff --git a/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs b/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs index 5b93cd8cfc..786ce28d06 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/DrawOperation.cs @@ -37,5 +37,20 @@ namespace Avalonia.Rendering.SceneGraph } public Matrix Transform { get; } + + public sealed override bool HitTest(Point p) + { + if (Transform.IsIdentity) + return HitTestTransformed(p); + + if (!Transform.HasInverse) + return false; + + var transformedPoint = Transform.Invert().Transform(p); + + return HitTestTransformed(transformedPoint); + } + + public abstract bool HitTestTransformed(Point p); } } diff --git a/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs index d5f0270cb2..0a2b74e46a 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/EllipseNode.cs @@ -43,7 +43,7 @@ namespace Avalonia.Rendering.SceneGraph public override void Render(IDrawingContextImpl context) => context.DrawEllipse(Brush, Pen, Rect); - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { var center = Rect.Center; diff --git a/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs index e1f79e0e10..22fc49d30e 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/ExperimentalAcrylicNode.cs @@ -65,6 +65,6 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) => Rect.Rect.ContainsExclusive(p); + public override bool HitTestTransformed(Point p) => Rect.Rect.ContainsExclusive(p); } } diff --git a/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs index f64a3e845d..48af3b0e6b 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/GeometryNode.cs @@ -64,7 +64,7 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { return (Brush != null && Geometry.FillContains(p)) || (Pen != null && Geometry.StrokeContains(Pen, p)); diff --git a/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs index 5b975e29e1..1c4e63b34a 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/GlyphRunNode.cs @@ -53,7 +53,10 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) => Bounds.ContainsExclusive(p); + public override bool HitTestTransformed(Point p) + { + return GlyphRun.Item.Bounds.ContainsExclusive(p); + } public override void Dispose() { diff --git a/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs index dd9787e8d1..ac946cc8b2 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/ImageNode.cs @@ -94,7 +94,7 @@ namespace Avalonia.Rendering.SceneGraph } /// - public override bool HitTest(Point p) => DestRect.ContainsExclusive(p); + public override bool HitTestTransformed(Point p) => DestRect.ContainsExclusive(p); public override void Dispose() { diff --git a/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs index 61bffc3260..1ac6cffe0a 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/LineNode.cs @@ -66,7 +66,7 @@ namespace Avalonia.Rendering.SceneGraph context.DrawLine(Pen, P1, P2); } - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { var halfThickness = Pen.Thickness / 2; var minX = Math.Min(P1.X, P2.X) - halfThickness; diff --git a/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs index b0584038a8..1c79a67944 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/OpacityMaskNode.cs @@ -30,7 +30,7 @@ namespace Avalonia.Rendering.SceneGraph /// - public override bool HitTest(Point p) => false; + public override bool HitTestTransformed(Point p) => false; /// /// Determines if this draw operation equals another. diff --git a/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs b/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs index 94f61df47d..e85992be34 100644 --- a/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs +++ b/src/Avalonia.Base/Rendering/SceneGraph/RectangleNode.cs @@ -74,7 +74,7 @@ namespace Avalonia.Rendering.SceneGraph public override void Render(IDrawingContextImpl context) => context.DrawRectangle(Brush, Pen, Rect, BoxShadows); /// - public override bool HitTest(Point p) + public override bool HitTestTransformed(Point p) { if (Brush != null) { diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 731cb97161..5881efce1e 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -78,7 +78,7 @@ namespace Avalonia private static readonly ControlTheme s_invalidTheme = new ControlTheme(); private int _initCount; private string? _name; - private readonly Classes _classes = new Classes(); + private Classes? _classes; private ILogicalRoot? _logicalRoot; private IAvaloniaList? _logicalChildren; private IResourceDictionary? _resources; @@ -183,21 +183,7 @@ namespace Avalonia /// collection. /// /// - public Classes Classes - { - get - { - return _classes; - } - - set - { - if (_classes != value) - { - _classes.Replace(value); - } - } - } + public Classes Classes => _classes ??= new(); /// /// Gets or sets the control's data context. diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index c6e9203f70..6842e4a255 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -557,10 +557,10 @@ public partial class Dispatcher /// /// An task that completes after the task returned from callback finishes /// - public Task InvokeTaskAsync(Func callback, DispatcherPriority priority = default) + public Task InvokeAsync(Func callback, DispatcherPriority priority = default) { _ = callback ?? throw new ArgumentNullException(nameof(callback)); - return InvokeAsync(callback, priority).GetTask().Unwrap(); + return InvokeAsync(callback, priority).GetTask().Unwrap(); } /// @@ -578,10 +578,10 @@ public partial class Dispatcher /// /// An task that completes after the task returned from callback finishes /// - public Task InvokeTaskAsync(Func> action, DispatcherPriority priority = default) + public Task InvokeAsync(Func> action, DispatcherPriority priority = default) { _ = action ?? throw new ArgumentNullException(nameof(action)); - return InvokeAsync(action, priority).GetTask().Unwrap(); + return InvokeAsync>(action, priority).GetTask().Unwrap(); } /// diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 05159eb4ae..79cc760fc6 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -48,7 +48,7 @@ namespace Avalonia /// public static readonly StyledProperty ClipProperty = AvaloniaProperty.Register(nameof(Clip)); - + /// /// Defines the property. /// @@ -66,6 +66,12 @@ namespace Avalonia /// 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 @@ namespace Avalonia ClipToBoundsProperty, IsVisibleProperty, OpacityProperty, + OpacityMaskProperty, + EffectProperty, HasMirrorTransformProperty); RenderTransformProperty.Changed.Subscribe(RenderTransformChanged); ZIndexProperty.Changed.Subscribe(ZIndexChanged); @@ -233,6 +241,16 @@ namespace Avalonia 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 31722974ee..91d718dfd8 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/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index 944e7b737a..030776ba14 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -3962,6 +3962,7 @@ namespace Avalonia.Controls bool focusLeftDataGrid = true; bool dataGridWillReceiveRoutedEvent = true; Visual focusedObject = FocusManager.Instance.Current as Visual; + DataGridColumn editingColumn = null; while (focusedObject != null) { @@ -3974,22 +3975,29 @@ namespace Avalonia.Controls // Walk up the visual tree. If we hit the root, try using the framework element's // parent. We do this because Popups behave differently with respect to the visual tree, // and it could have a parent even if the VisualTreeHelper doesn't find it. - Visual parent = focusedObject.GetVisualParent(); + var parent = focusedObject.Parent as Visual; if (parent == null) { - if (focusedObject is Control element) - { - parent = element.VisualParent; - if (parent != null) - { - dataGridWillReceiveRoutedEvent = false; - } - } + parent = focusedObject.GetVisualParent(); + } + else + { + dataGridWillReceiveRoutedEvent = false; } focusedObject = parent; } - if (focusLeftDataGrid) + if (EditingRow != null && EditingColumnIndex != -1) + { + editingColumn = ColumnsItemsInternal[EditingColumnIndex]; + + if (focusLeftDataGrid && editingColumn is DataGridTemplateColumn) + { + dataGridWillReceiveRoutedEvent = false; + } + } + + if (focusLeftDataGrid && !(editingColumn is DataGridTemplateColumn)) { ContainsFocus = false; if (EditingRow != null) diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index e8fb6b75ad..d04dfec3e8 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -88,10 +88,10 @@ namespace Avalonia.Automation.Peers if (string.IsNullOrWhiteSpace(result) && GetLabeledBy() is AutomationPeer labeledBy) { - return labeledBy.GetName(); + result = labeledBy.GetName(); } - return null; + return result; } protected override AutomationPeer? GetParentCore() diff --git a/src/Avalonia.Controls/Chrome/CaptionButtons.cs b/src/Avalonia.Controls/Chrome/CaptionButtons.cs index f5fcbed9fb..90dd14c703 100644 --- a/src/Avalonia.Controls/Chrome/CaptionButtons.cs +++ b/src/Avalonia.Controls/Chrome/CaptionButtons.cs @@ -15,6 +15,7 @@ namespace Avalonia.Controls.Chrome [PseudoClasses(":minimized", ":normal", ":maximized", ":fullscreen")] public class CaptionButtons : TemplatedControl { + private Button? _restoreButton; private IDisposable? _disposables; /// @@ -28,14 +29,23 @@ namespace Avalonia.Controls.Chrome { HostWindow = hostWindow; - _disposables = HostWindow.GetObservable(Window.WindowStateProperty) - .Subscribe(x => - { - PseudoClasses.Set(":minimized", x == WindowState.Minimized); - PseudoClasses.Set(":normal", x == WindowState.Normal); - PseudoClasses.Set(":maximized", x == WindowState.Maximized); - PseudoClasses.Set(":fullscreen", x == WindowState.FullScreen); - }); + _disposables = new CompositeDisposable + { + HostWindow.GetObservable(Window.CanResizeProperty) + .Subscribe(x => + { + if (_restoreButton is not null) + _restoreButton.IsEnabled = x; + }), + HostWindow.GetObservable(Window.WindowStateProperty) + .Subscribe(x => + { + PseudoClasses.Set(":minimized", x == WindowState.Minimized); + PseudoClasses.Set(":normal", x == WindowState.Normal); + PseudoClasses.Set(":maximized", x == WindowState.Maximized); + PseudoClasses.Set(":fullscreen", x == WindowState.FullScreen); + }), + }; } } @@ -94,6 +104,9 @@ namespace Avalonia.Controls.Chrome restoreButton.Click += (sender, e) => OnRestore(); minimiseButton.Click += (sender, e) => OnMinimize(); fullScreenButton.Click += (sender, e) => OnToggleFullScreen(); + + restoreButton.IsEnabled = HostWindow?.CanResize ?? true; + _restoreButton = restoreButton; } } } diff --git a/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs b/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs index 9774b603e8..69b0ffe9a6 100644 --- a/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs +++ b/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs @@ -454,7 +454,7 @@ namespace Avalonia.Controls.Primitives children.Add(new ListBoxItem { Height = ItemHeight, - Classes = new Classes($"{PanelType}Item"), + Classes = { $"{PanelType}Item" }, VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center, Focusable = false }); diff --git a/src/Avalonia.Controls/Flyouts/Flyout.cs b/src/Avalonia.Controls/Flyouts/Flyout.cs index c0d3600c1e..8ec5cfa50a 100644 --- a/src/Avalonia.Controls/Flyouts/Flyout.cs +++ b/src/Avalonia.Controls/Flyouts/Flyout.cs @@ -13,13 +13,13 @@ namespace Avalonia.Controls public static readonly StyledProperty ContentProperty = AvaloniaProperty.Register(nameof(Content)); + private Classes? _classes; + /// /// Gets the Classes collection to apply to the FlyoutPresenter this Flyout is hosting /// public Classes FlyoutPresenterClasses => _classes ??= new Classes(); - private Classes? _classes; - /// /// Defines the property. /// diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index b55ae74965..19eaaaa0d9 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -663,7 +663,6 @@ namespace Avalonia.Controls var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); _constraint = availableSize.Deflate(padding); - _textLayout?.Dispose(); _textLayout = null; @@ -690,18 +689,18 @@ namespace Avalonia.Controls inline.BuildTextRun(textRuns); } - foreach (var textRun in textRuns) + _textRuns = textRuns; + + foreach (var textRun in _textRuns) { if (textRun is EmbeddedControlRun controlRun && - controlRun.Control is Control control) + controlRun.Control is Control control) { VisualChildren.Add(control); control.Measure(Size.Infinity); } } - - _textRuns = textRuns; } var measuredSize = TextLayout.Bounds.Size.Inflate(padding); @@ -711,64 +710,39 @@ namespace Avalonia.Controls protected override Size ArrangeOverride(Size finalSize) { - var textWidth = Math.Ceiling(TextLayout.Bounds.Width); - - if (finalSize.Width < textWidth) - { - finalSize = finalSize.WithWidth(textWidth); - } - - var scale = LayoutHelper.GetLayoutScale(this); - - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); - if (HasComplexContent) { - ArrangeComplexContent(TextLayout, padding); - } + var scale = LayoutHelper.GetLayoutScale(this); - if (MathUtilities.AreClose(_constraint.Inflate(padding).Width, finalSize.Width)) - { - return finalSize; - } + var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); - _constraint = new Size(Math.Ceiling(finalSize.Deflate(padding).Width), double.PositiveInfinity); + var currentY = padding.Top; - _textLayout?.Dispose(); - _textLayout = null; - - if (HasComplexContent) - { - ArrangeComplexContent(TextLayout, padding); - } - - return finalSize; - } - - private static void ArrangeComplexContent(TextLayout textLayout, Thickness padding) - { - var currentY = padding.Top; - - foreach (var textLine in textLayout.TextLines) - { - var currentX = padding.Left + textLine.Start; - - foreach (var run in textLine.TextRuns) + foreach (var textLine in TextLayout.TextLines) { - if (run is DrawableTextRun drawable) + var currentX = padding.Left + textLine.Start; + + foreach (var run in textLine.TextRuns) { - if (drawable is EmbeddedControlRun controlRun - && controlRun.Control is Control control) + if (run is DrawableTextRun drawable) { - control.Arrange(new Rect(new Point(currentX, currentY), control.DesiredSize)); + if (drawable is EmbeddedControlRun controlRun + && controlRun.Control is Control control) + { + control.Arrange( + new Rect(new Point(currentX, currentY), + new Size(control.DesiredSize.Width, textLine.Height))); + } + + currentX += drawable.Size.Width; } - - currentX += drawable.Size.Width; } - } - currentY += textLine.Height; + currentY += textLine.Height; + } } + + return finalSize; } protected override AutomationPeer OnCreateAutomationPeer() diff --git a/src/Avalonia.Native/AvaloniaNativeTextInputMethod.cs b/src/Avalonia.Native/AvaloniaNativeTextInputMethod.cs index 8438ef10b5..4c93c06ef3 100644 --- a/src/Avalonia.Native/AvaloniaNativeTextInputMethod.cs +++ b/src/Avalonia.Native/AvaloniaNativeTextInputMethod.cs @@ -98,7 +98,7 @@ namespace Avalonia.Native var surroundingText = _client.SurroundingText; _inputMethod.SetSurroundingText( - surroundingText.Text, + surroundingText.Text ?? "", surroundingText.AnchorOffset, surroundingText.CursorOffset ); diff --git a/src/Avalonia.Native/WindowImpl.cs b/src/Avalonia.Native/WindowImpl.cs index 5d0e6a2d18..817fe3d080 100644 --- a/src/Avalonia.Native/WindowImpl.cs +++ b/src/Avalonia.Native/WindowImpl.cs @@ -21,6 +21,7 @@ namespace Avalonia.Native private DoubleClickHelper _doubleClickHelper; private readonly ITopLevelNativeMenuExporter _nativeMenuExporter; private readonly AvaloniaNativeTextInputMethod _inputMethod; + private bool _canResize = true; internal WindowImpl(IAvaloniaNativeFactory factory, AvaloniaNativePlatformOptions opts, AvaloniaNativeGlPlatformGraphics glFeature) : base(factory, opts, glFeature) @@ -75,6 +76,7 @@ namespace Avalonia.Native public void CanResize(bool value) { + _canResize = value; _native.SetCanResize(value.AsComBool()); } @@ -137,14 +139,10 @@ namespace Avalonia.Native { if (_doubleClickHelper.IsDoubleClick(e.Timestamp, e.Position)) { - // TOGGLE WINDOW STATE. - if (WindowState == WindowState.Maximized || WindowState == WindowState.FullScreen) + if (_canResize) { - WindowState = WindowState.Normal; - } - else - { - WindowState = WindowState.Maximized; + WindowState = WindowState is WindowState.Maximized or WindowState.FullScreen ? + WindowState.Normal : WindowState.Maximized; } } else diff --git a/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml b/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml index 71ae012289..666717acde 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml @@ -48,7 +48,8 @@ "; + var target = (Button)AvaloniaRuntimeXamlLoader.Load(xaml); + + Assert.Equal(new[] { "foo", "bar" }, target.Classes); + } + + [Fact] + public void Can_Specify_Flyout_FlyoutPresenterClasses() + { + var xaml = ""; + var target = (Flyout)AvaloniaRuntimeXamlLoader.Load(xaml); + + Assert.Equal(new[] { "foo", "bar" }, target.FlyoutPresenterClasses); + } + + [Fact] + public void Trying_To_Bind_ItemsControl_Items_Throws() + { + var xaml = ""; + + Assert.ThrowsAny(() => AvaloniaRuntimeXamlLoader.Load(xaml)); + } + private class SelectedItemsViewModel : INotifyPropertyChanged { public string[] Items { get; set; } diff --git a/tests/Avalonia.RenderTests/Media/EffectTests.cs b/tests/Avalonia.RenderTests/Media/EffectTests.cs new file mode 100644 index 0000000000..9a83b397f4 --- /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 ca9f5ed974..d149138fe6 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/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index cdcaadeff8..8c16e1046b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -416,7 +416,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } [InlineData("Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor", - new[] { "Lorem ipsum ", "dolor sit ", "amet, ", "consectetur ", "adipisicing ", "elit, sed do ", "eiusmod tempor" })] + new[] { "Lorem ipsum ", "dolor sit amet, ", "consectetur ", "adipisicing ", "elit, sed do ", "eiusmod tempor" })] [Theory] public void Should_Produce_Wrapped_And_Trimmed_Lines(string text, string[] expectedLines) 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 0000000000..e826b25c65 Binary files /dev/null and b/tests/TestFiles/Skia/Media/Effects/DropShadowEffect.expected.png differ