diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 687355d825..9e28f532be 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,6 @@ contact_links: - name: Questions, Discussions, Ideas url: https://github.com/AvaloniaUI/Avalonia/discussions/new about: Please ask and answer questions here. - - name: Avalonia Community Support on Gitter - url: https://gitter.im/AvaloniaUI/Avalonia + - name: Avalonia Community Support on Telegram + url: https://t.me/Avalonia about: Please ask and answer questions here. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcf95ce33c..cb5a7d7897 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Before You Start -Drop into our [gitter chat room](https://gitter.im/AvaloniaUI/Avalonia) and let us know what you're thinking of doing. We might be able to give you guidance or let you know if someone else is already working on the feature. +Drop into our [telegram group](https://t.me/Avalonia) or [gitter chat room](https://gitter.im/AvaloniaUI/Avalonia) and let us know what you're thinking of doing. We might be able to give you guidance or let you know if someone else is already working on the feature. ## Style diff --git a/readme.md b/readme.md index a1cdb6fe9d..a8a6399f2f 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,4 @@ +[![Telegram](https://raw.githubusercontent.com/Patrolavia/telegram-badge/master/chat.svg)](https://t.me/Avalonia) [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/AvaloniaUI/Avalonia?utm_campaign=pr-badge&utm_content=badge&utm_medium=badge&utm_source=badge) [![Discord](https://img.shields.io/badge/discord-join%20chat-46BC99)]( https://aka.ms/dotnet-discord) [![Build Status](https://dev.azure.com/AvaloniaUI/AvaloniaUI/_apis/build/status/AvaloniaUI.Avalonia)](https://dev.azure.com/AvaloniaUI/AvaloniaUI/_build/latest?definitionId=4) [![Backers on Open Collective](https://opencollective.com/Avalonia/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/Avalonia/sponsors/badge.svg)](#sponsors) ![License](https://img.shields.io/github/license/avaloniaui/avalonia.svg)
[![NuGet](https://img.shields.io/nuget/v/Avalonia.svg)](https://www.nuget.org/packages/Avalonia) [![downloads](https://img.shields.io/nuget/dt/avalonia)](https://www.nuget.org/packages/Avalonia) [![MyGet](https://img.shields.io/myget/avalonia-ci/vpre/Avalonia.svg?label=myget)](https://www.myget.org/gallery/avalonia-ci) ![Size](https://img.shields.io/github/repo-size/avaloniaui/avalonia.svg) diff --git a/src/Avalonia.Base/Collections/AvaloniaList.cs b/src/Avalonia.Base/Collections/AvaloniaList.cs index 2f1cb2888e..5782159813 100644 --- a/src/Avalonia.Base/Collections/AvaloniaList.cs +++ b/src/Avalonia.Base/Collections/AvaloniaList.cs @@ -503,11 +503,24 @@ namespace Avalonia.Collections { Contract.Requires(items != null); - foreach (var i in items) + var hItems = new HashSet(items); + + int counter = 0; + for (int i = _inner.Count - 1; i >= 0; --i) { - // TODO: Optimize to only send as many notifications as necessary. - Remove(i); + if (hItems.Contains(_inner[i])) + { + counter += 1; + } + else if(counter > 0) + { + RemoveRange(i + 1, counter); + counter = 0; + } } + + if (counter > 0) + RemoveRange(0, counter); } /// diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 0c6949465b..ee67f303f3 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -1,3 +1,5 @@ +using Avalonia.Collections; +using Avalonia.Controls.Shapes; using Avalonia.Controls.Utils; using Avalonia.Layout; using Avalonia.Media; @@ -41,7 +43,31 @@ namespace Avalonia.Controls /// public static readonly StyledProperty BoxShadowProperty = AvaloniaProperty.Register(nameof(BoxShadow)); - + + /// + /// Defines the property. + /// + public static readonly StyledProperty BorderDashOffsetProperty = + AvaloniaProperty.Register(nameof(BorderDashOffset)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty?> BorderDashArrayProperty = + AvaloniaProperty.Register?>(nameof(BorderDashArray)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty BorderLineCapProperty = + AvaloniaProperty.Register(nameof(BorderLineCap), PenLineCap.Flat); + + /// + /// Defines the property. + /// + public static readonly StyledProperty BorderLineJoinProperty = + AvaloniaProperty.Register(nameof(BorderLineJoin), PenLineJoin.Miter); + private readonly BorderRenderHelper _borderRenderHelper = new BorderRenderHelper(); /// @@ -54,6 +80,10 @@ namespace Avalonia.Controls BorderBrushProperty, BorderThicknessProperty, CornerRadiusProperty, + BorderDashArrayProperty, + BorderLineCapProperty, + BorderLineJoinProperty, + BorderDashOffsetProperty, BoxShadowProperty); AffectsMeasure(BorderThicknessProperty); } @@ -76,6 +106,15 @@ namespace Avalonia.Controls set { SetValue(BorderBrushProperty, value); } } + /// + /// Gets or sets a collection of values that indicate the pattern of dashes and gaps that is used to outline shapes. + /// + public AvaloniaList? BorderDashArray + { + get { return GetValue(BorderDashArrayProperty); } + set { SetValue(BorderDashArrayProperty, value); } + } + /// /// Gets or sets the thickness of the border. /// @@ -85,6 +124,33 @@ namespace Avalonia.Controls set { SetValue(BorderThicknessProperty, value); } } + /// + /// Gets or sets a value that specifies the distance within the dash pattern where a dash begins. + /// + public double BorderDashOffset + { + get { return GetValue(BorderDashOffsetProperty); } + set { SetValue(BorderDashOffsetProperty, value); } + } + + /// + /// Gets or sets a enumeration value that describes the shape at the ends of a line. + /// + public PenLineCap BorderLineCap + { + get { return GetValue(BorderLineCapProperty); } + set { SetValue(BorderLineCapProperty, value); } + } + + /// + /// Gets or sets a enumeration value that specifies the type of join that is used at the vertices of a Shape. + /// + public PenLineJoin BorderLineJoin + { + get { return GetValue(BorderLineJoinProperty); } + set { SetValue(BorderLineJoinProperty, value); } + } + /// /// Gets or sets the radius of the border rounded corners. /// @@ -93,7 +159,7 @@ namespace Avalonia.Controls get { return GetValue(CornerRadiusProperty); } set { SetValue(CornerRadiusProperty, value); } } - + /// /// Gets or sets the box shadow effect parameters /// @@ -102,7 +168,7 @@ namespace Avalonia.Controls get => GetValue(BoxShadowProperty); set => SetValue(BoxShadowProperty, value); } - + /// /// Renders the control. /// @@ -110,7 +176,7 @@ namespace Avalonia.Controls public override void Render(DrawingContext context) { _borderRenderHelper.Render(context, Bounds.Size, BorderThickness, CornerRadius, Background, BorderBrush, - BoxShadow); + BoxShadow, BorderDashOffset, BorderLineCap, BorderLineJoin, BorderDashArray); } /// diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 0a29db555c..00fc6002d1 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -642,15 +642,31 @@ namespace Avalonia.Controls if (!string.IsNullOrEmpty(input)) { - DeleteSelection(); - caretIndex = CaretIndex; - text = Text ?? string.Empty; - SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex)); - CaretIndex += input.Length; - ClearSelection(); - if (IsUndoEnabled) + var oldText = _text; + + _ignoreTextChanges = true; + + try { - _undoRedoHelper.DiscardRedo(); + DeleteSelection(false); + caretIndex = CaretIndex; + text = Text ?? string.Empty; + SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex)); + CaretIndex += input.Length; + ClearSelection(); + if (IsUndoEnabled) + { + _undoRedoHelper.DiscardRedo(); + } + + if (_text != oldText) + { + RaisePropertyChanged(TextProperty, oldText, _text); + } + } + finally + { + _ignoreTextChanges = false; } } } @@ -1285,7 +1301,7 @@ namespace Avalonia.Controls CaretIndex = SelectionEnd; } - private bool DeleteSelection() + private bool DeleteSelection(bool raiseTextChanged = true) { if (!IsReadOnly) { @@ -1297,7 +1313,7 @@ namespace Avalonia.Controls var start = Math.Min(selectionStart, selectionEnd); var end = Math.Max(selectionStart, selectionEnd); var text = Text; - SetTextInternal(text.Substring(0, start) + text.Substring(end)); + SetTextInternal(text.Substring(0, start) + text.Substring(end), raiseTextChanged); CaretIndex = start; ClearSelection(); return true; @@ -1348,16 +1364,23 @@ namespace Avalonia.Controls return i; } - private void SetTextInternal(string value) + private void SetTextInternal(string value, bool raiseTextChanged = true) { - try + if (raiseTextChanged) { - _ignoreTextChanges = true; - SetAndRaise(TextProperty, ref _text, value); + try + { + _ignoreTextChanges = true; + SetAndRaise(TextProperty, ref _text, value); + } + finally + { + _ignoreTextChanges = false; + } } - finally + else { - _ignoreTextChanges = false; + _text = value; } } diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 78cd22ae32..03f6fd3aaa 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -392,14 +392,22 @@ namespace Avalonia.Controls /// protected override IItemContainerGenerator CreateItemContainerGenerator() { - var result = new TreeItemContainerGenerator( + var result = CreateTreeItemContainerGenerator(); + result.Index.Materialized += ContainerMaterialized; + return result; + } + + protected virtual ITreeItemContainerGenerator CreateTreeItemContainerGenerator() => + CreateTreeItemContainerGenerator(); + + protected virtual ITreeItemContainerGenerator CreateTreeItemContainerGenerator() where TVItem: TreeViewItem, new() + { + return new TreeItemContainerGenerator( this, TreeViewItem.HeaderProperty, TreeViewItem.ItemTemplateProperty, TreeViewItem.ItemsProperty, TreeViewItem.IsExpandedProperty); - result.Index.Materialized += ContainerMaterialized; - return result; } /// diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 8ce258b546..88fb3cd9f1 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -92,9 +92,13 @@ namespace Avalonia.Controls (ITreeItemContainerGenerator)base.ItemContainerGenerator; /// - protected override IItemContainerGenerator CreateItemContainerGenerator() + protected override IItemContainerGenerator CreateItemContainerGenerator() => CreateTreeItemContainerGenerator(); + + /// + protected virtual ITreeItemContainerGenerator CreateTreeItemContainerGenerator() + where TVItem: TreeViewItem, new() { - return new TreeItemContainerGenerator( + return new TreeItemContainerGenerator( this, TreeViewItem.HeaderProperty, TreeViewItem.ItemTemplateProperty, diff --git a/src/Avalonia.Controls/Utils/BorderRenderHelper.cs b/src/Avalonia.Controls/Utils/BorderRenderHelper.cs index f8ab58d46e..bae787f0ed 100644 --- a/src/Avalonia.Controls/Utils/BorderRenderHelper.cs +++ b/src/Avalonia.Controls/Utils/BorderRenderHelper.cs @@ -1,8 +1,10 @@ using System; +using Avalonia.Collections; using Avalonia.Media; using Avalonia.Media.Immutable; using Avalonia.Platform; using Avalonia.Utilities; +using JetBrains.Annotations; namespace Avalonia.Controls.Utils { @@ -15,8 +17,13 @@ namespace Avalonia.Controls.Utils private Size _size; private Thickness _borderThickness; private CornerRadius _cornerRadius; + private AvaloniaList _borderDashArray; + private double _borderDashOffset; + private PenLineCap _borderLineCap; + private PenLineJoin _borderJoin; private bool _initialized; + void Update(Size finalSize, Thickness borderThickness, CornerRadius cornerRadius) { _backendSupportsIndividualCorners ??= AvaloniaLocator.Current.GetService() @@ -60,7 +67,8 @@ namespace Avalonia.Controls.Utils if (boundRect.Width != 0 && innerRect.Height != 0) { - var borderGeometryKeypoints = new BorderGeometryKeypoints(boundRect, borderThickness, cornerRadius, false); + var borderGeometryKeypoints = + new BorderGeometryKeypoints(boundRect, borderThickness, cornerRadius, false); var borderGeometry = new StreamGeometry(); using (var ctx = borderGeometry.Open()) @@ -84,17 +92,22 @@ namespace Avalonia.Controls.Utils public void Render(DrawingContext context, Size finalSize, Thickness borderThickness, CornerRadius cornerRadius, - IBrush background, IBrush borderBrush, BoxShadows boxShadows) + IBrush background, IBrush borderBrush, BoxShadows boxShadows, double borderDashOffset = 0, + PenLineCap borderLineCap = PenLineCap.Flat, PenLineJoin borderLineJoin = PenLineJoin.Miter, + AvaloniaList borderDashArray = null) { if (_size != finalSize || _borderThickness != borderThickness || _cornerRadius != cornerRadius || !_initialized) Update(finalSize, borderThickness, cornerRadius); - RenderCore(context, background, borderBrush, boxShadows); + RenderCore(context, background, borderBrush, boxShadows, borderDashOffset, borderLineCap, borderLineJoin, + borderDashArray); } - void RenderCore(DrawingContext context, IBrush background, IBrush borderBrush, BoxShadows boxShadows) + void RenderCore(DrawingContext context, IBrush background, IBrush borderBrush, BoxShadows boxShadows, + double borderDashOffset, PenLineCap borderLineCap, PenLineJoin borderLineJoin, + AvaloniaList borderDashArray) { if (_useComplexRendering) { @@ -115,11 +128,25 @@ namespace Avalonia.Controls.Utils var borderThickness = _borderThickness.Top; IPen pen = null; + + ImmutableDashStyle? dashStyle = null; + + if (borderDashArray != null && borderDashArray.Count > 0) + { + dashStyle = new ImmutableDashStyle(borderDashArray, borderDashOffset); + } + if (borderBrush != null && borderThickness > 0) { - pen = new ImmutablePen(borderBrush.ToImmutable(), borderThickness); + pen = new ImmutablePen( + borderBrush.ToImmutable(), + borderThickness, + dashStyle, + borderLineCap, + borderLineJoin); } + var rect = new Rect(_size); if (!MathUtilities.IsZero(borderThickness)) rect = rect.Deflate(borderThickness * 0.5); @@ -130,7 +157,8 @@ namespace Avalonia.Controls.Utils } } - private static void CreateGeometry(StreamGeometryContext context, Rect boundRect, BorderGeometryKeypoints keypoints) + private static void CreateGeometry(StreamGeometryContext context, Rect boundRect, + BorderGeometryKeypoints keypoints) { context.BeginFigure(keypoints.TopLeft, true); @@ -184,7 +212,8 @@ namespace Avalonia.Controls.Utils private class BorderGeometryKeypoints { - internal BorderGeometryKeypoints(Rect boundRect, Thickness borderThickness, CornerRadius cornerRadius, bool inner) + internal BorderGeometryKeypoints(Rect boundRect, Thickness borderThickness, CornerRadius cornerRadius, + bool inner) { var left = 0.5 * borderThickness.Left; var top = 0.5 * borderThickness.Top; @@ -206,10 +235,13 @@ namespace Avalonia.Controls.Utils topLeftX = Math.Max(0, cornerRadius.TopLeft - left) + boundRect.TopLeft.X; topRightX = boundRect.Width - Math.Max(0, cornerRadius.TopRight - top) + boundRect.TopLeft.X; rightTopY = Math.Max(0, cornerRadius.TopRight - right) + boundRect.TopLeft.Y; - rightBottomY = boundRect.Height - Math.Max(0, cornerRadius.BottomRight - bottom) + boundRect.TopLeft.Y; - bottomRightX = boundRect.Width - Math.Max(0, cornerRadius.BottomRight - right) + boundRect.TopLeft.X; + rightBottomY = boundRect.Height - Math.Max(0, cornerRadius.BottomRight - bottom) + + boundRect.TopLeft.Y; + bottomRightX = boundRect.Width - Math.Max(0, cornerRadius.BottomRight - right) + + boundRect.TopLeft.X; bottomLeftX = Math.Max(0, cornerRadius.BottomLeft - left) + boundRect.TopLeft.X; - leftBottomY = boundRect.Height - Math.Max(0, cornerRadius.BottomLeft - bottom) + boundRect.TopLeft.Y; + leftBottomY = boundRect.Height - Math.Max(0, cornerRadius.BottomLeft - bottom) + + boundRect.TopLeft.Y; } else { diff --git a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs index 5f4980e461..81a79201d0 100644 --- a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs +++ b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using System.Threading; using Avalonia.Media; using Avalonia.Platform; +using Avalonia.Utilities; using SkiaSharp; namespace Avalonia.Skia @@ -13,6 +14,25 @@ namespace Avalonia.Skia /// internal class FormattedTextImpl : IFormattedTextImpl { + private static readonly ThreadLocal t_builder = new ThreadLocal(() => new SKTextBlobBuilder()); + + private const float MAX_LINE_WIDTH = 10000; + + private readonly List> _foregroundBrushes = + new List>(); + private readonly List _lines = new List(); + private readonly SKPaint _paint; + private readonly List _rects = new List(); + public string Text { get; } + private readonly TextWrapping _wrapping; + private Size _constraint = new Size(double.PositiveInfinity, double.PositiveInfinity); + private float _lineHeight = 0; + private float _lineOffset = 0; + private Rect _bounds; + private List _skiaLines; + private ReadOnlySlice _glyphs; + private ReadOnlySlice _advances; + public FormattedTextImpl( string text, Typeface typeface, @@ -23,12 +43,9 @@ namespace Avalonia.Skia IReadOnlyList spans) { Text = text ?? string.Empty; - - // Replace 0 characters with zero-width spaces (200B) - Text = Text.Replace((char)0, (char)0x200B); - - var glyphTypeface = (GlyphTypefaceImpl)typeface.GlyphTypeface.PlatformImpl; + UpdateGlyphInfo(Text, typeface.GlyphTypeface, (float)fontSize); + _paint = new SKPaint { TextEncoding = SKTextEncoding.Utf16, @@ -37,7 +54,7 @@ namespace Avalonia.Skia LcdRenderText = true, SubpixelText = true, IsLinearText = true, - Typeface = glyphTypeface.Typeface, + Typeface = ((GlyphTypefaceImpl)typeface.GlyphTypeface.PlatformImpl).Typeface, TextSize = (float)fontSize, TextAlign = textAlignment.ToSKTextAlign() }; @@ -195,6 +212,40 @@ namespace Avalonia.Skia return Text; } + private void DrawTextBlob(int start, int length, float x, float y, SKCanvas canvas, SKPaint paint) + { + if(length == 0) + { + return; + } + + var glyphs = _glyphs.Buffer.Span.Slice(start, length); + var advances = _advances.Buffer.Span.Slice(start, length); + var builder = t_builder.Value; + + var buffer = builder.AllocateHorizontalRun(_paint.ToFont(), length, 0); + + buffer.SetGlyphs(glyphs); + + var positions = buffer.GetPositionSpan(); + + var pos = 0f; + + for (int i = 0; i < advances.Length; i++) + { + positions[i] = pos; + + pos += advances[i]; + } + + var blob = builder.Build(); + + if(blob != null) + { + canvas.DrawText(blob, x, y, paint); + } + } + internal void Draw(DrawingContextImpl context, SKCanvas canvas, SKPoint origin, @@ -244,16 +295,15 @@ namespace Avalonia.Skia if (!hasCusomFGBrushes) { - var subString = Text.Substring(line.Start, line.Length); - canvas.DrawText(subString, x, origin.Y + line.Top + _lineOffset, paint); + DrawTextBlob(line.Start, line.Length, x, origin.Y + line.Top + _lineOffset, canvas, paint); } else { float currX = x; - string subStr; float measure; int len; float factor; + switch (paint.TextAlign) { case SKTextAlign.Left: @@ -269,8 +319,7 @@ namespace Avalonia.Skia throw new ArgumentOutOfRangeException(); } - var textLine = Text.Substring(line.Start, line.Length); - currX -= textLine.Length == 0 ? 0 : paint.MeasureText(textLine) * factor; + currX -= line.Length == 0 ? 0 : MeasureText(line.Start, line.Length) * factor; for (int i = line.Start; i < line.Start + line.Length;) { @@ -288,13 +337,12 @@ namespace Avalonia.Skia currentWrapper = foreground; } - subStr = Text.Substring(i, len); - measure = paint.MeasureText(subStr); + measure = MeasureText(i, len); currX += measure * factor; - ApplyWrapperTo(ref currentPaint, currentWrapper, ref currd, paint, canUseLcdRendering); + ApplyWrapperTo(ref currentPaint, currentWrapper, ref currd, paint, canUseLcdRendering); - canvas.DrawText(subStr, currX, origin.Y + line.Top + _lineOffset, paint); + DrawTextBlob(i, len, currX, origin.Y + line.Top + _lineOffset, canvas, paint); i += len; currX += measure * (1 - factor); @@ -310,21 +358,6 @@ namespace Avalonia.Skia } } - private const float MAX_LINE_WIDTH = 10000; - - private readonly List> _foregroundBrushes = - new List>(); - private readonly List _lines = new List(); - private readonly SKPaint _paint; - private readonly List _rects = new List(); - public string Text { get; } - private readonly TextWrapping _wrapping; - private Size _constraint = new Size(double.PositiveInfinity, double.PositiveInfinity); - private float _lineHeight = 0; - private float _lineOffset = 0; - private Rect _bounds; - private List _skiaLines; - private static void ApplyWrapperTo(ref SKPaint current, DrawingContextImpl.PaintWrapper wrapper, ref IDisposable curr, SKPaint paint, bool canUseLcdRendering) { @@ -352,9 +385,8 @@ namespace Avalonia.Skia } else { - float measuredWidth; string subText = textInput.Substring(textIndex, stop - textIndex); - lengthBreak = (int)paint.BreakText(subText, maxWidth, out measuredWidth); + lengthBreak = (int)paint.BreakText(subText, maxWidth, out _); } //Check for white space or line breakers before the lengthBreak @@ -468,8 +500,7 @@ namespace Avalonia.Skia for (int i = line.Start; i < line.Start + line.TextLength; i++) { - var c = Text[i]; - var w = line.IsEmptyTrailingLine ? 0 :_paint.MeasureText(Text[i].ToString()); + var w = line.IsEmptyTrailingLine ? 0 : _advances[i]; _rects.Add(new Rect( prevRight, @@ -554,7 +585,7 @@ namespace Avalonia.Skia // This seems like the best measure of full vertical extent // matches Direct2D line height - _lineHeight = mDescent - mAscent; + _lineHeight = mDescent - mAscent + metrics.Leading; // Rendering is relative to baseline _lineOffset = (-metrics.Ascent); @@ -585,7 +616,7 @@ namespace Avalonia.Skia line.Start = curOff; line.TextLength = measured; subString = Text.Substring(line.Start, line.TextLength); - lineWidth = _paint.MeasureText(subString); + lineWidth = MeasureText(line.Start, line.TextLength); line.Length = measured - trailingnumber; line.Width = lineWidth; line.Height = _lineHeight; @@ -608,8 +639,7 @@ namespace Avalonia.Skia AvaloniaFormattedTextLine lastLine = new AvaloniaFormattedTextLine(); lastLine.TextLength = lengthDiff; lastLine.Start = curOff - lengthDiff; - var lastLineSubString = Text.Substring(line.Start, line.TextLength); - var lastLineWidth = _paint.MeasureText(lastLineSubString); + var lastLineWidth = MeasureText(line.Start, line.TextLength); lastLine.Length = 0; lastLine.Width = lastLineWidth; lastLine.Height = _lineHeight; @@ -668,6 +698,67 @@ namespace Avalonia.Skia } } + private float MeasureText(int start, int length) + { + var width = 0f; + + for (int i = start; i < start + length; i++) + { + var advance = _advances[i]; + + width += advance; + } + + return width; + } + + private void UpdateGlyphInfo(string text, GlyphTypeface glyphTypeface, float fontSize) + { + var glyphs = new ushort[text.Length]; + var advances = new float[text.Length]; + + var scale = fontSize / glyphTypeface.DesignEmHeight; + var width = 0f; + var characters = text.AsSpan(); + + for (int i = 0; i < characters.Length; i++) + { + var c = characters[i]; + float advance; + ushort glyph; + + switch (c) + { + case (char)0: + { + glyph = glyphTypeface.GetGlyph(0x200B); + advance = 0; + break; + } + case '\t': + { + glyph = glyphTypeface.GetGlyph(' '); + advance = glyphTypeface.GetGlyphAdvance(glyph) * scale * 4; + break; + } + default: + { + glyph = glyphTypeface.GetGlyph(c); + advance = glyphTypeface.GetGlyphAdvance(glyph) * scale; + break; + } + } + + glyphs[i] = glyph; + advances[i] = advance; + + width += advance; + } + + _glyphs = new ReadOnlySlice(glyphs); + _advances = new ReadOnlySlice(advances); + } + private float TransformX(float originX, float lineWidth, SKTextAlign align) { float x = 0; diff --git a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs index d5ac01a092..f5ae4cc1e0 100644 --- a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs +++ b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs @@ -357,5 +357,157 @@ namespace Avalonia.Base.UnitTests.Collections Assert.Equal(target, result); } + + [Fact] + public void RemoveAll_Should_Send_Single_Notification_For_Sequential_Range() + { + var target = new AvaloniaList(Enumerable.Range(0, 10).Select(x => $"Item {x}")); + var toRemove = new[] { "Item 5", "Item 6", "Item 7" }; + var raised = 0; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(NotifyCollectionChangedAction.Remove, e.Action); + Assert.Equal(5, e.OldStartingIndex); + Assert.Equal(toRemove, e.OldItems); + ++raised; + }; + + target.RemoveAll(toRemove); + + Assert.Equal(1, raised); + } + + [Fact] + public void RemoveAll_Should_Send_Single_Notification_For_Sequential_Range_With_Duplicate_Source_Items() + { + var items = Enumerable.Range(0, 20).Select(x => $"Item {x / 2}"); + var target = new AvaloniaList(items); + var toRemove = new[] { "Item 5", "Item 6", "Item 7" }; + var raised = 0; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(NotifyCollectionChangedAction.Remove, e.Action); + Assert.Equal(10, e.OldStartingIndex); + + Assert.Equal(new[] + { + "Item 5", + "Item 5", + "Item 6", + "Item 6", + "Item 7", + "Item 7", + }, e.OldItems); + ++raised; + }; + + target.RemoveAll(toRemove); + + Assert.Equal(1, raised); + } + + [Fact] + public void RemoveAll_Should_Send_Multiple_Notifications_For_Non_Sequential_Range() + { + var target = new AvaloniaList(Enumerable.Range(0, 10).Select(x => $"Item {x}")); + var raised = 0; + var toRemove = new[] + { + new[] { "Item 2", "Item 3" }, + new[] { "Item 5", "Item 6" } + }; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(NotifyCollectionChangedAction.Remove, e.Action); + + if (raised == 0) + { + Assert.Equal(5, e.OldStartingIndex); + Assert.Equal(toRemove[1], e.OldItems); + } + else + { + Assert.Equal(2, e.OldStartingIndex); + Assert.Equal(toRemove[0], e.OldItems); + } + + ++raised; + }; + + target.RemoveAll(toRemove[0].Concat(toRemove[1])); + + Assert.Equal(2, raised); + } + + [Fact] + public void RemoveAll_Should_Send_Multiple_Notifications_For_Sequential_Range_With_Nonsequential_Duplicate_Source_Items() + { + var items = Enumerable.Range(0, 10).Select(x => $"Item {x}"); + var target = new AvaloniaList(items.Concat(items)); + var raised = 0; + var toRemove = new[] { "Item 5", "Item 6", "Item 7" }; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(NotifyCollectionChangedAction.Remove, e.Action); + + if (raised == 0) + { + Assert.Equal(15, e.OldStartingIndex); + Assert.Equal(toRemove, e.OldItems); + } + else + { + Assert.Equal(5, e.OldStartingIndex); + Assert.Equal(toRemove, e.OldItems); + } + + ++raised; + }; + + target.RemoveAll(toRemove); + + Assert.Equal(2, raised); + } + + [Fact] + public void RemoveAll_Should_Not_Send_Notification_For_Items_Not_Present() + { + var target = new AvaloniaList(Enumerable.Range(0, 10).Select(x => $"Item {x}")); + var toRemove = new[] { "Item 5", "Item 6", "Item 7", "Not present" }; + var raised = 0; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(NotifyCollectionChangedAction.Remove, e.Action); + Assert.Equal(5, e.OldStartingIndex); + Assert.Equal(toRemove.Take(3).ToArray(), e.OldItems); + ++raised; + }; + + target.RemoveAll(toRemove); + + Assert.Equal(1, raised); + } + + [Fact] + public void RemoveAll_Should_Handle_Empty_List() + { + var target = new AvaloniaList(); + var toRemove = new[] { "Item 5", "Item 6", "Item 7" }; + var raised = 0; + + target.CollectionChanged += (s, e) => + { + ++raised; + }; + + target.RemoveAll(toRemove); + + Assert.Equal(0, raised); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index cb20071860..23cae8fd0d 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -784,6 +784,54 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Setting_SelectedText_Should_Fire_Single_Text_Changed_Notification() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "0123", + AcceptsReturn = true, + AcceptsTab = true, + SelectionStart = 1, + SelectionEnd = 3, + }; + + var values = new List(); + target.GetObservable(TextBox.TextProperty).Subscribe(x => values.Add(x)); + + target.SelectedText = "A"; + + Assert.Equal(new[] { "0123", "0A3" }, values); + } + } + + [Fact] + public void Entering_Text_With_SelectedText_Should_Fire_Single_Text_Changed_Notification() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "0123", + AcceptsReturn = true, + AcceptsTab = true, + SelectionStart = 1, + SelectionEnd = 3, + }; + + var values = new List(); + target.GetObservable(TextBox.TextProperty).Subscribe(x => values.Add(x)); + + RaiseTextEvent(target, "A"); + + Assert.Equal(new[] { "0123", "0A3" }, values); + } + } + private static TestServices FocusServices => TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 72ba3ab273..2169b15cad 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; using Avalonia.Collections; +using Avalonia.Controls.Generators; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; using Avalonia.Data; @@ -24,7 +25,7 @@ namespace Avalonia.Controls.UnitTests public class TreeViewTests { MouseTestHelper _mouse = new MouseTestHelper(); - + [Fact] public void Items_Should_Be_Created() { @@ -675,7 +676,7 @@ namespace Avalonia.Controls.UnitTests Assert.Same(node, focus.Current); } } - + [Fact] public void Keyboard_Navigation_Should_Not_Crash_If_Selected_Item_Is_not_In_Tree() { @@ -1166,6 +1167,34 @@ namespace Avalonia.Controls.UnitTests Assert.Empty(target.ItemContainerGenerator.Index.Containers); } + [Fact] + public void Can_Use_Derived_TreeViewItem() + { + var tree = CreateTestTreeData(); + var target = new DerivedTreeViewWithDerivedTreeViewItems + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + ApplyTemplates(target); + + // Verify that all items are DerivedTreeViewItem + VerifyItemType(target.ItemContainerGenerator); + + void VerifyItemType(ITreeItemContainerGenerator containerGenerator) + { + foreach (var container in containerGenerator.Index.Containers) + { + var item = Assert.IsType(container); + if (item.ItemCount > 0) + { + VerifyItemType(item.ItemContainerGenerator); + } + } + } + } + private void ApplyTemplates(TreeView tree) { tree.ApplyTemplate(); @@ -1376,6 +1405,17 @@ namespace Avalonia.Controls.UnitTests { } + private class DerivedTreeViewWithDerivedTreeViewItems : TreeView + { + protected override ITreeItemContainerGenerator CreateTreeItemContainerGenerator() => + CreateTreeItemContainerGenerator(); + } + + private class DerivedTreeViewItem : TreeViewItem + { + protected override IItemContainerGenerator CreateItemContainerGenerator() => CreateTreeItemContainerGenerator(); + } + private class TestDataContext : INotifyPropertyChanged { private string _selectedItem; @@ -1398,7 +1438,7 @@ namespace Avalonia.Controls.UnitTests } public event PropertyChangedEventHandler PropertyChanged; - + } } }