From 0b1b3914b347c616ec63265942f67f3f547f4dba Mon Sep 17 00:00:00 2001 From: Benedikt Schroeder Date: Mon, 27 Apr 2020 12:47:37 +0200 Subject: [PATCH 01/11] More text rendering improvements --- src/Avalonia.Visuals/Media/FontManager.cs | 26 +++- .../Media/Fonts/FamilyNameCollection.cs | 3 +- src/Avalonia.Visuals/Media/Fonts/FontKey.cs | 10 +- .../Media/TextFormatting/TextFormatter.cs | 2 +- .../Unicode/CodepointEnumerator.cs | 2 +- .../Unicode/UnicodeGeneralCategory.cs | 44 ------- src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 67 ++++++---- src/Skia/Avalonia.Skia/FontManagerImpl.cs | 46 +++++-- src/Skia/Avalonia.Skia/FormattedTextImpl.cs | 3 +- src/Skia/Avalonia.Skia/GlyphRunImpl.cs | 9 +- .../Avalonia.Skia/PlatformRenderInterface.cs | 116 +++++++++--------- .../Avalonia.Skia/SKTypefaceCollection.cs | 4 +- .../SKTypefaceCollectionCache.cs | 2 +- .../Media/FontManagerImpl.cs | 2 +- .../CustomFontManagerImpl.cs | 48 ++++++-- .../FontManagerImplTests.cs | 13 ++ .../TextLayoutTests.cs | 24 +++- .../Avalonia.UnitTests/MockFontManagerImpl.cs | 11 +- .../Media/FontManagerTests.cs | 12 +- 19 files changed, 271 insertions(+), 173 deletions(-) delete mode 100644 src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs diff --git a/src/Avalonia.Visuals/Media/FontManager.cs b/src/Avalonia.Visuals/Media/FontManager.cs index 2de629432c..f9410afe6a 100644 --- a/src/Avalonia.Visuals/Media/FontManager.cs +++ b/src/Avalonia.Visuals/Media/FontManager.cs @@ -23,6 +23,11 @@ namespace Avalonia.Media DefaultFontFamilyName = PlatformImpl.GetDefaultFontFamilyName(); + if (string.IsNullOrEmpty(DefaultFontFamilyName)) + { + throw new InvalidOperationException("Default font family name can't be null or empty."); + } + _defaultFontFamily = new FontFamily(DefaultFontFamilyName); } @@ -39,7 +44,8 @@ namespace Avalonia.Media var fontManagerImpl = AvaloniaLocator.Current.GetService(); - if (fontManagerImpl == null) throw new InvalidOperationException("No font manager implementation was registered."); + if (fontManagerImpl == null) + throw new InvalidOperationException("No font manager implementation was registered."); current = new FontManager(fontManagerImpl); @@ -87,7 +93,7 @@ namespace Avalonia.Media fontFamily = _defaultFontFamily; } - var key = new FontKey(fontFamily, fontWeight, fontStyle); + var key = new FontKey(fontFamily.Name, fontWeight, fontStyle); if (_typefaceCache.TryGetValue(key, out var typeface)) { @@ -126,9 +132,21 @@ namespace Avalonia.Media FontStyle fontStyle = FontStyle.Normal, FontFamily fontFamily = null, CultureInfo culture = null) { - return PlatformImpl.TryMatchCharacter(codepoint, fontWeight, fontStyle, fontFamily, culture, out var key) ? - _typefaceCache.GetOrAdd(key, new Typeface(key.FontFamily, key.Weight, key.Style)) : + foreach (var cachedTypeface in _typefaceCache.Values) + { + // First try to find a cached typeface by style and weight to avoid redundant glyph index lookup. + if (cachedTypeface.Style == fontStyle && cachedTypeface.Weight == fontWeight + && cachedTypeface.GlyphTypeface.GetGlyph((uint)codepoint) != 0) + { + return cachedTypeface; + } + } + + var matchedTypeface = PlatformImpl.TryMatchCharacter(codepoint, fontWeight, fontStyle, fontFamily, culture, out var key) ? + _typefaceCache.GetOrAdd(key, new Typeface(key.FamilyName, key.Weight, key.Style)) : null; + + return matchedTypeface; } } } diff --git a/src/Avalonia.Visuals/Media/Fonts/FamilyNameCollection.cs b/src/Avalonia.Visuals/Media/Fonts/FamilyNameCollection.cs index a9ea322d76..96312a5466 100644 --- a/src/Avalonia.Visuals/Media/Fonts/FamilyNameCollection.cs +++ b/src/Avalonia.Visuals/Media/Fonts/FamilyNameCollection.cs @@ -1,7 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Linq; using System.Text; using Avalonia.Utilities; @@ -21,7 +20,7 @@ namespace Avalonia.Media.Fonts throw new ArgumentNullException(nameof(familyNames)); } - Names = familyNames.Split(',').Select(x => x.Trim()).ToArray(); + Names = Array.ConvertAll(familyNames.Split(','), p => p.Trim()); PrimaryFamilyName = Names[0]; diff --git a/src/Avalonia.Visuals/Media/Fonts/FontKey.cs b/src/Avalonia.Visuals/Media/Fonts/FontKey.cs index 1f1e9b067d..579a229fd3 100644 --- a/src/Avalonia.Visuals/Media/Fonts/FontKey.cs +++ b/src/Avalonia.Visuals/Media/Fonts/FontKey.cs @@ -4,20 +4,20 @@ namespace Avalonia.Media.Fonts { public readonly struct FontKey : IEquatable { - public readonly FontFamily FontFamily; + public readonly string FamilyName; public readonly FontStyle Style; public readonly FontWeight Weight; - public FontKey(FontFamily fontFamily, FontWeight weight, FontStyle style) + public FontKey(string familyName, FontWeight weight, FontStyle style) { - FontFamily = fontFamily; + FamilyName = familyName; Style = style; Weight = weight; } public override int GetHashCode() { - var hash = FontFamily.GetHashCode(); + var hash = FamilyName.GetHashCode(); hash = hash * 31 + (int)Style; hash = hash * 31 + (int)Weight; @@ -32,7 +32,7 @@ namespace Avalonia.Media.Fonts public bool Equals(FontKey other) { - return FontFamily == other.FontFamily && + return FamilyName == other.FamilyName && Style == other.Style && Weight == other.Weight; } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs index 7956c5f260..7da39dc5dc 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs @@ -66,7 +66,7 @@ namespace Avalonia.Media.TextFormatting //ToDo: Fix FontFamily fallback currentTypeface = - FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style); + FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style, defaultStyle.TextFormat.Typeface.FontFamily); if (currentTypeface != null && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count)) { diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs index 9c20efd867..2ff4952cab 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs @@ -2,7 +2,7 @@ namespace Avalonia.Media.TextFormatting.Unicode { - internal ref struct CodepointEnumerator + public ref struct CodepointEnumerator { private ReadOnlySlice _text; diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs deleted file mode 100644 index 3385116f26..0000000000 --- a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Avalonia.Media.TextFormatting.Unicode -{ - public enum UnicodeGeneralCategory : byte - { - Other, //C# Cc | Cf | Cn | Co | Cs - Control, //Cc - Format, //Cf - Unassigned, //Cn - PrivateUse, //Co - Surrogate, //Cs - Letter, //L# Ll | Lm | Lo | Lt | Lu - CasedLetter, //LC# Ll | Lt | Lu - LowercaseLetter, //Ll - ModifierLetter, //Lm - OtherLetter, //Lo - TitlecaseLetter, //Lt - UppercaseLetter, //Lu - Mark, //M - SpacingMark, //Mc - EnclosingMark, //Me - NonspacingMark, //Mn - Number, //N# Nd | Nl | No - DecimalNumber, //Nd - LetterNumber, //Nl - OtherNumber, //No - Punctuation, //P - ConnectorPunctuation, //Pc - DashPunctuation, //Pd - ClosePunctuation, //Pe - FinalPunctuation, //Pf - InitialPunctuation, //Pi - OtherPunctuation, //Po - OpenPunctuation, //Ps - Symbol, //S# Sc | Sk | Sm | So - CurrencySymbol, //Sc - ModifierSymbol, //Sk - MathSymbol, //Sm - OtherSymbol, //So - Separator, //Z# Zl | Zp | Zs - LineSeparator, //Zl - ParagraphSeparator, //Zp - SpaceSeparator, //Zs - } -} diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 4bec7b3f56..9f99ed3cef 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -30,6 +30,10 @@ namespace Avalonia.Skia private Matrix _currentTransform; private GRContext _grContext; private bool _disposed; + + private readonly SKPaint _strokePaint = new SKPaint(); + private readonly SKPaint _fillPaint = new SKPaint(); + /// /// Context create info. /// @@ -153,7 +157,7 @@ namespace Avalonia.Skia /// public void DrawLine(IPen pen, Point p1, Point p2) { - using (var paint = CreatePaint(pen, new Size(Math.Abs(p2.X - p1.X), Math.Abs(p2.Y - p1.Y)))) + using (var paint = CreatePaint(_strokePaint, pen, new Size(Math.Abs(p2.X - p1.X), Math.Abs(p2.Y - p1.Y)))) { Canvas.DrawLine((float) p1.X, (float) p1.Y, (float) p2.X, (float) p2.Y, paint.Paint); } @@ -165,8 +169,8 @@ namespace Avalonia.Skia var impl = (GeometryImpl) geometry; var size = geometry.Bounds.Size; - using (var fill = brush != null ? CreatePaint(brush, size) : default(PaintWrapper)) - using (var stroke = pen?.Brush != null ? CreatePaint(pen, size) : default(PaintWrapper)) + using (var fill = brush != null ? CreatePaint(_fillPaint, brush, size) : default(PaintWrapper)) + using (var stroke = pen?.Brush != null ? CreatePaint(_strokePaint, pen, size) : default(PaintWrapper)) { if (fill.Paint != null) { @@ -188,7 +192,7 @@ namespace Avalonia.Skia if (brush != null) { - using (var paint = CreatePaint(brush, rect.Size)) + using (var paint = CreatePaint(_fillPaint, brush, rect.Size)) { if (isRounded) { @@ -204,7 +208,7 @@ namespace Avalonia.Skia if (pen?.Brush != null) { - using (var paint = CreatePaint(pen, rect.Size)) + using (var paint = CreatePaint(_strokePaint, pen, rect.Size)) { if (isRounded) { @@ -222,7 +226,7 @@ namespace Avalonia.Skia /// public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text) { - using (var paint = CreatePaint(foreground, text.Bounds.Size)) + using (var paint = CreatePaint(_fillPaint, foreground, text.Bounds.Size)) { var textImpl = (FormattedTextImpl) text; textImpl.Draw(this, Canvas, origin.ToSKPoint(), paint, _canTextUseLcdRendering); @@ -232,14 +236,14 @@ namespace Avalonia.Skia /// public void DrawGlyphRun(IBrush foreground, GlyphRun glyphRun, Point baselineOrigin) { - using (var paint = CreatePaint(foreground, glyphRun.Bounds.Size)) + using (var paintWrapper = CreatePaint(_fillPaint, foreground, glyphRun.Bounds.Size)) { var glyphRunImpl = (GlyphRunImpl)glyphRun.GlyphRunImpl; - paint.ApplyTo(glyphRunImpl.Paint); + ConfigureTextRendering(paintWrapper); Canvas.DrawText(glyphRunImpl.TextBlob, (float)baselineOrigin.X, - (float)baselineOrigin.Y, glyphRunImpl.Paint); + (float)baselineOrigin.Y, paintWrapper.Paint); } } @@ -323,7 +327,7 @@ namespace Avalonia.Skia var paint = new SKPaint(); Canvas.SaveLayer(paint); - _maskStack.Push(CreatePaint(mask, bounds.Size)); + _maskStack.Push(CreatePaint(paint, mask, bounds.Size, true)); } /// @@ -364,6 +368,15 @@ namespace Avalonia.Skia } } + internal void ConfigureTextRendering(PaintWrapper wrapper) + { + var paint = wrapper.Paint; + + paint.IsEmbeddedBitmapText = true; + paint.SubpixelText = true; + paint.LcdRenderText = _canTextUseLcdRendering; + } + /// /// Configure paint wrapper for using gradient brush. /// @@ -514,17 +527,16 @@ namespace Avalonia.Skia /// /// Creates paint wrapper for given brush. /// + /// The paint to wrap. /// Source brush. /// Target size. + /// Optional dispose of the supplied paint. /// Paint wrapper for given brush. - internal PaintWrapper CreatePaint(IBrush brush, Size targetSize) + internal PaintWrapper CreatePaint(SKPaint paint, IBrush brush, Size targetSize, bool disposePaint = false) { - var paint = new SKPaint - { - IsAntialias = true - }; + var paintWrapper = new PaintWrapper(paint, disposePaint); - var paintWrapper = new PaintWrapper(paint); + paint.IsAntialias = true; double opacity = brush.Opacity * _currentOpacity; @@ -572,10 +584,12 @@ namespace Avalonia.Skia /// /// Creates paint wrapper for given pen. /// + /// The paint to wrap. /// Source pen. /// Target size. + /// Optional dispose of the supplied paint. /// - private PaintWrapper CreatePaint(IPen pen, Size targetSize) + private PaintWrapper CreatePaint(SKPaint paint, IPen pen, Size targetSize, bool disposePaint = false) { // In Skia 0 thickness means - use hairline rendering // and for us it means - there is nothing rendered. @@ -584,8 +598,7 @@ namespace Avalonia.Skia return default; } - var rv = CreatePaint(pen.Brush, targetSize); - var paint = rv.Paint; + var rv = CreatePaint(paint, pen.Brush, targetSize, disposePaint); paint.IsStroke = true; paint.StrokeWidth = (float) pen.Thickness; @@ -668,7 +681,7 @@ namespace Avalonia.Skia /// /// Skia cached paint state. /// - private struct PaintState : IDisposable + private readonly struct PaintState : IDisposable { private readonly SKColor _color; private readonly SKShader _shader; @@ -696,14 +709,16 @@ namespace Avalonia.Skia { //We are saving memory allocations there public readonly SKPaint Paint; + private readonly bool _disposePaint; private IDisposable _disposable1; private IDisposable _disposable2; private IDisposable _disposable3; - public PaintWrapper(SKPaint paint) + public PaintWrapper(SKPaint paint, bool disposePaint) { Paint = paint; + _disposePaint = disposePaint; _disposable1 = null; _disposable2 = null; @@ -751,7 +766,15 @@ namespace Avalonia.Skia /// public void Dispose() { - Paint?.Dispose(); + if (_disposePaint) + { + Paint?.Dispose(); + } + else + { + Paint?.Reset(); + } + _disposable1?.Dispose(); _disposable2?.Dispose(); _disposable3?.Dispose(); diff --git a/src/Skia/Avalonia.Skia/FontManagerImpl.cs b/src/Skia/Avalonia.Skia/FontManagerImpl.cs index 53aa6a147c..415a89e1c1 100644 --- a/src/Skia/Avalonia.Skia/FontManagerImpl.cs +++ b/src/Skia/Avalonia.Skia/FontManagerImpl.cs @@ -32,6 +32,27 @@ namespace Avalonia.Skia public bool TryMatchCharacter(int codepoint, FontWeight fontWeight, FontStyle fontStyle, FontFamily fontFamily, CultureInfo culture, out FontKey fontKey) { + SKFontStyle skFontStyle; + + switch (fontWeight) + { + case FontWeight.Normal when fontStyle == FontStyle.Normal: + skFontStyle = SKFontStyle.Normal; + break; + case FontWeight.Normal when fontStyle == FontStyle.Italic: + skFontStyle = SKFontStyle.Italic; + break; + case FontWeight.Bold when fontStyle == FontStyle.Normal: + skFontStyle = SKFontStyle.Bold; + break; + case FontWeight.Bold when fontStyle == FontStyle.Italic: + skFontStyle = SKFontStyle.BoldItalic; + break; + default: + skFontStyle = new SKFontStyle((SKFontStyleWeight)fontWeight, SKFontStyleWidth.Normal, (SKFontStyleSlant)fontStyle); + break; + } + if (culture == null) { culture = CultureInfo.CurrentUICulture; @@ -45,31 +66,32 @@ namespace Avalonia.Skia t_languageTagBuffer[0] = culture.TwoLetterISOLanguageName; t_languageTagBuffer[1] = culture.ThreeLetterISOLanguageName; - if (fontFamily != null) + if (fontFamily != null && fontFamily.FamilyNames.HasFallbacks) { - foreach (var familyName in fontFamily.FamilyNames) + var familyNames = fontFamily.FamilyNames; + + for (var i = 1; i < familyNames.Count; i++) { - var skTypeface = _skFontManager.MatchCharacter(familyName, (SKFontStyleWeight)fontWeight, - SKFontStyleWidth.Normal, (SKFontStyleSlant)fontStyle, t_languageTagBuffer, codepoint); + var skTypeface = + _skFontManager.MatchCharacter(familyNames[i], skFontStyle, t_languageTagBuffer, codepoint); if (skTypeface == null) { continue; } - fontKey = new FontKey(new FontFamily(familyName), fontWeight, fontStyle); + fontKey = new FontKey(skTypeface.FamilyName, fontWeight, fontStyle); return true; } } else { - var skTypeface = _skFontManager.MatchCharacter(null, (SKFontStyleWeight)fontWeight, - SKFontStyleWidth.Normal, (SKFontStyleSlant)fontStyle, t_languageTagBuffer, codepoint); + var skTypeface = _skFontManager.MatchCharacter(null, skFontStyle, t_languageTagBuffer, codepoint); if (skTypeface != null) { - fontKey = new FontKey(new FontFamily(skTypeface.FamilyName), fontWeight, fontStyle); + fontKey = new FontKey(skTypeface.FamilyName, fontWeight, fontStyle); return true; } @@ -82,7 +104,7 @@ namespace Avalonia.Skia public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface) { - var skTypeface = SKTypeface.Default; + SKTypeface skTypeface = null; if (typeface.FontFamily.Key == null) { @@ -109,6 +131,12 @@ namespace Avalonia.Skia skTypeface = fontCollection.Get(typeface); } + if (skTypeface == null) + { + throw new InvalidOperationException( + $"Could not create glyph typeface for: {typeface.FontFamily.Name}."); + } + return new GlyphTypefaceImpl(skTypeface); } } diff --git a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs index d0157815a9..6022e7a552 100644 --- a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs +++ b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs @@ -266,7 +266,8 @@ namespace Avalonia.Skia if (fb != null) { //TODO: figure out how to get the brush size - currentWrapper = context.CreatePaint(fb, new Size()); + currentWrapper = context.CreatePaint(new SKPaint { IsAntialias = true }, fb, + new Size()); } else { diff --git a/src/Skia/Avalonia.Skia/GlyphRunImpl.cs b/src/Skia/Avalonia.Skia/GlyphRunImpl.cs index 7c3f718f9e..0fdea5ed40 100644 --- a/src/Skia/Avalonia.Skia/GlyphRunImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphRunImpl.cs @@ -7,17 +7,11 @@ namespace Avalonia.Skia /// public class GlyphRunImpl : IGlyphRunImpl { - public GlyphRunImpl(SKPaint paint, SKTextBlob textBlob) + public GlyphRunImpl(SKTextBlob textBlob) { - Paint = paint; TextBlob = textBlob; } - /// - /// Gets the paint to draw with. - /// - public SKPaint Paint { get; } - /// /// Gets the text blob to draw. /// @@ -26,7 +20,6 @@ namespace Avalonia.Skia void IDisposable.Dispose() { TextBlob.Dispose(); - Paint.Dispose(); } } } diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index f1df6a804e..46872f903e 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -149,6 +149,16 @@ namespace Avalonia.Skia return new WriteableBitmapImpl(size, dpi, format); } + private static readonly SKPaint s_paint = new SKPaint + { + TextEncoding = SKTextEncoding.GlyphId, + IsAntialias = true, + IsStroke = false, + SubpixelText = true + }; + + private static readonly SKTextBlobBuilder s_textBlobBuilder = new SKTextBlobBuilder(); + /// public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width) { @@ -158,92 +168,84 @@ namespace Avalonia.Skia var typeface = glyphTypeface.Typeface; - var paint = new SKPaint - { - TextSize = (float)glyphRun.FontRenderingEmSize, - Typeface = typeface, - TextEncoding = SKTextEncoding.GlyphId, - IsAntialias = true, - IsStroke = false, - SubpixelText = true - }; - - using (var textBlobBuilder = new SKTextBlobBuilder()) - { - SKTextBlob textBlob; + s_paint.TextSize = (float)glyphRun.FontRenderingEmSize; + s_paint.Typeface = typeface; - width = 0; - var scale = (float)(glyphRun.FontRenderingEmSize / glyphTypeface.DesignEmHeight); + SKTextBlob textBlob; - if (glyphRun.GlyphOffsets.IsEmpty) - { - if (glyphTypeface.IsFixedPitch) - { - textBlobBuilder.AddRun(paint, 0, 0, glyphRun.GlyphIndices.Buffer.Span); + width = 0; - textBlob = textBlobBuilder.Build(); + var scale = (float)(glyphRun.FontRenderingEmSize / glyphTypeface.DesignEmHeight); - width = glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[0]) * scale * glyphRun.GlyphIndices.Length; - } - else - { - var buffer = textBlobBuilder.AllocateHorizontalRun(paint, count, 0); - - var positions = buffer.GetPositionSpan(); - - for (var i = 0; i < count; i++) - { - positions[i] = (float)width; - - if (glyphRun.GlyphAdvances.IsEmpty) - { - width += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale; - } - else - { - width += glyphRun.GlyphAdvances[i]; - } - } + if (glyphRun.GlyphOffsets.IsEmpty) + { + if (glyphTypeface.IsFixedPitch) + { + s_textBlobBuilder.AddRun(s_paint, 0, 0, glyphRun.GlyphIndices.Buffer.Span); - buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); + textBlob = s_textBlobBuilder.Build(); - textBlob = textBlobBuilder.Build(); - } + width = glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[0]) * scale * glyphRun.GlyphIndices.Length; } else { - var buffer = textBlobBuilder.AllocatePositionedRun(paint, count); + var buffer = s_textBlobBuilder.AllocateHorizontalRun(s_paint, count, 0); - var glyphPositions = buffer.GetPositionSpan(); - - var currentX = 0.0; + var positions = buffer.GetPositionSpan(); for (var i = 0; i < count; i++) { - var glyphOffset = glyphRun.GlyphOffsets[i]; - - glyphPositions[i] = new SKPoint((float)(currentX + glyphOffset.X), (float)glyphOffset.Y); + positions[i] = (float)width; if (glyphRun.GlyphAdvances.IsEmpty) { - currentX += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale; + width += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale; } else { - currentX += glyphRun.GlyphAdvances[i]; + width += glyphRun.GlyphAdvances[i]; } } buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); - width = currentX; + textBlob = s_textBlobBuilder.Build(); + } + } + else + { + var buffer = s_textBlobBuilder.AllocatePositionedRun(s_paint, count); + + var glyphPositions = buffer.GetPositionSpan(); + + var currentX = 0.0; + + for (var i = 0; i < count; i++) + { + var glyphOffset = glyphRun.GlyphOffsets[i]; + + glyphPositions[i] = new SKPoint((float)(currentX + glyphOffset.X), (float)glyphOffset.Y); - textBlob = textBlobBuilder.Build(); + if (glyphRun.GlyphAdvances.IsEmpty) + { + currentX += glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[i]) * scale; + } + else + { + currentX += glyphRun.GlyphAdvances[i]; + } } - return new GlyphRunImpl(paint, textBlob); + buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); + + width = currentX; + + textBlob = s_textBlobBuilder.Build(); } + + return new GlyphRunImpl(textBlob); + } } } diff --git a/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs b/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs index ce82835968..7aea90e61e 100644 --- a/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs +++ b/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs @@ -19,7 +19,7 @@ namespace Avalonia.Skia public SKTypeface Get(Typeface typeface) { - var key = new FontKey(typeface.FontFamily, typeface.Weight, typeface.Style); + var key = new FontKey(typeface.FontFamily.Name, typeface.Weight, typeface.Style); return GetNearestMatch(_typefaces, key); } @@ -49,7 +49,7 @@ namespace Avalonia.Skia if (keys.Length == 0) { - return SKTypeface.Default; + return null; } key = keys[0]; diff --git a/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs b/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs index 4f04d25dee..a9aed80a04 100644 --- a/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs +++ b/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs @@ -54,7 +54,7 @@ namespace Avalonia.Skia continue; } - var key = new FontKey(fontFamily, (FontWeight)typeface.FontWeight, (FontStyle)typeface.FontSlant); + var key = new FontKey(fontFamily.Name, (FontWeight)typeface.FontWeight, (FontStyle)typeface.FontSlant); typeFaceCollection.AddTypeface(key, typeface); } diff --git a/src/Windows/Avalonia.Direct2D1/Media/FontManagerImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/FontManagerImpl.cs index 8ba46dcbce..253a373106 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/FontManagerImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/FontManagerImpl.cs @@ -50,7 +50,7 @@ namespace Avalonia.Direct2D1.Media var fontFamilyName = font.FontFamily.FamilyNames.GetString(0); - fontKey = new FontKey(new FontFamily(fontFamilyName), fontWeight, fontStyle); + fontKey = new FontKey(fontFamilyName, fontWeight, fontStyle); return true; } diff --git a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs index aef2423f8a..8d64190ebd 100644 --- a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs +++ b/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs @@ -11,10 +11,11 @@ namespace Avalonia.Skia.UnitTests public class CustomFontManagerImpl : IFontManagerImpl { private readonly Typeface[] _customTypefaces; + private readonly string _defaultFamilyName; private readonly Typeface _defaultTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); - private readonly Typeface _italicTypeface = + private readonly Typeface _italicTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans"); private readonly Typeface _emojiTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Twitter Color Emoji"); @@ -22,11 +23,12 @@ namespace Avalonia.Skia.UnitTests public CustomFontManagerImpl() { _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface }; + _defaultFamilyName = _defaultTypeface.FontFamily.FamilyNames.PrimaryFamilyName; } public string GetDefaultFontFamilyName() { - return _defaultTypeface.FontFamily.ToString(); + return _defaultFamilyName; } public IEnumerable GetInstalledFontFamilyNames(bool checkForUpdates = false) @@ -34,39 +36,65 @@ namespace Avalonia.Skia.UnitTests return _customTypefaces.Select(x => x.FontFamily.Name); } + private readonly string[] _bcp47 = { CultureInfo.CurrentCulture.ThreeLetterISOLanguageName, CultureInfo.CurrentCulture.TwoLetterISOLanguageName }; + public bool TryMatchCharacter(int codepoint, FontWeight fontWeight, FontStyle fontStyle, FontFamily fontFamily, CultureInfo culture, out FontKey fontKey) { foreach (var customTypeface in _customTypefaces) { if (customTypeface.GlyphTypeface.GetGlyph((uint)codepoint) == 0) + { continue; - fontKey = new FontKey(customTypeface.FontFamily, fontWeight, fontStyle); + } + + fontKey = new FontKey(customTypeface.FontFamily.Name, fontWeight, fontStyle); return true; } - var fallback = SKFontManager.Default.MatchCharacter(codepoint); + var fallback = SKFontManager.Default.MatchCharacter(fontFamily?.Name, (SKFontStyleWeight)fontWeight, + SKFontStyleWidth.Normal, (SKFontStyleSlant)fontStyle, _bcp47, codepoint); - fontKey = new FontKey(fallback?.FamilyName ?? SKTypeface.Default.FamilyName, fontWeight, fontStyle); + fontKey = new FontKey(fallback?.FamilyName ?? _defaultFamilyName, fontWeight, fontStyle); return true; } public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface) { + SKTypeface skTypeface; + switch (typeface.FontFamily.Name) { case "Twitter Color Emoji": + { + var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(_emojiTypeface.FontFamily); + skTypeface = typefaceCollection.Get(typeface); + break; + } + case "Noto Sans": + { + var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(_italicTypeface.FontFamily); + skTypeface = typefaceCollection.Get(typeface); + break; + } case "Noto Mono": - var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(typeface.FontFamily); - var skTypeface = typefaceCollection.Get(typeface); - return new GlyphTypefaceImpl(skTypeface); + { + var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(_defaultTypeface.FontFamily); + skTypeface = typefaceCollection.Get(typeface); + break; + } default: - return new GlyphTypefaceImpl(SKTypeface.FromFamilyName(typeface.FontFamily.Name, - (SKFontStyleWeight)typeface.Weight, SKFontStyleWidth.Normal, (SKFontStyleSlant)typeface.Style)); + { + skTypeface = SKTypeface.FromFamilyName(typeface.FontFamily.Name, + (SKFontStyleWeight)typeface.Weight, SKFontStyleWidth.Normal, (SKFontStyleSlant)typeface.Style); + break; + } } + + return new GlyphTypefaceImpl(skTypeface); } } } diff --git a/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs b/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs index dc2a40aeba..8f80d89ac6 100644 --- a/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs +++ b/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs @@ -95,5 +95,18 @@ namespace Avalonia.Skia.UnitTests Assert.Equal("Noto Mono", skTypeface.FamilyName); } } + + [Fact] + public void Should_Throw_For_Invalid_Custom_Font() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var fontManager = new FontManagerImpl(); + + Assert.Throws(() => + fontManager.CreateGlyphTypeface( + new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Unknown"))); + } + } } } diff --git a/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs index 1f89a5833c..627b7c2ead 100644 --- a/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs @@ -332,7 +332,7 @@ namespace Avalonia.Skia.UnitTests Typeface.Default, 12.0f, Brushes.Black.ToImmutable(), - maxWidth : 200, + maxWidth : 200, maxHeight : 125, textStyleOverrides: spans); @@ -506,10 +506,30 @@ namespace Avalonia.Skia.UnitTests } } + private const string Text = "日本でTest一番読まれている英字新聞・ジャパンタイムズが発信する国内外ニュースと、様々なジャンルの特集記事。"; + + [Fact] + public void Should_Wrap() + { + using (Start()) + { + for (var i = 0; i < 2000; i++) + { + var layout = new TextLayout( + Text, + Typeface.Default, + 12, + Brushes.Black, + textWrapping: TextWrapping.Wrap, + maxWidth: 50); + } + } + } + public static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface - .With(renderInterface: new PlatformRenderInterface(null), + .With(renderInterface: new PlatformRenderInterface(null), textShaperImpl: new TextShaperImpl(), fontManagerImpl : new CustomFontManagerImpl())); diff --git a/tests/Avalonia.UnitTests/MockFontManagerImpl.cs b/tests/Avalonia.UnitTests/MockFontManagerImpl.cs index affdc48f5e..55656fcfc0 100644 --- a/tests/Avalonia.UnitTests/MockFontManagerImpl.cs +++ b/tests/Avalonia.UnitTests/MockFontManagerImpl.cs @@ -8,14 +8,21 @@ namespace Avalonia.UnitTests { public class MockFontManagerImpl : IFontManagerImpl { + private readonly string _defaultFamilyName; + + public MockFontManagerImpl(string defaultFamilyName = "Default") + { + _defaultFamilyName = defaultFamilyName; + } + public string GetDefaultFontFamilyName() { - return "Default"; + return _defaultFamilyName; } public IEnumerable GetInstalledFontFamilyNames(bool checkForUpdates = false) { - return new[] { "Default" }; + return new[] { _defaultFamilyName }; } public bool TryMatchCharacter(int codepoint, FontWeight fontWeight, FontStyle fontStyle, FontFamily fontFamily, diff --git a/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs index d35108080b..81a4ca6495 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs @@ -1,4 +1,5 @@ -using Avalonia.Media; +using System; +using Avalonia.Media; using Avalonia.Platform; using Avalonia.UnitTests; using Xunit; @@ -19,5 +20,14 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Same(typeface, FontManager.Current.GetOrAddTypeface(fontFamily)); } } + + [Fact] + public void Should_Throw_When_Default_FamilyName_Is_Null() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new MockFontManagerImpl(null)))) + { + Assert.Throws(() => FontManager.Current); + } + } } } From 7e6f0b82659f529925a8a5df697a57babee048d4 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 10:57:16 -0400 Subject: [PATCH 02/11] Add KeySpline class from WPF source --- src/Avalonia.Animation/KeySpline.cs | 271 ++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/Avalonia.Animation/KeySpline.cs diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs new file mode 100644 index 0000000000..34e8d89aae --- /dev/null +++ b/src/Avalonia.Animation/KeySpline.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Avalonia; +using Avalonia.Utilities; + +// From: https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs + +namespace Avalonia.Animation +{ + [TypeConverter(typeof(KeySplineTypeConverter))] + public class KeySpline : AvaloniaObject + { + // Control points + private double _controlPointX1; + private double _controlPointY1; + private double _controlPointX2; + private double _controlPointY2; + private bool _isSpecified; + private bool _isDirty; + + // The parameter that corresponds to the most recent time + private double _parameter; + + // Cached coefficients + private double _Bx; // 3*points[0].X + private double _Cx; // 3*points[1].X + private double _Cx_Bx; // 2*(Cx - Bx) + private double _three_Cx; // 3 - Cx + + private double _By; // 3*points[0].Y + private double _Cy; // 3*points[1].Y + + // constants + private const double _accuracy = .001; // 1/3 the desired accuracy in X + private const double _fuzz = .000001; // computational zero + + public KeySpline() + { + _controlPointX1 = 0.0; + _controlPointY1 = 0.0; + _controlPointX2 = 1.0; + _controlPointY2 = 1.0; + _isDirty = true; + } + + public KeySpline(double x1, double y1, double x2, double y2) + { + _controlPointX1 = x1; + _controlPointY1 = y1; + _controlPointX2 = x2; + _controlPointY2 = y2; + _isDirty = true; + } + + public static KeySpline Parse(string value, CultureInfo culture) + { + using (var tokenizer = new StringTokenizer((string)value, CultureInfo.InvariantCulture, exceptionMessage: "Invalid KeySpline.")) + { + return new KeySpline(tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble()); + } + } + + public double ControlPointX1 + { + get => _controlPointX1; + set => _controlPointX1 = value; + } + + public double ControlPointY1 + { + get => _controlPointY1; + set => _controlPointY1 = value; + } + + public double ControlPointX2 + { + get => _controlPointX2; + set => _controlPointX2 = value; + } + + public double ControlPointY2 + { + get => _controlPointY2; + set => _controlPointY2 = value; + } + + /// + /// Calculates spline progress from a linear progress. + /// + /// the linear progress + /// the spline progress + public double GetSplineProgress(double linearProgress) + { + //ReadPreamble(); + + if (_isDirty) + { + Build(); + } + + if (!_isSpecified) + { + return linearProgress; + } + else + { + SetParameterFromX(linearProgress); + + return GetBezierValue(_By, _Cy, _parameter); + } + } + + /// + /// Compute cached coefficients. + /// + private void Build() + { + if (_controlPointX1 == 0 && _controlPointY1 == 0 && _controlPointX2 == 1 && _controlPointY2 == 1) + { + // This KeySpline would have no effect on the progress. + _isSpecified = false; + } + else + { + _isSpecified = true; + + _parameter = 0; + + // X coefficients + _Bx = 3 * _controlPointX1; + _Cx = 3 * _controlPointX2; + _Cx_Bx = 2 * (_Cx - _Bx); + _three_Cx = 3 - _Cx; + + // Y coefficients + _By = 3 * _controlPointY1; + _Cy = 3 * _controlPointY2; + } + + _isDirty = false; + } + + /// + /// Get an X or Y value with the Bezier formula. + /// + /// the second Bezier coefficient + /// the third Bezier coefficient + /// the parameter value to evaluate at + /// the value of the Bezier function at the given parameter + static private double GetBezierValue(double b, double c, double t) + { + double s = 1.0 - t; + double t2 = t * t; + + return b * t * s * s + c * t2 * s + t2 * t; + } + + /// + /// Get X and dX/dt at a given parameter + /// + /// the parameter value to evaluate at + /// the value of x there + /// the value of dx/dt there + private void GetXAndDx(double t, out double x, out double dx) + { + double s = 1.0 - t; + double t2 = t * t; + double s2 = s * s; + + x = _Bx * t * s2 + _Cx * t2 * s + t2 * t; + dx = _Bx * s2 + _Cx_Bx * s * t + _three_Cx * t2; + } + + /// + /// Compute the parameter value that corresponds to a given X value, using a modified + /// clamped Newton-Raphson algorithm to solve the equation X(t) - time = 0. We make + /// use of some known properties of this particular function: + /// * We are only interested in solutions in the interval [0,1] + /// * X(t) is increasing, so we can assume that if X(t) > time t > solution. We use + /// that to clamp down the search interval with every probe. + /// * The derivative of X and Y are between 0 and 3. + /// + /// the time, scaled to fit in [0,1] + private void SetParameterFromX(double time) + { + // Dynamic search interval to clamp with + double bottom = 0; + double top = 1; + + if (time == 0) + { + _parameter = 0; + } + else if (time == 1) + { + _parameter = 1; + } + else + { + // Loop while improving the guess + while (top - bottom > _fuzz) + { + double x, dx, absdx; + + // Get x and dx/dt at the current parameter + GetXAndDx(_parameter, out x, out dx); + absdx = Math.Abs(dx); + + // Clamp down the search interval, relying on the monotonicity of X(t) + if (x > time) + { + top = _parameter; // because parameter > solution + } + else + { + bottom = _parameter; // because parameter < solution + } + + // The desired accuracy is in ultimately in y, not in x, so the + // accuracy needs to be multiplied by dx/dy = (dx/dt) / (dy/dt). + // But dy/dt <=3, so we omit that + if (Math.Abs(x - time) < _accuracy * absdx) + { + break; // We're there + } + + if (absdx > _fuzz) + { + // Nonzero derivative, use Newton-Raphson to obtain the next guess + double next = _parameter - (x - time) / dx; + + // If next guess is out of the search interval then clamp it in + if (next >= top) + { + _parameter = (_parameter + top) / 2; + } + else if (next <= bottom) + { + _parameter = (_parameter + bottom) / 2; + } + else + { + // Next guess is inside the search interval, accept it + _parameter = next; + } + } + else // Zero derivative, halve the search interval + { + _parameter = (bottom + top) / 2; + } + } + } + } + } + + public class KeySplineTypeConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return KeySpline.Parse((string)value, culture); + } + } +} From 24870351fa79ed925833b4afe11953fa0141ba29 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 11:32:41 -0400 Subject: [PATCH 03/11] Use KeySpline in Animation --- src/Avalonia.Animation/Animation.cs | 2 +- src/Avalonia.Animation/AnimatorKeyFrame.cs | 9 +++++ .../Animators/Animator`1.cs | 3 ++ src/Avalonia.Animation/KeyFrame.cs | 20 +++++++++++ src/Avalonia.Animation/KeySpline.cs | 36 ++++++++++++++++--- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Animation/Animation.cs b/src/Avalonia.Animation/Animation.cs index 1ce9f5db06..ca1d97290e 100644 --- a/src/Avalonia.Animation/Animation.cs +++ b/src/Avalonia.Animation/Animation.cs @@ -254,7 +254,7 @@ namespace Avalonia.Animation cue = new Cue(keyframe.KeyTime.TotalSeconds / Duration.TotalSeconds); } - var newKF = new AnimatorKeyFrame(handler, cue); + var newKF = new AnimatorKeyFrame(handler, cue, keyframe.KeySpline); subscriptions.Add(newKF.BindSetter(setter, control)); diff --git a/src/Avalonia.Animation/AnimatorKeyFrame.cs b/src/Avalonia.Animation/AnimatorKeyFrame.cs index 36d15e518e..f6a0c12be4 100644 --- a/src/Avalonia.Animation/AnimatorKeyFrame.cs +++ b/src/Avalonia.Animation/AnimatorKeyFrame.cs @@ -24,11 +24,20 @@ namespace Avalonia.Animation { AnimatorType = animatorType; Cue = cue; + KeySpline = null; + } + + public AnimatorKeyFrame(Type animatorType, Cue cue, KeySpline keySpline) + { + AnimatorType = animatorType; + Cue = cue; + KeySpline = keySpline; } internal bool isNeutral; public Type AnimatorType { get; } public Cue Cue { get; } + public KeySpline KeySpline { get; } public AvaloniaProperty Property { get; private set; } private object _value; diff --git a/src/Avalonia.Animation/Animators/Animator`1.cs b/src/Avalonia.Animation/Animators/Animator`1.cs index aa5e6aaf14..121ffda564 100644 --- a/src/Avalonia.Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Animation/Animators/Animator`1.cs @@ -89,6 +89,9 @@ namespace Avalonia.Animation.Animators else newValue = (T)lastKeyframe.Value; + if (lastKeyframe.KeySpline != null) // TODO: do we use firstKeyFrame or lastKeyframe?! + progress = lastKeyframe.KeySpline.GetSplineProgress(progress); + return Interpolate(progress, oldValue, newValue); } diff --git a/src/Avalonia.Animation/KeyFrame.cs b/src/Avalonia.Animation/KeyFrame.cs index ec59586584..c2cc1aa051 100644 --- a/src/Avalonia.Animation/KeyFrame.cs +++ b/src/Avalonia.Animation/KeyFrame.cs @@ -19,6 +19,7 @@ namespace Avalonia.Animation { private TimeSpan _ktimeSpan; private Cue _kCue; + private KeySpline _kKeySpline; public KeyFrame() { @@ -74,6 +75,25 @@ namespace Avalonia.Animation } } + /// + /// Gets or sets the KeySpline of this . + /// + /// The key spline. + public KeySpline KeySpline + { + get + { + return _kKeySpline; + } + set + { + _kKeySpline = value; + if (value != null && !value.IsValid()) + { + throw new ArgumentException($"{nameof(KeySpline)} must have X coordinates >= 0.0 and <= 1.0."); + } + } + } } diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index 34e8d89aae..dc6fb61744 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -66,7 +66,17 @@ namespace Avalonia.Animation public double ControlPointX1 { get => _controlPointX1; - set => _controlPointX1 = value; + set + { + if (IsValidXValue(value)) + { + _controlPointX1 = value; + } + else + { + throw new ArgumentException("Invalid KeySpline X1 value. Must be >= 0.0 and <= 1.0."); + } + } } public double ControlPointY1 @@ -78,7 +88,17 @@ namespace Avalonia.Animation public double ControlPointX2 { get => _controlPointX2; - set => _controlPointX2 = value; + set + { + if (IsValidXValue(value)) + { + _controlPointX2 = value; + } + else + { + throw new ArgumentException("Invalid KeySpline X2 value. Must be >= 0.0 and <= 1.0."); + } + } } public double ControlPointY2 @@ -94,8 +114,6 @@ namespace Avalonia.Animation /// the spline progress public double GetSplineProgress(double linearProgress) { - //ReadPreamble(); - if (_isDirty) { Build(); @@ -113,6 +131,16 @@ namespace Avalonia.Animation } } + public bool IsValid() + { + return IsValidXValue(_controlPointX1) && IsValidXValue(_controlPointX2); + } + + private bool IsValidXValue(double value) + { + return value >= 0.0 && value <= 1.0; + } + /// /// Compute cached coefficients. /// From 30eacab09d5023774231e219fd3edc5bfa42fb05 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 12:10:43 -0400 Subject: [PATCH 04/11] Add some unit tests for KeySpline --- .../KeySplineTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Avalonia.Animation.UnitTests/KeySplineTests.cs diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs new file mode 100644 index 0000000000..a523be2a78 --- /dev/null +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -0,0 +1,48 @@ +using System; +using Avalonia.Controls; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Animation.UnitTests +{ + public class KeySplineTests + { + [Theory] + [InlineData("1,2 3,4")] + [InlineData("1 2 3 4")] + [InlineData("1 2,3 4")] + [InlineData("1,2,3,4")] + public void Can_Parse_KeySpline_Via_TypeConverter(string input) + { + var conv = new KeySplineTypeConverter(); + + var keySpline = (KeySpline)conv.ConvertFrom(input); + + Assert.Equal(1, keySpline.ControlPointX1); + Assert.Equal(2, keySpline.ControlPointY1); + Assert.Equal(3, keySpline.ControlPointX2); + Assert.Equal(4, keySpline.ControlPointY2); + } + + [Theory] + [InlineData(0.00)] + [InlineData(0.50)] + [InlineData(1.00)] + public void KeySpline_X_Values_In_Range_Do_Not_Throw(double input) + { + var keySpline = new KeySpline(); + keySpline.ControlPointX1 = input; // no exception will be thrown -- test will fail if exception thrown + keySpline.ControlPointX2 = input; // no exception will be thrown -- test will fail if exception thrown + } + + [Theory] + [InlineData(-0.01)] + [InlineData(1.01)] + public void KeySpline_X_Values_Cannot_Be_Out_Of_Range(double input) + { + var keySpline = new KeySpline(); + Assert.Throws(() => keySpline.ControlPointX1 = input); + Assert.Throws(() => keySpline.ControlPointX2 = input); + } + } +} From 47c0282ae9368d414310aa2ef167fb1e1465ab4a Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 12:21:27 -0400 Subject: [PATCH 05/11] Update documentation --- src/Avalonia.Animation/KeySpline.cs | 49 ++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index dc6fb61744..fc7dc8966e 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -6,10 +6,16 @@ using System.Text; using Avalonia; using Avalonia.Utilities; -// From: https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs +// Ported from WPF open-source code. +// https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs namespace Avalonia.Animation { + /// + /// Determines how an animation is used based on a cubic bezier curve. + /// X1 and X2 must be between 0.0 and 1.0, inclusive. + /// See https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.animation.keyspline + /// [TypeConverter(typeof(KeySplineTypeConverter))] public class KeySpline : AvaloniaObject { @@ -37,6 +43,9 @@ namespace Avalonia.Animation private const double _accuracy = .001; // 1/3 the desired accuracy in X private const double _fuzz = .000001; // computational zero + /// + /// Create a with X1 = Y1 = 0 and X2 = Y2 = 1. + /// public KeySpline() { _controlPointX1 = 0.0; @@ -46,6 +55,13 @@ namespace Avalonia.Animation _isDirty = true; } + /// + /// Create a with the given parameters + /// + /// X coordinate for the first control point + /// Y coordinate for the first control point + /// X coordinate for the second control point + /// Y coordinate for the second control point public KeySpline(double x1, double y1, double x2, double y2) { _controlPointX1 = x1; @@ -55,6 +71,14 @@ namespace Avalonia.Animation _isDirty = true; } + /// + /// Parse a from a string. The string + /// needs to contain 4 values in it for the 2 control points. + /// + /// string with 4 values in it + /// culture of the string + /// Thrown if the string does not have 4 values + /// A with the appropriate values set public static KeySpline Parse(string value, CultureInfo culture) { using (var tokenizer = new StringTokenizer((string)value, CultureInfo.InvariantCulture, exceptionMessage: "Invalid KeySpline.")) @@ -63,6 +87,9 @@ namespace Avalonia.Animation } } + /// + /// X coordinate of the first control point + /// public double ControlPointX1 { get => _controlPointX1; @@ -79,12 +106,18 @@ namespace Avalonia.Animation } } + /// + /// Y coordinate of the first control point + /// public double ControlPointY1 { get => _controlPointY1; set => _controlPointY1 = value; } + /// + /// X coordinate of the second control point + /// public double ControlPointX2 { get => _controlPointX2; @@ -101,6 +134,9 @@ namespace Avalonia.Animation } } + /// + /// Y coordinate of the second control point + /// public double ControlPointY2 { get => _controlPointY2; @@ -131,11 +167,22 @@ namespace Avalonia.Animation } } + /// + /// Check to see whether the is valid by looking + /// at its X values. + /// + /// true if the X values for this fall in + /// acceptable range; false otherwise. public bool IsValid() { return IsValidXValue(_controlPointX1) && IsValidXValue(_controlPointX2); } + /// + /// + /// + /// + /// private bool IsValidXValue(double value) { return value >= 0.0 && value <= 1.0; From 4cf4bb953bd7a11dfb80718e61730fec627b97a8 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 12:39:25 -0400 Subject: [PATCH 06/11] Update docs again (missed KeySplineTypeConverter) --- src/Avalonia.Animation/KeySpline.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index fc7dc8966e..5a4f7a15a3 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -331,6 +331,9 @@ namespace Avalonia.Animation } } + /// + /// Converts string values to values + /// public class KeySplineTypeConverter : TypeConverter { public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) From 996821b60b8b60159d911b9ee6499db262bfc4fb Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Thu, 30 Apr 2020 09:06:50 -0400 Subject: [PATCH 07/11] Removed TODO now that behavior is verified with WPF --- src/Avalonia.Animation/Animators/Animator`1.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Animation/Animators/Animator`1.cs b/src/Avalonia.Animation/Animators/Animator`1.cs index 121ffda564..0660440e30 100644 --- a/src/Avalonia.Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Animation/Animators/Animator`1.cs @@ -89,7 +89,7 @@ namespace Avalonia.Animation.Animators else newValue = (T)lastKeyframe.Value; - if (lastKeyframe.KeySpline != null) // TODO: do we use firstKeyFrame or lastKeyframe?! + if (lastKeyframe.KeySpline != null) progress = lastKeyframe.KeySpline.GetSplineProgress(progress); return Interpolate(progress, oldValue, newValue); From 62b8da3cdf2dd4741078b7b82af972b95185e0ca Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Thu, 30 Apr 2020 15:46:07 -0400 Subject: [PATCH 08/11] Added a test for KeySpline animation --- .../KeySplineTests.cs | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs index a523be2a78..b98564a03c 100644 --- a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -1,6 +1,7 @@ using System; -using Avalonia.Controls; -using Avalonia.UnitTests; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Styling; using Xunit; namespace Avalonia.Animation.UnitTests @@ -44,5 +45,76 @@ namespace Avalonia.Animation.UnitTests Assert.Throws(() => keySpline.ControlPointX1 = input); Assert.Throws(() => keySpline.ControlPointX2 = input); } + + [Fact] + public void Check_KeySpline_Handled_properly() + { + var keyframe1 = new KeyFrame() + { + Setters = + { + new Setter(RotateTransform.AngleProperty, -2.5d), + }, + KeyTime = TimeSpan.FromSeconds(0) + }; + + var keyframe2 = new KeyFrame() + { + Setters = + { + new Setter(RotateTransform.AngleProperty, 2.5d), + }, + KeyTime = TimeSpan.FromSeconds(5), + KeySpline = new KeySpline(0.1123555056179775, + 0.657303370786517, + 0.8370786516853934, + 0.499999999999999999) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(5), + Children = + { + keyframe1, + keyframe2 + }, + IterationCount = new IterationCount(5), + PlaybackDirection = PlaybackDirection.Alternate + }; + + var rotateTransform = new RotateTransform(-2.5); + var rect = new Rectangle() + { + RenderTransform = rotateTransform + }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(rect, clock); + + // position is what you'd expect at end and beginning + clock.Step(TimeSpan.Zero); + Assert.Equal(rotateTransform.Angle, -2.5); + clock.Step(TimeSpan.FromSeconds(5)); + Assert.Equal(rotateTransform.Angle, 2.5); + + // test some points in between end and beginning + var tolerance = 0.01; + clock.Step(TimeSpan.Parse("00:00:10.0153932")); + var expected = -2.4122350198982545; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:11.2655407")); + expected = -0.37153223002125113; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:12.6158773")); + expected = 0.3967885416786294; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:14.6495256")); + expected = 1.8016358493761722; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + } } } From 7ba1c9661bd5d3db6b91b898d7e222e447fde841 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Thu, 30 Apr 2020 15:58:21 -0400 Subject: [PATCH 09/11] Add some notes on how I got the KeySpline test values --- .../KeySplineTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs index b98564a03c..df7c0693e1 100644 --- a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -46,6 +46,31 @@ namespace Avalonia.Animation.UnitTests Assert.Throws(() => keySpline.ControlPointX2 = input); } + /* + To get the test values for the KeySpline test, you can: + 1) Grab the WPF sample for KeySpline animations from https://github.com/microsoft/WPF-Samples/tree/master/Animation/KeySplineAnimations + 2) Add the following xaml somewhere: +