diff --git a/.ncrunch/ReactiveUIDemo.v3.ncrunchproject b/.ncrunch/ReactiveUIDemo.v3.ncrunchproject new file mode 100644 index 0000000000..319cd523ce --- /dev/null +++ b/.ncrunch/ReactiveUIDemo.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 6ba05332be..3fa8e969c8 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -9,6 +9,7 @@ "samples\\MiniMvvm\\MiniMvvm.csproj", "samples\\SampleControls\\ControlSamples.csproj", "samples\\Sandbox\\Sandbox.csproj", + "samples\\ReactiveUIDemo\\ReactiveUIDemo.csproj", "src\\Avalonia.Base\\Avalonia.Base.csproj", "src\\Avalonia.Build.Tasks\\Avalonia.Build.Tasks.csproj", "src\\Avalonia.Controls.ColorPicker\\Avalonia.Controls.ColorPicker.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index bd83cde620..7efb294b64 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -232,6 +232,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.Browser", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.Browser.Blazor", "samples\ControlCatalog.Browser.Blazor\ControlCatalog.Browser.Blazor.csproj", "{90B08091-9BBD-4362-B712-E9F2CC62B218}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUIDemo", "samples\ReactiveUIDemo\ReactiveUIDemo.csproj", "{75C47156-C5D8-44BC-A5A7-E8657C2248D6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -543,6 +545,10 @@ Global {90B08091-9BBD-4362-B712-E9F2CC62B218}.Debug|Any CPU.Build.0 = Debug|Any CPU {90B08091-9BBD-4362-B712-E9F2CC62B218}.Release|Any CPU.ActiveCfg = Release|Any CPU {90B08091-9BBD-4362-B712-E9F2CC62B218}.Release|Any CPU.Build.0 = Release|Any CPU + {75C47156-C5D8-44BC-A5A7-E8657C2248D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75C47156-C5D8-44BC-A5A7-E8657C2248D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75C47156-C5D8-44BC-A5A7-E8657C2248D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75C47156-C5D8-44BC-A5A7-E8657C2248D6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -607,6 +613,7 @@ Global {47F8530C-F19B-4B1A-B4D6-EB231522AE5D} = {86A3F706-DC3C-43C6-BE1B-B98F5BAAA268} {15B93A4C-1B46-43F6-B534-7B25B6E99932} = {9B9E3891-2366-4253-A952-D08BCEB71098} {90B08091-9BBD-4362-B712-E9F2CC62B218} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {75C47156-C5D8-44BC-A5A7-E8657C2248D6} = {9B9E3891-2366-4253-A952-D08BCEB71098} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index b95b455ca4..4a5f5bc96c 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -135,6 +135,9 @@ + + + diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml new file mode 100644 index 0000000000..f3bf1724b4 --- /dev/null +++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs new file mode 100644 index 0000000000..f9d0328d9a --- /dev/null +++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using ControlCatalog.ViewModels; + +namespace ControlCatalog.Pages +{ + public class RefreshContainerPage : UserControl + { + private RefreshContainerViewModel _viewModel; + + public RefreshContainerPage() + { + this.InitializeComponent(); + + _viewModel = new RefreshContainerViewModel(); + + DataContext = _viewModel; + } + + private async void RefreshContainerPage_RefreshRequested(object? sender, RefreshRequestedEventArgs e) + { + var deferral = e.GetDeferral(); + + await _viewModel.AddToTop(); + + deferral.Complete(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs new file mode 100644 index 0000000000..d4b43043be --- /dev/null +++ b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs @@ -0,0 +1,26 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using Avalonia.Controls.Notifications; +using ControlCatalog.Pages; +using MiniMvvm; + +namespace ControlCatalog.ViewModels +{ + public class RefreshContainerViewModel : ViewModelBase + { + public ObservableCollection Items { get; } + + public RefreshContainerViewModel() + { + Items = new ObservableCollection(Enumerable.Range(1, 200).Select(i => $"Item {i}")); + } + + public async Task AddToTop() + { + await Task.Delay(3000); + Items.Insert(0, $"Item {200 - Items.Count}"); + } + } +} diff --git a/samples/ReactiveUIDemo/App.axaml b/samples/ReactiveUIDemo/App.axaml new file mode 100644 index 0000000000..dd3a39f6ac --- /dev/null +++ b/samples/ReactiveUIDemo/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/samples/ReactiveUIDemo/App.axaml.cs b/samples/ReactiveUIDemo/App.axaml.cs new file mode 100644 index 0000000000..4578566427 --- /dev/null +++ b/samples/ReactiveUIDemo/App.axaml.cs @@ -0,0 +1,37 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using ReactiveUI; +using ReactiveUIDemo.ViewModels; +using ReactiveUIDemo.Views; +using Splat; + +namespace ReactiveUIDemo +{ + public class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + Locator.CurrentMutable.Register(() => new FooView(), typeof(IViewFor)); + Locator.CurrentMutable.Register(() => new BarView(), typeof(IViewFor)); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } + + public static int Main(string[] args) + => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .UseReactiveUI() + .LogToTrace(); + } +} diff --git a/samples/ReactiveUIDemo/MainWindow.axaml b/samples/ReactiveUIDemo/MainWindow.axaml new file mode 100644 index 0000000000..7775fc5a79 --- /dev/null +++ b/samples/ReactiveUIDemo/MainWindow.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/samples/ReactiveUIDemo/MainWindow.axaml.cs b/samples/ReactiveUIDemo/MainWindow.axaml.cs new file mode 100644 index 0000000000..5bf2d476fd --- /dev/null +++ b/samples/ReactiveUIDemo/MainWindow.axaml.cs @@ -0,0 +1,22 @@ +using ReactiveUIDemo.ViewModels; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace ReactiveUIDemo +{ + public class MainWindow : Window + { + public MainWindow() + { + this.InitializeComponent(); + this.DataContext = new MainWindowViewModel(); + this.AttachDevTools(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/ReactiveUIDemo/ReactiveUIDemo.csproj b/samples/ReactiveUIDemo/ReactiveUIDemo.csproj new file mode 100644 index 0000000000..94ca4ee809 --- /dev/null +++ b/samples/ReactiveUIDemo/ReactiveUIDemo.csproj @@ -0,0 +1,28 @@ + + + Exe + net6.0 + enable + + + + + + + + + + + BarView.axaml + + + FooView.axaml + + + + + + + + + diff --git a/samples/ReactiveUIDemo/ViewModels/BarViewModel.cs b/samples/ReactiveUIDemo/ViewModels/BarViewModel.cs new file mode 100644 index 0000000000..3448453d81 --- /dev/null +++ b/samples/ReactiveUIDemo/ViewModels/BarViewModel.cs @@ -0,0 +1,11 @@ +using ReactiveUI; + +namespace ReactiveUIDemo.ViewModels +{ + internal class BarViewModel : ReactiveObject, IRoutableViewModel + { + public BarViewModel(IScreen screen) => HostScreen = screen; + public string UrlPathSegment => "Bar"; + public IScreen HostScreen { get; } + } +} diff --git a/samples/ReactiveUIDemo/ViewModels/FooViewModel.cs b/samples/ReactiveUIDemo/ViewModels/FooViewModel.cs new file mode 100644 index 0000000000..1a363e18dc --- /dev/null +++ b/samples/ReactiveUIDemo/ViewModels/FooViewModel.cs @@ -0,0 +1,11 @@ +using ReactiveUI; + +namespace ReactiveUIDemo.ViewModels +{ + internal class FooViewModel : ReactiveObject, IRoutableViewModel + { + public FooViewModel(IScreen screen) => HostScreen = screen; + public string UrlPathSegment => "Foo"; + public IScreen HostScreen { get; } + } +} diff --git a/samples/ReactiveUIDemo/ViewModels/MainWindowViewModel.cs b/samples/ReactiveUIDemo/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..2222137d38 --- /dev/null +++ b/samples/ReactiveUIDemo/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,9 @@ +using ReactiveUI; + +namespace ReactiveUIDemo.ViewModels +{ + internal class MainWindowViewModel : ReactiveObject + { + public RoutedViewHostPageViewModel RoutedViewHost { get; } = new(); + } +} diff --git a/samples/ReactiveUIDemo/ViewModels/RoutedViewHostPageViewModel.cs b/samples/ReactiveUIDemo/ViewModels/RoutedViewHostPageViewModel.cs new file mode 100644 index 0000000000..701447cfe8 --- /dev/null +++ b/samples/ReactiveUIDemo/ViewModels/RoutedViewHostPageViewModel.cs @@ -0,0 +1,21 @@ +using ReactiveUI; + +namespace ReactiveUIDemo.ViewModels +{ + internal class RoutedViewHostPageViewModel : ReactiveObject, IScreen + { + public RoutedViewHostPageViewModel() + { + Foo = new(this); + Bar = new(this); + Router.Navigate.Execute(Foo); + } + + public RoutingState Router { get; } = new(); + public FooViewModel Foo { get; } + public BarViewModel Bar { get; } + + public void ShowFoo() => Router.Navigate.Execute(Foo); + public void ShowBar() => Router.Navigate.Execute(Bar); + } +} diff --git a/samples/ReactiveUIDemo/Views/BarView.axaml b/samples/ReactiveUIDemo/Views/BarView.axaml new file mode 100644 index 0000000000..2622245997 --- /dev/null +++ b/samples/ReactiveUIDemo/Views/BarView.axaml @@ -0,0 +1,16 @@ + + + + Bar! + + + diff --git a/samples/ReactiveUIDemo/Views/BarView.axaml.cs b/samples/ReactiveUIDemo/Views/BarView.axaml.cs new file mode 100644 index 0000000000..2fbea6de91 --- /dev/null +++ b/samples/ReactiveUIDemo/Views/BarView.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using ReactiveUI; +using ReactiveUIDemo.ViewModels; + +namespace ReactiveUIDemo.Views +{ + internal partial class BarView : UserControl, IViewFor + { + public BarView() + { + InitializeComponent(); + } + + public BarViewModel? ViewModel { get; set; } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (BarViewModel?)value; + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/ReactiveUIDemo/Views/FooView.axaml b/samples/ReactiveUIDemo/Views/FooView.axaml new file mode 100644 index 0000000000..8f73250d3b --- /dev/null +++ b/samples/ReactiveUIDemo/Views/FooView.axaml @@ -0,0 +1,16 @@ + + + + Foo! + + + diff --git a/samples/ReactiveUIDemo/Views/FooView.axaml.cs b/samples/ReactiveUIDemo/Views/FooView.axaml.cs new file mode 100644 index 0000000000..313a71044c --- /dev/null +++ b/samples/ReactiveUIDemo/Views/FooView.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using ReactiveUI; +using ReactiveUIDemo.ViewModels; + +namespace ReactiveUIDemo.Views +{ + internal partial class FooView : UserControl, IViewFor + { + public FooView() + { + InitializeComponent(); + } + + public FooViewModel? ViewModel { get; set; } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (FooViewModel?)value; + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/RenderDemo/Pages/TextFormatterPage.axaml.cs b/samples/RenderDemo/Pages/TextFormatterPage.axaml.cs index 57a5c7101f..8fbfa854b1 100644 --- a/samples/RenderDemo/Pages/TextFormatterPage.axaml.cs +++ b/samples/RenderDemo/Pages/TextFormatterPage.axaml.cs @@ -90,7 +90,7 @@ namespace RenderDemo.Pages return new ControlRun(_control, _defaultProperties); } - return new TextCharacters(_text.AsMemory(), _defaultProperties); + return new TextCharacters(_text, _defaultProperties); } } diff --git a/src/Avalonia.Base/Animation/Animators/Animator`1.cs b/src/Avalonia.Base/Animation/Animators/Animator`1.cs index 8765cfb4c9..b5d1feb4a7 100644 --- a/src/Avalonia.Base/Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Base/Animation/Animators/Animator`1.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reactive.Disposables; using System.Reactive.Linq; using Avalonia.Animation.Utils; using Avalonia.Collections; @@ -39,7 +40,7 @@ namespace Avalonia.Animation.Animators VerifyConvertKeyFrames(); var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete); - return match.Subscribe(subject); + return new CompositeDisposable(match.Subscribe(subject), subject); } protected T InterpolationHandler(double animationTime, T neutralValue) diff --git a/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs new file mode 100644 index 0000000000..fedd07ec32 --- /dev/null +++ b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs @@ -0,0 +1,152 @@ +using Avalonia.Input.GestureRecognizers; + +namespace Avalonia.Input +{ + public class PullGestureRecognizer : StyledElement, IGestureRecognizer + { + private IInputElement? _target; + private IGestureRecognizerActionsDispatcher? _actions; + private Point _initialPosition; + private int _gestureId; + private IPointer? _tracking; + private PullDirection _pullDirection; + + /// + /// Defines the property. + /// + public static readonly DirectProperty PullDirectionProperty = + AvaloniaProperty.RegisterDirect( + nameof(PullDirection), + o => o.PullDirection, + (o, v) => o.PullDirection = v); + + public PullDirection PullDirection + { + get => _pullDirection; + set => SetAndRaise(PullDirectionProperty, ref _pullDirection, value); + } + + public PullGestureRecognizer(PullDirection pullDirection) + { + PullDirection = pullDirection; + } + + public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions) + { + _target = target; + _actions = actions; + + _target?.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble); + _target?.AddHandler(InputElement.PointerReleasedEvent, OnPointerReleased, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble); + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + PointerPressed(e); + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + PointerReleased(e); + } + + public void PointerCaptureLost(IPointer pointer) + { + if (_tracking == pointer) + { + EndPull(); + } + } + + public void PointerMoved(PointerEventArgs e) + { + if (_tracking == e.Pointer && _target is Visual visual) + { + var currentPosition = e.GetPosition(visual); + _actions!.Capture(e.Pointer, this); + + Vector delta = default; + switch (PullDirection) + { + case PullDirection.TopToBottom: + if (currentPosition.Y > _initialPosition.Y) + { + delta = new Vector(0, currentPosition.Y - _initialPosition.Y); + } + break; + case PullDirection.BottomToTop: + if (currentPosition.Y < _initialPosition.Y) + { + delta = new Vector(0, _initialPosition.Y - currentPosition.Y); + } + break; + case PullDirection.LeftToRight: + if (currentPosition.X > _initialPosition.X) + { + delta = new Vector(currentPosition.X - _initialPosition.X, 0); + } + break; + case PullDirection.RightToLeft: + if (currentPosition.X < _initialPosition.X) + { + delta = new Vector(_initialPosition.X - currentPosition.X, 0); + } + break; + } + + _target?.RaiseEvent(new PullGestureEventArgs(_gestureId, delta, PullDirection)); + } + } + + public void PointerPressed(PointerPressedEventArgs e) + { + if (_target != null && _target is Visual visual && (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen)) + { + var position = e.GetPosition(visual); + + var canPull = false; + + var bounds = visual.Bounds; + + switch (PullDirection) + { + case PullDirection.TopToBottom: + canPull = position.Y < bounds.Height * 0.1; + break; + case PullDirection.BottomToTop: + canPull = position.Y > bounds.Height - (bounds.Height * 0.1); + break; + case PullDirection.LeftToRight: + canPull = position.X < bounds.Width * 0.1; + break; + case PullDirection.RightToLeft: + canPull = position.X > bounds.Width - (bounds.Width * 0.1); + break; + } + + if (canPull) + { + _gestureId = PullGestureEventArgs.GetNextFreeId(); + _tracking = e.Pointer; + _initialPosition = position; + } + } + } + + public void PointerReleased(PointerReleasedEventArgs e) + { + if (_tracking == e.Pointer) + { + EndPull(); + } + } + + private void EndPull() + { + _tracking = null; + _initialPosition = default; + + _target?.RaiseEvent(new PullGestureEndedEventArgs(_gestureId, PullDirection)); + } + } +} diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index 496052ad35..1ea88fe824 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -46,6 +46,14 @@ namespace Avalonia.Input private static readonly WeakReference s_lastPress = new WeakReference(null); private static Point s_lastPressPoint; + public static readonly RoutedEvent PullGestureEvent = + RoutedEvent.Register( + "PullGesture", RoutingStrategies.Bubble, typeof(Gestures)); + + public static readonly RoutedEvent PullGestureEndedEvent = + RoutedEvent.Register( + "PullGestureEnded", RoutingStrategies.Bubble, typeof(Gestures)); + static Gestures() { InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed); diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index d0130258c3..fa755277cc 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -442,6 +442,11 @@ namespace Avalonia.Input { SetAndRaise(IsEffectivelyEnabledProperty, ref _isEffectivelyEnabled, value); PseudoClasses.Set(":disabled", !value); + + if (!IsEffectivelyEnabled && FocusManager.Instance?.Current == this) + { + FocusManager.Instance?.Focus(null); + } } } diff --git a/src/Avalonia.Base/Input/PullGestureEventArgs.cs b/src/Avalonia.Base/Input/PullGestureEventArgs.cs new file mode 100644 index 0000000000..34d95c87f4 --- /dev/null +++ b/src/Avalonia.Base/Input/PullGestureEventArgs.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia.Interactivity; + +namespace Avalonia.Input +{ + public class PullGestureEventArgs : RoutedEventArgs + { + public int Id { get; } + public Vector Delta { get; } + public PullDirection PullDirection { get; } + + private static int _nextId = 1; + + internal static int GetNextFreeId() => _nextId++; + + public PullGestureEventArgs(int id, Vector delta, PullDirection pullDirection) : base(Gestures.PullGestureEvent) + { + Id = id; + Delta = delta; + PullDirection = pullDirection; + } + } + + public class PullGestureEndedEventArgs : RoutedEventArgs + { + public int Id { get; } + public PullDirection PullDirection { get; } + + public PullGestureEndedEventArgs(int id, PullDirection pullDirection) : base(Gestures.PullGestureEndedEvent) + { + Id = id; + PullDirection = pullDirection; + } + } + + public enum PullDirection + { + TopToBottom, + BottomToTop, + LeftToRight, + RightToLeft + } +} diff --git a/src/Avalonia.Base/Media/FormattedText.cs b/src/Avalonia.Base/Media/FormattedText.cs index 90b9755493..138e8b79eb 100644 --- a/src/Avalonia.Base/Media/FormattedText.cs +++ b/src/Avalonia.Base/Media/FormattedText.cs @@ -1,10 +1,8 @@ using System; using System.Collections; -using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Globalization; -using Avalonia.Controls; using Avalonia.Media.TextFormatting; using Avalonia.Utilities; @@ -25,7 +23,7 @@ namespace Avalonia.Media private const double MaxFontEmSize = RealInfiniteWidth / GreatestMultiplierOfEm; // properties and format runs - private ReadOnlySlice _text; + private string _text; private readonly SpanVector _formatRuns = new SpanVector(null); private SpanPosition _latestPosition; @@ -69,9 +67,7 @@ namespace Avalonia.Media ValidateFontSize(emSize); - _text = textToFormat != null ? - new ReadOnlySlice(textToFormat.AsMemory()) : - throw new ArgumentNullException(nameof(textToFormat)); + _text = textToFormat; var runProps = new GenericTextRunProperties( typeface, diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index d93a68e78b..af9e458a28 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Drawing; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Platform; using Avalonia.Utilities; @@ -22,15 +21,12 @@ namespace Avalonia.Media private Point? _baselineOrigin; private GlyphRunMetrics? _glyphRunMetrics; - private ReadOnlySlice _characters; - + private IReadOnlyList _characters; private IReadOnlyList _glyphIndices; private IReadOnlyList? _glyphAdvances; private IReadOnlyList? _glyphOffsets; private IReadOnlyList? _glyphClusters; - private int _offsetToFirstCharacter; - /// /// Initializes a new instance of the class by specifying properties of the class. /// @@ -45,7 +41,7 @@ namespace Avalonia.Media public GlyphRun( IGlyphTypeface glyphTypeface, double fontRenderingEmSize, - ReadOnlySlice characters, + IReadOnlyList characters, IReadOnlyList glyphIndices, IReadOnlyList? glyphAdvances = null, IReadOnlyList? glyphOffsets = null, @@ -54,19 +50,19 @@ namespace Avalonia.Media { _glyphTypeface = glyphTypeface; - FontRenderingEmSize = fontRenderingEmSize; + _fontRenderingEmSize = fontRenderingEmSize; - Characters = characters; + _characters = characters; _glyphIndices = glyphIndices; - GlyphAdvances = glyphAdvances; + _glyphAdvances = glyphAdvances; - GlyphOffsets = glyphOffsets; + _glyphOffsets = glyphOffsets; - GlyphClusters = glyphClusters; + _glyphClusters = glyphClusters; - BiDiLevel = biDiLevel; + _biDiLevel = biDiLevel; } /// @@ -145,7 +141,7 @@ namespace Avalonia.Media /// /// Gets or sets the list of UTF16 code points that represent the Unicode content of the . /// - public ReadOnlySlice Characters + public IReadOnlyList Characters { get => _characters; set => Set(ref _characters, value); @@ -219,7 +215,7 @@ namespace Avalonia.Media /// public double GetDistanceFromCharacterHit(CharacterHit characterHit) { - var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength - _offsetToFirstCharacter; + var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; var distance = 0.0; @@ -227,12 +223,12 @@ namespace Avalonia.Media { if (GlyphClusters != null) { - if (characterIndex < GlyphClusters[0]) + if (characterIndex < Metrics.FirstCluster) { return 0; } - if (characterIndex > GlyphClusters[GlyphClusters.Count - 1]) + if (characterIndex > Metrics.LastCluster) { return Metrics.WidthIncludingTrailingWhitespace; } @@ -268,12 +264,12 @@ namespace Avalonia.Media if (GlyphClusters != null && GlyphClusters.Count > 0) { - if (characterIndex > GlyphClusters[0]) + if (characterIndex > Metrics.LastCluster) { return 0; } - if (characterIndex <= GlyphClusters[GlyphClusters.Count - 1]) + if (characterIndex <= Metrics.FirstCluster) { return Size.Width; } @@ -299,19 +295,12 @@ namespace Avalonia.Media /// public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside) { - var characterIndex = 0; - // Before if (distance <= 0) { isInside = false; - if (GlyphClusters != null) - { - characterIndex = GlyphClusters[characterIndex]; - } - - var firstCharacterHit = FindNearestCharacterHit(characterIndex, out _); + var firstCharacterHit = FindNearestCharacterHit(IsLeftToRight ? Metrics.FirstCluster : Metrics.LastCluster, out _); return IsLeftToRight ? new CharacterHit(firstCharacterHit.FirstCharacterIndex) : firstCharacterHit; } @@ -321,18 +310,13 @@ namespace Avalonia.Media { isInside = false; - characterIndex = GlyphIndices.Count - 1; - - if (GlyphClusters != null) - { - characterIndex = GlyphClusters[characterIndex]; - } - - var lastCharacterHit = FindNearestCharacterHit(characterIndex, out _); + var lastCharacterHit = FindNearestCharacterHit(IsLeftToRight ? Metrics.LastCluster : Metrics.FirstCluster, out _); return IsLeftToRight ? lastCharacterHit : new CharacterHit(lastCharacterHit.FirstCharacterIndex); } + var characterIndex = 0; + //Within var currentX = 0d; @@ -378,7 +362,7 @@ namespace Avalonia.Media var characterHit = FindNearestCharacterHit(characterIndex, out var width); var delta = width / 2; - + var offset = IsLeftToRight ? Math.Round(distance - currentX, 3) : Math.Round(currentX - distance, 3); var isTrailing = offset > delta; @@ -400,24 +384,15 @@ namespace Avalonia.Media { characterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex, out _); - var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - - return textPosition > _characters.End ? - characterHit : - new CharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength); - } - - var nextCharacterHit = - FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _); + if (characterHit.FirstCharacterIndex == Metrics.LastCluster) + { + return characterHit; + } - if (characterHit == nextCharacterHit) - { - return characterHit; + return new CharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength); } - return characterHit.TrailingLength > 0 ? - nextCharacterHit : - new CharacterHit(nextCharacterHit.FirstCharacterIndex); + return FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _); } /// @@ -454,29 +429,24 @@ namespace Avalonia.Media return characterIndex; } - if (IsLeftToRight) + if (characterIndex > Metrics.LastCluster) { - if (characterIndex < GlyphClusters[0]) + if (IsLeftToRight) { - return 0; + return GlyphIndices.Count - 1; } - if (characterIndex > GlyphClusters[GlyphClusters.Count - 1]) - { - return GlyphClusters.Count - 1; - } + return 0; } - else - { - if (characterIndex < GlyphClusters[GlyphClusters.Count - 1]) - { - return GlyphClusters.Count - 1; - } - if (characterIndex > GlyphClusters[0]) + if (characterIndex < Metrics.FirstCluster) + { + if (IsLeftToRight) { return 0; } + + return GlyphIndices.Count - 1; } var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer; @@ -498,7 +468,7 @@ namespace Avalonia.Media if (start < 0) { - return -1; + goto result; } } @@ -517,6 +487,18 @@ namespace Avalonia.Media } } + result: + + if (start < 0) + { + return 0; + } + + if (start > GlyphIndices.Count - 1) + { + return GlyphIndices.Count - 1; + } + return start; } @@ -532,20 +514,20 @@ namespace Avalonia.Media { width = 0.0; - var start = FindGlyphIndex(index); + var glyphIndex = FindGlyphIndex(index); if (GlyphClusters == null) { width = GetGlyphAdvance(index, out _); - return new CharacterHit(start, 1); + return new CharacterHit(glyphIndex, 1); } - var cluster = GlyphClusters[start]; + var cluster = GlyphClusters[glyphIndex]; var nextCluster = cluster; - var currentIndex = start; + var currentIndex = glyphIndex; while (nextCluster == cluster) { @@ -571,20 +553,64 @@ namespace Avalonia.Media } nextCluster = GlyphClusters[currentIndex]; - } + } - int trailingLength; + var clusterLength = Math.Max(0, nextCluster - cluster); - if (nextCluster == cluster) - { - trailingLength = Characters.Start + Characters.Length - _offsetToFirstCharacter - cluster; - } - else + if (cluster == Metrics.LastCluster && clusterLength == 0) { - trailingLength = nextCluster - cluster; + var characterLength = 0; + + var currentCluster = Metrics.FirstCluster; + + if (IsLeftToRight) + { + for (int i = 1; i < GlyphClusters.Count; i++) + { + nextCluster = GlyphClusters[i]; + + if (currentCluster > cluster) + { + break; + } + + var length = nextCluster - currentCluster; + + characterLength += length; + + currentCluster = nextCluster; + } + } + else + { + for (int i = GlyphClusters.Count - 1; i >= 0; i--) + { + nextCluster = GlyphClusters[i]; + + if (currentCluster > cluster) + { + break; + } + + var length = nextCluster - currentCluster; + + characterLength += length; + + currentCluster = nextCluster; + } + } + + if (Characters != null) + { + clusterLength = Characters.Count - characterLength; + } + else + { + clusterLength = 1; + } } - return new CharacterHit(_offsetToFirstCharacter + cluster, trailingLength); + return new CharacterHit(cluster, clusterLength); } /// @@ -618,22 +644,25 @@ namespace Avalonia.Media private GlyphRunMetrics CreateGlyphRunMetrics() { - var firstCluster = 0; - var lastCluster = Characters.Length - 1; + int firstCluster = 0, lastCluster = 0; - if (!IsLeftToRight) + if (_glyphClusters != null && _glyphClusters.Count > 0) { - var cluster = firstCluster; - firstCluster = lastCluster; - lastCluster = cluster; + firstCluster = _glyphClusters[0]; + lastCluster = _glyphClusters[_glyphClusters.Count - 1]; } - - if (GlyphClusters != null && GlyphClusters.Count > 0) + else { - firstCluster = GlyphClusters[0]; - lastCluster = GlyphClusters[GlyphClusters.Count - 1]; + if (Characters != null && Characters.Count > 0) + { + firstCluster = 0; + lastCluster = Characters.Count - 1; + } + } - _offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster); + if (!IsLeftToRight) + { + (lastCluster, firstCluster) = (firstCluster, lastCluster); } var isReversed = firstCluster > lastCluster; @@ -666,12 +695,19 @@ namespace Avalonia.Media } } - return new GlyphRunMetrics(width, widthIncludingTrailingWhitespace, trailingWhitespaceLength, newLineLength, - height); + return new GlyphRunMetrics( + width, + widthIncludingTrailingWhitespace, + height, + trailingWhitespaceLength, + newLineLength, + firstCluster, + lastCluster + ); } private int GetTrailingWhitespaceLength(bool isReversed, out int newLineLength, out int glyphCount) - { + { if (isReversed) { return GetTralingWhitespaceLengthRightToLeft(out newLineLength, out glyphCount); @@ -681,66 +717,82 @@ namespace Avalonia.Media newLineLength = 0; var trailingWhitespaceLength = 0; - if (GlyphClusters == null) + if (Characters != null) { - for (var i = _characters.Length - 1; i >= 0;) + if (GlyphClusters == null) { - var codepoint = Codepoint.ReadAt(_characters, i, out var count); - - if (!codepoint.IsWhiteSpace) + for (var i = _characters.Count - 1; i >= 0;) { - break; - } + var codepoint = Codepoint.ReadAt(_characters, i, out var count); - if (codepoint.IsBreakChar) - { - newLineLength++; - } + if (!codepoint.IsWhiteSpace) + { + break; + } - trailingWhitespaceLength++; + if (codepoint.IsBreakChar) + { + newLineLength++; + } + + trailingWhitespaceLength++; - i -= count; - glyphCount++; + i -= count; + glyphCount++; + } } - } - else - { - for (var i = GlyphClusters.Count - 1; i >= 0; i--) + else { - var currentCluster = GlyphClusters[i]; - var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset); - var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _); - - if (!codepoint.IsWhiteSpace) + if (Characters.Count > 0) { - break; - } + var characterIndex = Characters.Count - 1; - var clusterLength = 1; + for (var i = GlyphClusters.Count - 1; i >= 0; i--) + { + var currentCluster = GlyphClusters[i]; + var codepoint = Codepoint.ReadAt(_characters, characterIndex, out var characterLength); - while(i - 1 >= 0) - { - var nextCluster = GlyphClusters[i - 1]; + characterIndex -= characterLength; - if(currentCluster == nextCluster) - { - clusterLength++; - i--; + if (!codepoint.IsWhiteSpace) + { + break; + } - continue; - } + var clusterLength = 1; - break; - } + while (i - 1 >= 0) + { + var nextCluster = GlyphClusters[i - 1]; - if (codepoint.IsBreakChar) - { - newLineLength += clusterLength; - } + if (currentCluster == nextCluster) + { + clusterLength++; + i--; + + if(characterIndex >= 0) + { + codepoint = Codepoint.ReadAt(_characters, characterIndex, out characterLength); + + characterIndex -= characterLength; + } + + continue; + } + + break; + } + + if (codepoint.IsBreakChar) + { + newLineLength += clusterLength; + } - trailingWhitespaceLength += clusterLength; - - glyphCount++; + trailingWhitespaceLength += clusterLength; + + glyphCount++; + } + } } } @@ -753,67 +805,73 @@ namespace Avalonia.Media newLineLength = 0; var trailingWhitespaceLength = 0; - if (GlyphClusters == null) + if (Characters != null) { - for (var i = 0; i < Characters.Length;) + if (GlyphClusters == null) { - var codepoint = Codepoint.ReadAt(_characters, i, out var count); - - if (!codepoint.IsWhiteSpace) + for (var i = 0; i < Characters.Count;) { - break; - } + var codepoint = Codepoint.ReadAt(_characters, i, out var count); - if (codepoint.IsBreakChar) - { - newLineLength++; - } + if (!codepoint.IsWhiteSpace) + { + break; + } - trailingWhitespaceLength++; + if (codepoint.IsBreakChar) + { + newLineLength++; + } - i += count; - glyphCount++; + trailingWhitespaceLength++; + + i += count; + glyphCount++; + } } - } - else - { - for (var i = 0; i < GlyphClusters.Count; i++) + else { - var currentCluster = GlyphClusters[i]; - var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset); - var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _); + var characterIndex = 0; - if (!codepoint.IsWhiteSpace) + for (var i = 0; i < GlyphClusters.Count; i++) { - break; - } + var currentCluster = GlyphClusters[i]; + var codepoint = Codepoint.ReadAt(_characters, characterIndex, out var characterLength); - var clusterLength = 1; + characterIndex += characterLength; - var j = i; + if (!codepoint.IsWhiteSpace) + { + break; + } - while (j - 1 >= 0) - { - var nextCluster = GlyphClusters[--j]; + var clusterLength = 1; - if (currentCluster == nextCluster) + var j = i; + + while (j - 1 >= 0) { - clusterLength++; + var nextCluster = GlyphClusters[--j]; - continue; - } + if (currentCluster == nextCluster) + { + clusterLength++; - break; - } + continue; + } - if (codepoint.IsBreakChar) - { - newLineLength += clusterLength; - } + break; + } + + if (codepoint.IsBreakChar) + { + newLineLength += clusterLength; + } - trailingWhitespaceLength += clusterLength; + trailingWhitespaceLength += clusterLength; - glyphCount += clusterLength; + glyphCount += clusterLength; + } } } @@ -855,14 +913,9 @@ namespace Avalonia.Media throw new InvalidOperationException(); } - _glyphRunImpl = CreateGlyphRunImpl(); - } - - private IGlyphRunImpl CreateGlyphRunImpl() - { var platformRenderInterface = AvaloniaLocator.Current.GetRequiredService(); - return platformRenderInterface.CreateGlyphRun(GlyphTypeface, FontRenderingEmSize, GlyphIndices, GlyphAdvances, GlyphOffsets); + _glyphRunImpl = platformRenderInterface.CreateGlyphRun(GlyphTypeface, FontRenderingEmSize, GlyphIndices, GlyphAdvances, GlyphOffsets); } void IDisposable.Dispose() diff --git a/src/Avalonia.Base/Media/GlyphRunMetrics.cs b/src/Avalonia.Base/Media/GlyphRunMetrics.cs index a8698a7d82..983f029c7a 100644 --- a/src/Avalonia.Base/Media/GlyphRunMetrics.cs +++ b/src/Avalonia.Base/Media/GlyphRunMetrics.cs @@ -2,24 +2,30 @@ { public readonly struct GlyphRunMetrics { - public GlyphRunMetrics(double width, double widthIncludingTrailingWhitespace, int trailingWhitespaceLength, - int newlineLength, double height) + public GlyphRunMetrics(double width, double widthIncludingTrailingWhitespace, double height, + int trailingWhitespaceLength, int newLineLength, int firstCluster, int lastCluster) { Width = width; WidthIncludingTrailingWhitespace = widthIncludingTrailingWhitespace; - TrailingWhitespaceLength = trailingWhitespaceLength; - NewlineLength = newlineLength; Height = height; + TrailingWhitespaceLength = trailingWhitespaceLength; + NewLineLength= newLineLength; + FirstCluster = firstCluster; + LastCluster = lastCluster; } public double Width { get; } public double WidthIncludingTrailingWhitespace { get; } + public double Height { get; } + public int TrailingWhitespaceLength { get; } - public int NewlineLength { get; } + public int NewLineLength { get; } - public double Height { get; } + public int FirstCluster { get; } + + public int LastCluster { get; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/CharacterBufferRange.cs b/src/Avalonia.Base/Media/TextFormatting/CharacterBufferRange.cs new file mode 100644 index 0000000000..d76f212f26 --- /dev/null +++ b/src/Avalonia.Base/Media/TextFormatting/CharacterBufferRange.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Avalonia.Utilities; + +namespace Avalonia.Media.TextFormatting +{ + public readonly struct CharacterBufferRange : IReadOnlyList + { + /// + /// Getting an empty character string + /// + public static CharacterBufferRange Empty => new CharacterBufferRange(); + + /// + /// Construct from character array + /// + /// character array + /// character buffer offset to the first character + /// character length + public CharacterBufferRange( + char[] characterArray, + int offsetToFirstChar, + int characterLength + ) + : this( + new CharacterBufferReference(characterArray, offsetToFirstChar), + characterLength + ) + { } + + /// + /// Construct from string + /// + /// character string + /// character buffer offset to the first character + /// character length + public CharacterBufferRange( + string characterString, + int offsetToFirstChar, + int characterLength + ) + : this( + new CharacterBufferReference(characterString, offsetToFirstChar), + characterLength + ) + { } + + /// + /// Construct a from + /// + /// character buffer reference + /// number of characters + public CharacterBufferRange( + CharacterBufferReference characterBufferReference, + int characterLength + ) + { + if (characterLength < 0) + { + throw new ArgumentOutOfRangeException("characterLength", "ParameterCannotBeNegative"); + } + + int maxLength = characterBufferReference.CharacterBuffer.Length > 0 ? + characterBufferReference.CharacterBuffer.Length - characterBufferReference.OffsetToFirstChar : + 0; + + if (characterLength > maxLength) + { + throw new ArgumentOutOfRangeException("characterLength", $"ParameterCannotBeGreaterThan {maxLength}"); + } + + CharacterBufferReference = characterBufferReference; + Length = characterLength; + } + + /// + /// Construct a from part of another + /// + internal CharacterBufferRange( + CharacterBufferRange characterBufferRange, + int offsetToFirstChar, + int characterLength + ) : + this( + characterBufferRange.CharacterBuffer, + characterBufferRange.OffsetToFirstChar + offsetToFirstChar, + characterLength + ) + { } + + + /// + /// Construct a from string + /// + internal CharacterBufferRange( + string charString + ) : + this( + charString, + 0, + charString.Length + ) + { } + + + /// + /// Construct from memory buffer + /// + internal CharacterBufferRange( + ReadOnlyMemory charBuffer, + int offsetToFirstChar, + int characterLength + ) : + this( + new CharacterBufferReference(charBuffer, offsetToFirstChar), + characterLength + ) + { } + + + /// + /// Construct a by extracting text info from a text run + /// + internal CharacterBufferRange(TextRun textRun) + { + CharacterBufferReference = textRun.CharacterBufferReference; + Length = textRun.Length; + } + + public char this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { +#if DEBUG + if (index.CompareTo(0) < 0 || index.CompareTo(Length) > 0) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } +#endif + return Span[index]; + } + } + + /// + /// Gets a reference to the character buffer + /// + public CharacterBufferReference CharacterBufferReference { get; } + + /// + /// Gets the number of characters in text source character store + /// + public int Length { get; } + + /// + /// Gets a span from the character buffer range + /// + public ReadOnlySpan Span => + CharacterBufferReference.CharacterBuffer.Span.Slice(CharacterBufferReference.OffsetToFirstChar, Length); + + /// + /// Gets the character memory buffer + /// + internal ReadOnlyMemory CharacterBuffer + { + get { return CharacterBufferReference.CharacterBuffer; } + } + + /// + /// Gets the character offset relative to the beginning of buffer to + /// the first character of the run + /// + internal int OffsetToFirstChar + { + get { return CharacterBufferReference.OffsetToFirstChar; } + } + + /// + /// Indicate whether the character buffer range is empty + /// + internal bool IsEmpty + { + get { return CharacterBufferReference.CharacterBuffer.Length == 0 || Length <= 0; } + } + + internal CharacterBufferRange Take(int length) + { + if (IsEmpty) + { + return this; + } + + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new CharacterBufferRange(CharacterBufferReference, length); + } + + internal CharacterBufferRange Skip(int length) + { + if (IsEmpty) + { + return this; + } + + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + if (length == Length) + { + return new CharacterBufferRange(new CharacterBufferReference(), 0); + } + + var characterBufferReference = new CharacterBufferReference( + CharacterBufferReference.CharacterBuffer, + CharacterBufferReference.OffsetToFirstChar + length); + + return new CharacterBufferRange(characterBufferReference, Length - length); + } + + /// + /// Compute hash code + /// + public override int GetHashCode() + { + return CharacterBufferReference.GetHashCode() ^ Length; + } + + /// + /// Test equality with the input object + /// + /// The object to test + public override bool Equals(object? obj) + { + if (obj is CharacterBufferRange range) + { + return Equals(range); + } + + return false; + } + + /// + /// Test equality with the input CharacterBufferRange + /// + /// The CharacterBufferRange value to test + public bool Equals(CharacterBufferRange value) + { + return CharacterBufferReference.Equals(value.CharacterBufferReference) + && Length == value.Length; + } + + /// + /// Compare two CharacterBufferRange for equality + /// + /// left operand + /// right operand + /// whether or not two operands are equal + public static bool operator ==(CharacterBufferRange left, CharacterBufferRange right) + { + return left.Equals(right); + } + + /// + /// Compare two CharacterBufferRange for inequality + /// + /// left operand + /// right operand + /// whether or not two operands are equal + public static bool operator !=(CharacterBufferRange left, CharacterBufferRange right) + { + return !(left == right); + } + + int IReadOnlyCollection.Count => Length; + + public IEnumerator GetEnumerator() + { + return new ImmutableReadOnlyListStructEnumerator(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/src/Avalonia.Base/Media/TextFormatting/CharacterBufferReference.cs b/src/Avalonia.Base/Media/TextFormatting/CharacterBufferReference.cs new file mode 100644 index 0000000000..672fcf3377 --- /dev/null +++ b/src/Avalonia.Base/Media/TextFormatting/CharacterBufferReference.cs @@ -0,0 +1,115 @@ +using System; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Text character buffer reference + /// + public readonly struct CharacterBufferReference : IEquatable + { + /// + /// Construct character buffer reference from character array + /// + /// character array + /// character buffer offset to the first character + public CharacterBufferReference(char[] characterArray, int offsetToFirstChar = 0) + : this(characterArray.AsMemory(), offsetToFirstChar) + { } + + /// + /// Construct character buffer reference from string + /// + /// character string + /// character buffer offset to the first character + public CharacterBufferReference(string characterString, int offsetToFirstChar = 0) + : this(characterString.AsMemory(), offsetToFirstChar) + { } + + /// + /// Construct character buffer reference from memory buffer + /// + internal CharacterBufferReference(ReadOnlyMemory characterBuffer, int offsetToFirstChar = 0) + { + if (offsetToFirstChar < 0) + { + throw new ArgumentOutOfRangeException("offsetToFirstChar", "ParameterCannotBeNegative"); + } + + // maximum offset is one less than CharacterBuffer.Count, except that zero is always a valid offset + // even in the case of an empty or null character buffer + var maxOffset = characterBuffer.Length == 0 ? 0 : Math.Max(0, characterBuffer.Length - 1); + if (offsetToFirstChar > maxOffset) + { + throw new ArgumentOutOfRangeException("offsetToFirstChar", $"ParameterCannotBeGreaterThan, {maxOffset}"); + } + + CharacterBuffer = characterBuffer; + OffsetToFirstChar = offsetToFirstChar; + } + + /// + /// Gets the character memory buffer + /// + public ReadOnlyMemory CharacterBuffer { get; } + + /// + /// Gets the character offset relative to the beginning of buffer to + /// the first character of the run + /// + public int OffsetToFirstChar { get; } + + /// + /// Compute hash code + /// + public override int GetHashCode() + { + return CharacterBuffer.IsEmpty ? 0 : CharacterBuffer.GetHashCode(); + } + + /// + /// Test equality with the input object + /// + /// The object to test. + public override bool Equals(object? obj) + { + if (obj is CharacterBufferReference reference) + { + return Equals(reference); + } + + return false; + } + + /// + /// Test equality with the input CharacterBufferReference + /// + /// The characterBufferReference value to test + public bool Equals(CharacterBufferReference value) + { + return CharacterBuffer.Equals(value.CharacterBuffer); + } + + /// + /// Compare two CharacterBufferReference for equality + /// + /// left operand + /// right operand + /// whether or not two operands are equal + public static bool operator ==(CharacterBufferReference left, CharacterBufferReference right) + { + return left.Equals(right); + } + + /// + /// Compare two CharacterBufferReference for inequality + /// + /// left operand + /// right operand + /// whether or not two operands are equal + public static bool operator !=(CharacterBufferReference left, CharacterBufferReference right) + { + return !(left == right); + } + } +} + diff --git a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs index fb8e699d8e..e745a873a2 100644 --- a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs +++ b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs @@ -7,14 +7,15 @@ namespace Avalonia.Media.TextFormatting { internal readonly struct FormattedTextSource : ITextSource { - private readonly ReadOnlySlice _text; + private readonly CharacterBufferRange _text; + private readonly int length; private readonly TextRunProperties _defaultProperties; private readonly IReadOnlyList>? _textModifier; - public FormattedTextSource(ReadOnlySlice text, TextRunProperties defaultProperties, + public FormattedTextSource(string text, TextRunProperties defaultProperties, IReadOnlyList>? textModifier) { - _text = text; + _text = new CharacterBufferRange(text); _defaultProperties = defaultProperties; _textModifier = textModifier; } @@ -35,7 +36,7 @@ namespace Avalonia.Media.TextFormatting var textStyleRun = CreateTextStyleRun(runText, textSourceIndex, _defaultProperties, _textModifier); - return new TextCharacters(runText.Take(textStyleRun.Length), textStyleRun.Value); + return new TextCharacters(runText.Take(textStyleRun.Length).CharacterBufferReference, textStyleRun.Length, textStyleRun.Value); } /// @@ -48,7 +49,7 @@ namespace Avalonia.Media.TextFormatting /// /// The created text style run. /// - private static ValueSpan CreateTextStyleRun(ReadOnlySlice text, int firstTextSourceIndex, + private static ValueSpan CreateTextStyleRun(CharacterBufferRange text, int firstTextSourceIndex, TextRunProperties defaultProperties, IReadOnlyList>? textModifier) { if (textModifier == null || textModifier.Count == 0) @@ -122,7 +123,7 @@ namespace Avalonia.Media.TextFormatting return new ValueSpan(firstTextSourceIndex, length, currentProperties); } - private static int CoerceLength(ReadOnlySlice text, int length) + private static int CoerceLength(CharacterBufferRange text, int length) { var finalLength = 0; diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs index a49e4ef13b..3c3a46c209 100644 --- a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs +++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs @@ -46,28 +46,30 @@ namespace Avalonia.Media.TextFormatting var breakOportunities = new Queue(); + var currentPosition = textLine.FirstTextSourceIndex; + foreach (var textRun in lineImpl.TextRuns) { - var text = textRun.Text; + var text = new CharacterBufferRange(textRun); if (text.IsEmpty) { continue; } - var start = text.Start; - var lineBreakEnumerator = new LineBreakEnumerator(text); while (lineBreakEnumerator.MoveNext()) { var currentBreak = lineBreakEnumerator.Current; - if (!currentBreak.Required && currentBreak.PositionWrap != text.Length) + if (!currentBreak.Required && currentBreak.PositionWrap != textRun.Length) { - breakOportunities.Enqueue(start + currentBreak.PositionMeasure); + breakOportunities.Enqueue(currentPosition + currentBreak.PositionMeasure); } } + + currentPosition += textRun.Length; } if (breakOportunities.Count == 0) @@ -78,9 +80,11 @@ namespace Avalonia.Media.TextFormatting var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.WidthIncludingTrailingWhitespace); var spacing = remainingSpace / breakOportunities.Count; + currentPosition = textLine.FirstTextSourceIndex; + foreach (var textRun in lineImpl.TextRuns) { - var text = textRun.Text; + var text = textRun.CharacterBufferReference.CharacterBuffer; if (text.IsEmpty) { @@ -91,7 +95,6 @@ namespace Avalonia.Media.TextFormatting { var glyphRun = shapedText.GlyphRun; var shapedBuffer = shapedText.ShapedBuffer; - var currentPosition = text.Start; while (breakOportunities.Count > 0) { @@ -110,6 +113,8 @@ namespace Avalonia.Media.TextFormatting glyphRun.GlyphAdvances = shapedBuffer.GlyphAdvances; } + + currentPosition += textRun.Length; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapeableTextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/ShapeableTextCharacters.cs index b31a6f4d13..0e8d6e3e4a 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapeableTextCharacters.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapeableTextCharacters.cs @@ -7,30 +7,26 @@ namespace Avalonia.Media.TextFormatting /// public sealed class ShapeableTextCharacters : TextRun { - public ShapeableTextCharacters(ReadOnlySlice text, TextRunProperties properties, sbyte biDiLevel) + public ShapeableTextCharacters(CharacterBufferReference characterBufferReference, int length, + TextRunProperties properties, sbyte biDiLevel) { - TextSourceLength = text.Length; - Text = text; + CharacterBufferReference = characterBufferReference; + Length = length; Properties = properties; BidiLevel = biDiLevel; } - public override int TextSourceLength { get; } + public override int Length { get; } - public override ReadOnlySlice Text { get; } + public override CharacterBufferReference CharacterBufferReference { get; } public override TextRunProperties Properties { get; } - + public sbyte BidiLevel { get; } public bool CanShapeTogether(ShapeableTextCharacters shapeableTextCharacters) { - if (!Text.Buffer.Equals(shapeableTextCharacters.Text.Buffer)) - { - return false; - } - - if (Text.Start + Text.Length != shapeableTextCharacters.Text.Start) + if (!CharacterBufferReference.Equals(shapeableTextCharacters.CharacterBufferReference)) { return false; } diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs index 85924a3d32..644c0ecbe1 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs @@ -7,16 +7,16 @@ namespace Avalonia.Media.TextFormatting public sealed class ShapedBuffer : IList { private static readonly IComparer s_clusterComparer = new CompareClusters(); - - public ShapedBuffer(ReadOnlySlice text, int length, IGlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) - : this(text, new GlyphInfo[length], glyphTypeface, fontRenderingEmSize, bidiLevel) + + public ShapedBuffer(CharacterBufferRange characterBufferRange, int bufferLength, IGlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) : + this(characterBufferRange, new GlyphInfo[bufferLength], glyphTypeface, fontRenderingEmSize, bidiLevel) { } - internal ShapedBuffer(ReadOnlySlice text, ArraySlice glyphInfos, IGlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) + internal ShapedBuffer(CharacterBufferRange characterBufferRange, ArraySlice glyphInfos, IGlyphTypeface glyphTypeface, double fontRenderingEmSize, sbyte bidiLevel) { - Text = text; + CharacterBufferRange = characterBufferRange; GlyphInfos = glyphInfos; GlyphTypeface = glyphTypeface; FontRenderingEmSize = fontRenderingEmSize; @@ -24,9 +24,7 @@ namespace Avalonia.Media.TextFormatting } internal ArraySlice GlyphInfos { get; } - - public ReadOnlySlice Text { get; } - + public int Length => GlyphInfos.Length; public IGlyphTypeface GlyphTypeface { get; } @@ -45,6 +43,8 @@ namespace Avalonia.Media.TextFormatting public IReadOnlyList GlyphOffsets => new GlyphOffsetList(GlyphInfos); + public CharacterBufferRange CharacterBufferRange { get; } + /// /// Finds a glyph index for given character index. /// @@ -105,16 +105,23 @@ namespace Avalonia.Media.TextFormatting /// The split result. internal SplitResult Split(int length) { - if (Text.Length == length) + if (CharacterBufferRange.Length == length) { return new SplitResult(this, null); } - var glyphCount = FindGlyphIndex(Text.Start + length); + var firstCluster = GlyphClusters[0]; + var lastCluster = GlyphClusters[GlyphClusters.Count - 1]; + + var start = firstCluster < lastCluster ? firstCluster : lastCluster; + + var glyphCount = FindGlyphIndex(start + length); - var first = new ShapedBuffer(Text.Take(length), GlyphInfos.Take(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel); + var first = new ShapedBuffer(CharacterBufferRange.Take(length), + GlyphInfos.Take(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel); - var second = new ShapedBuffer(Text.Skip(length), GlyphInfos.Skip(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel); + var second = new ShapedBuffer(CharacterBufferRange.Skip(length), + GlyphInfos.Skip(glyphCount), GlyphTypeface, FontRenderingEmSize, BidiLevel); return new SplitResult(first, second); } diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextCharacters.cs index 21101f462c..3035eb7b18 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextCharacters.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedTextCharacters.cs @@ -1,6 +1,5 @@ using System; using Avalonia.Media.TextFormatting.Unicode; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { @@ -14,10 +13,10 @@ namespace Avalonia.Media.TextFormatting public ShapedTextCharacters(ShapedBuffer shapedBuffer, TextRunProperties properties) { ShapedBuffer = shapedBuffer; - Text = shapedBuffer.Text; + CharacterBufferReference = shapedBuffer.CharacterBufferRange.CharacterBufferReference; + Length = shapedBuffer.CharacterBufferRange.Length; Properties = properties; - TextSourceLength = Text.Length; - TextMetrics = new TextMetrics(properties.Typeface, properties.FontRenderingEmSize); + TextMetrics = new TextMetrics(properties.Typeface.GlyphTypeface, properties.FontRenderingEmSize); } public bool IsReversed { get; private set; } @@ -27,13 +26,13 @@ namespace Avalonia.Media.TextFormatting public ShapedBuffer ShapedBuffer { get; } /// - public override ReadOnlySlice Text { get; } + public override CharacterBufferReference CharacterBufferReference { get; } /// public override TextRunProperties Properties { get; } /// - public override int TextSourceLength { get; } + public override int Length { get; } public TextMetrics TextMetrics { get; } @@ -176,12 +175,12 @@ namespace Avalonia.Media.TextFormatting #if DEBUG - if (first.Text.Length != length) + if (first.Length != length) { throw new InvalidOperationException("Split length mismatch."); } - - #endif + +#endif var second = new ShapedTextCharacters(splitBuffer.Second!, Properties); @@ -193,7 +192,7 @@ namespace Avalonia.Media.TextFormatting return new GlyphRun( ShapedBuffer.GlyphTypeface, ShapedBuffer.FontRenderingEmSize, - Text, + new CharacterBufferRange(CharacterBufferReference, Length), ShapedBuffer.GlyphIndices, ShapedBuffer.GlyphAdvances, ShapedBuffer.GlyphOffsets, diff --git a/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs b/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs index 02c7174499..03b93cfaf0 100644 --- a/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs +++ b/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs @@ -1,6 +1,6 @@ namespace Avalonia.Media.TextFormatting { - internal readonly struct SplitResult + public readonly struct SplitResult { public SplitResult(T first, T? second) { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs index bcfa35ae30..0be753bd04 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using Avalonia.Media.TextFormatting.Unicode; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { @@ -10,26 +9,83 @@ namespace Avalonia.Media.TextFormatting /// public class TextCharacters : TextRun { - public TextCharacters(ReadOnlySlice text, TextRunProperties properties) - { - TextSourceLength = text.Length; - Text = text; - Properties = properties; - } + /// + /// Construct a run of text content from character array + /// + public TextCharacters( + char[] characterArray, + int offsetToFirstChar, + int length, + TextRunProperties textRunProperties + ) : + this( + new CharacterBufferReference(characterArray, offsetToFirstChar), + length, + textRunProperties + ) + { } + - public TextCharacters(ReadOnlySlice text, int offsetToFirstCharacter, int length, - TextRunProperties properties) + /// + /// Construct a run for text content from string + /// + public TextCharacters( + string characterString, + TextRunProperties textRunProperties + ) : + this( + characterString, + 0, // offsetToFirstChar + (characterString == null) ? 0 : characterString.Length, + textRunProperties + ) + { } + + /// + /// Construct a run for text content from string + /// + public TextCharacters( + string characterString, + int offsetToFirstChar, + int length, + TextRunProperties textRunProperties + ) : + this( + new CharacterBufferReference(characterString, offsetToFirstChar), + length, + textRunProperties + ) + { } + + /// + /// Internal constructor of TextContent + /// + public TextCharacters( + CharacterBufferReference characterBufferReference, + int length, + TextRunProperties textRunProperties + ) { - Text = text.Skip(offsetToFirstCharacter).Take(length); - TextSourceLength = length; - Properties = properties; + if (length <= 0) + { + throw new ArgumentOutOfRangeException("length", "ParameterMustBeGreaterThanZero"); + } + + if (textRunProperties.FontRenderingEmSize <= 0) + { + throw new ArgumentOutOfRangeException("textRunProperties.FontRenderingEmSize", "ParameterMustBeGreaterThanZero"); + } + + CharacterBufferReference = characterBufferReference; + Length = length; + Properties = textRunProperties; } /// - public override int TextSourceLength { get; } + public override int Length { get; } /// - public override ReadOnlySlice Text { get; } + public override CharacterBufferReference CharacterBufferReference { get; } /// public override TextRunProperties Properties { get; } @@ -38,18 +94,17 @@ namespace Avalonia.Media.TextFormatting /// Gets a list of . /// /// The shapeable text characters. - internal IReadOnlyList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel, - ref TextRunProperties? previousProperties) + internal IReadOnlyList GetShapeableCharacters(CharacterBufferRange characterBufferRange, sbyte biDiLevel, ref TextRunProperties? previousProperties) { var shapeableCharacters = new List(2); - while (!runText.IsEmpty) + while (characterBufferRange.Length > 0) { - var shapeableRun = CreateShapeableRun(runText, Properties, biDiLevel, ref previousProperties); + var shapeableRun = CreateShapeableRun(characterBufferRange, Properties, biDiLevel, ref previousProperties); shapeableCharacters.Add(shapeableRun); - runText = runText.Skip(shapeableRun.Text.Length); + characterBufferRange = characterBufferRange.Skip(shapeableRun.Length); previousProperties = shapeableRun.Properties; } @@ -60,45 +115,45 @@ namespace Avalonia.Media.TextFormatting /// /// Creates a shapeable text run with unique properties. /// - /// The text to create text runs from. + /// The character buffer range to create text runs from. /// The default text run properties. /// The bidi level of the run. /// /// A list of shapeable text runs. - private static ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text, + private static ShapeableTextCharacters CreateShapeableRun(CharacterBufferRange characterBufferRange, TextRunProperties defaultProperties, sbyte biDiLevel, ref TextRunProperties? previousProperties) { var defaultTypeface = defaultProperties.Typeface; var currentTypeface = defaultTypeface; var previousTypeface = previousProperties?.Typeface; - if (TryGetShapeableLength(text, currentTypeface, null, out var count, out var script)) + if (TryGetShapeableLength(characterBufferRange, currentTypeface, null, out var count, out var script)) { if (script == Script.Common && previousTypeface is not null) { - if (TryGetShapeableLength(text, previousTypeface.Value, null, out var fallbackCount, out _)) + if (TryGetShapeableLength(characterBufferRange, previousTypeface.Value, null, out var fallbackCount, out _)) { - return new ShapeableTextCharacters(text.Take(fallbackCount), + return new ShapeableTextCharacters(characterBufferRange.CharacterBufferReference, fallbackCount, defaultProperties.WithTypeface(previousTypeface.Value), biDiLevel); } } - return new ShapeableTextCharacters(text.Take(count), defaultProperties.WithTypeface(currentTypeface), + return new ShapeableTextCharacters(characterBufferRange.CharacterBufferReference, count, defaultProperties.WithTypeface(currentTypeface), biDiLevel); } if (previousTypeface is not null) { - if (TryGetShapeableLength(text, previousTypeface.Value, defaultTypeface, out count, out _)) + if (TryGetShapeableLength(characterBufferRange, previousTypeface.Value, defaultTypeface, out count, out _)) { - return new ShapeableTextCharacters(text.Take(count), + return new ShapeableTextCharacters(characterBufferRange.CharacterBufferReference, count, defaultProperties.WithTypeface(previousTypeface.Value), biDiLevel); } } var codepoint = Codepoint.ReplacementCodepoint; - var codepointEnumerator = new CodepointEnumerator(text.Skip(count)); + var codepointEnumerator = new CodepointEnumerator(characterBufferRange.Skip(count)); while (codepointEnumerator.MoveNext()) { @@ -118,10 +173,10 @@ namespace Avalonia.Media.TextFormatting defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo, out currentTypeface); - if (matchFound && TryGetShapeableLength(text, currentTypeface, defaultTypeface, out count, out _)) + if (matchFound && TryGetShapeableLength(characterBufferRange, currentTypeface, defaultTypeface, out count, out _)) { //Fallback found - return new ShapeableTextCharacters(text.Take(count), defaultProperties.WithTypeface(currentTypeface), + return new ShapeableTextCharacters(characterBufferRange.CharacterBufferReference, count, defaultProperties.WithTypeface(currentTypeface), biDiLevel); } @@ -130,7 +185,7 @@ namespace Avalonia.Media.TextFormatting var glyphTypeface = currentTypeface.GlyphTypeface; - var enumerator = new GraphemeEnumerator(text); + var enumerator = new GraphemeEnumerator(characterBufferRange); while (enumerator.MoveNext()) { @@ -144,20 +199,20 @@ namespace Avalonia.Media.TextFormatting count += grapheme.Text.Length; } - return new ShapeableTextCharacters(text.Take(count), defaultProperties, biDiLevel); + return new ShapeableTextCharacters(characterBufferRange.CharacterBufferReference, count, defaultProperties, biDiLevel); } /// /// Tries to get a shapeable length that is supported by the specified typeface. /// - /// The text. + /// The character buffer range to shape. /// The typeface that is used to find matching characters. /// /// The shapeable length. /// /// - protected static bool TryGetShapeableLength( - ReadOnlySlice text, + internal static bool TryGetShapeableLength( + CharacterBufferRange characterBufferRange, Typeface typeface, Typeface? defaultTypeface, out int length, @@ -166,7 +221,7 @@ namespace Avalonia.Media.TextFormatting length = 0; script = Script.Unknown; - if (text.Length == 0) + if (characterBufferRange.Length == 0) { return false; } @@ -174,7 +229,7 @@ namespace Avalonia.Media.TextFormatting var font = typeface.GlyphTypeface; var defaultFont = defaultTypeface?.GlyphTypeface; - var enumerator = new GraphemeEnumerator(text); + var enumerator = new GraphemeEnumerator(characterBufferRange); while (enumerator.MoveNext()) { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs b/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs index 5a2169630b..a1b8985b43 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs @@ -32,86 +32,88 @@ namespace Avalonia.Media.TextFormatting switch (currentRun) { case ShapedTextCharacters shapedRun: - { - currentWidth += shapedRun.Size.Width; - - if (currentWidth > availableWidth) { - if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) + currentWidth += shapedRun.Size.Width; + + if (currentWidth > availableWidth) { - if (isWordEllipsis && measuredLength < textLine.Length) + if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) { - var currentBreakPosition = 0; + if (isWordEllipsis && measuredLength < textLine.Length) + { + var currentBreakPosition = 0; - var lineBreaker = new LineBreakEnumerator(currentRun.Text); + var text = new CharacterBufferRange(currentRun.CharacterBufferReference, currentRun.Length); - while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) - { - var nextBreakPosition = lineBreaker.Current.PositionMeasure; + var lineBreaker = new LineBreakEnumerator(text); - if (nextBreakPosition == 0) + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) { - break; - } + var nextBreakPosition = lineBreaker.Current.PositionMeasure; - if (nextBreakPosition >= measuredLength) - { - break; + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition >= measuredLength) + { + break; + } + + currentBreakPosition = nextBreakPosition; } - currentBreakPosition = nextBreakPosition; + measuredLength = currentBreakPosition; } - - measuredLength = currentBreakPosition; } - } - collapsedLength += measuredLength; + collapsedLength += measuredLength; - var collapsedRuns = new List(textRuns.Count); + var collapsedRuns = new List(textRuns.Count); - if (collapsedLength > 0) - { - var splitResult = TextFormatterImpl.SplitDrawableRuns(textRuns, collapsedLength); + if (collapsedLength > 0) + { + var splitResult = TextFormatterImpl.SplitDrawableRuns(textRuns, collapsedLength); - collapsedRuns.AddRange(splitResult.First); - } + collapsedRuns.AddRange(splitResult.First); + } - collapsedRuns.Add(shapedSymbol); + collapsedRuns.Add(shapedSymbol); - return collapsedRuns; - } + return collapsedRuns; + } - availableWidth -= currentRun.Size.Width; + availableWidth -= currentRun.Size.Width; - - break; - } + + break; + } case { } drawableRun: - { - //The whole run needs to fit into available space - if (currentWidth + drawableRun.Size.Width > availableWidth) { - var collapsedRuns = new List(textRuns.Count); - - if (collapsedLength > 0) + //The whole run needs to fit into available space + if (currentWidth + drawableRun.Size.Width > availableWidth) { - var splitResult = TextFormatterImpl.SplitDrawableRuns(textRuns, collapsedLength); + var collapsedRuns = new List(textRuns.Count); - collapsedRuns.AddRange(splitResult.First); - } + if (collapsedLength > 0) + { + var splitResult = TextFormatterImpl.SplitDrawableRuns(textRuns, collapsedLength); + + collapsedRuns.AddRange(splitResult.First); + } + + collapsedRuns.Add(shapedSymbol); - collapsedRuns.Add(shapedSymbol); + return collapsedRuns; + } - return collapsedRuns; + break; } - - break; - } } - collapsedLength += currentRun.TextSourceLength; + collapsedLength += currentRun.Length; runIndex++; } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextEndOfLine.cs b/src/Avalonia.Base/Media/TextFormatting/TextEndOfLine.cs index 21e354a119..ffb879e721 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextEndOfLine.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextEndOfLine.cs @@ -7,9 +7,9 @@ { public TextEndOfLine(int textSourceLength = DefaultTextSourceLength) { - TextSourceLength = textSourceLength; + Length = textSourceLength; } - public override int TextSourceLength { get; } + public override int Length { get; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 7bad95c4a2..93eb4811b9 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -79,14 +79,14 @@ namespace Avalonia.Media.TextFormatting { var currentRun = textRuns[i]; - if (currentLength + currentRun.TextSourceLength < length) + if (currentLength + currentRun.Length < length) { - currentLength += currentRun.TextSourceLength; + currentLength += currentRun.Length; continue; } - var firstCount = currentRun.TextSourceLength >= 1 ? i + 1 : i; + var firstCount = currentRun.Length >= 1 ? i + 1 : i; var first = new List(firstCount); @@ -100,13 +100,13 @@ namespace Avalonia.Media.TextFormatting var secondCount = textRuns.Count - firstCount; - if (currentLength + currentRun.TextSourceLength == length) + if (currentLength + currentRun.Length == length) { var second = secondCount > 0 ? new List(secondCount) : null; if (second != null) { - var offset = currentRun.TextSourceLength >= 1 ? 1 : 0; + var offset = currentRun.Length >= 1 ? 1 : 0; for (var j = 0; j < secondCount; j++) { @@ -163,15 +163,17 @@ namespace Avalonia.Media.TextFormatting foreach (var textRun in textRuns) { - if (textRun.Text.IsEmpty) + if (textRun.CharacterBufferReference.CharacterBuffer.Length == 0) { - var text = new char[textRun.TextSourceLength]; + var characterBuffer = new CharacterBufferReference(new char[textRun.Length]); - biDiData.Append(text); + biDiData.Append(new CharacterBufferRange(characterBuffer, textRun.Length)); } else { - biDiData.Append(textRun.Text); + var text = new CharacterBufferRange(textRun.CharacterBufferReference, textRun.Length); + + biDiData.Append(text); } } @@ -207,10 +209,9 @@ namespace Avalonia.Media.TextFormatting case ShapeableTextCharacters shapeableRun: { var groupedRuns = new List(2) { shapeableRun }; - var text = currentRun.Text; - var start = currentRun.Text.Start; - var length = currentRun.Text.Length; - var bufferOffset = currentRun.Text.BufferOffset; + var characterBufferReference = currentRun.CharacterBufferReference; + var length = currentRun.Length; + var offsetToFirstCharacter = characterBufferReference.OffsetToFirstChar; while (index + 1 < processedRuns.Count) { @@ -223,19 +224,14 @@ namespace Avalonia.Media.TextFormatting { groupedRuns.Add(nextRun); - length += nextRun.Text.Length; - - if (start > nextRun.Text.Start) - { - start = nextRun.Text.Start; - } + length += nextRun.Length; - if (bufferOffset > nextRun.Text.BufferOffset) + if (offsetToFirstCharacter > nextRun.CharacterBufferReference.OffsetToFirstChar) { - bufferOffset = nextRun.Text.BufferOffset; + offsetToFirstCharacter = nextRun.CharacterBufferReference.OffsetToFirstChar; } - text = new ReadOnlySlice(text.Buffer, start, length, bufferOffset); + characterBufferReference = new CharacterBufferReference(characterBufferReference.CharacterBuffer, offsetToFirstCharacter); index++; @@ -252,7 +248,7 @@ namespace Avalonia.Media.TextFormatting shapeableRun.BidiLevel, currentRun.Properties.CultureInfo, paragraphProperties.DefaultIncrementalTab, paragraphProperties.LetterSpacing); - drawableTextRuns.AddRange(ShapeTogether(groupedRuns, text, shaperOptions)); + drawableTextRuns.AddRange(ShapeTogether(groupedRuns, characterBufferReference, length, shaperOptions)); break; } @@ -263,17 +259,17 @@ namespace Avalonia.Media.TextFormatting } private static IReadOnlyList ShapeTogether( - IReadOnlyList textRuns, ReadOnlySlice text, TextShaperOptions options) + IReadOnlyList textRuns, CharacterBufferReference text, int length, TextShaperOptions options) { var shapedRuns = new List(textRuns.Count); - var shapedBuffer = TextShaper.Current.ShapeText(text, options); + var shapedBuffer = TextShaper.Current.ShapeText(text, length, options); for (var i = 0; i < textRuns.Count; i++) { var currentRun = textRuns[i]; - var splitResult = shapedBuffer.Split(currentRun.Text.Length); + var splitResult = shapedBuffer.Split(currentRun.Length); shapedRuns.Add(new ShapedTextCharacters(splitResult.First, currentRun.Properties)); @@ -301,7 +297,7 @@ namespace Avalonia.Media.TextFormatting TextRunProperties? previousProperties = null; TextCharacters? currentRun = null; - var runText = ReadOnlySlice.Empty; + CharacterBufferRange runText = default; for (var i = 0; i < textCharacters.Count; i++) { @@ -314,12 +310,12 @@ namespace Avalonia.Media.TextFormatting yield return new[] { drawableRun }; - levelIndex += drawableRun.TextSourceLength; + levelIndex += drawableRun.Length; continue; } - runText = currentRun.Text; + runText = new CharacterBufferRange(currentRun.CharacterBufferReference, currentRun.Length); for (; j < runText.Length;) { @@ -401,7 +397,7 @@ namespace Avalonia.Media.TextFormatting { endOfLine = textEndOfLine; - textSourceLength += textEndOfLine.TextSourceLength; + textSourceLength += textEndOfLine.Length; textRuns.Add(textRun); @@ -414,7 +410,7 @@ namespace Avalonia.Media.TextFormatting { if (TryGetLineBreak(textCharacters, out var runLineBreak)) { - var splitResult = new TextCharacters(textCharacters.Text.Take(runLineBreak.PositionWrap), + var splitResult = new TextCharacters(textCharacters.CharacterBufferReference, runLineBreak.PositionWrap, textCharacters.Properties); textRuns.Add(splitResult); @@ -435,7 +431,7 @@ namespace Avalonia.Media.TextFormatting } } - textSourceLength += textRun.TextSourceLength; + textSourceLength += textRun.Length; } return textRuns; @@ -445,12 +441,14 @@ namespace Avalonia.Media.TextFormatting { lineBreak = default; - if (textRun.Text.IsEmpty) + if (textRun.CharacterBufferReference.CharacterBuffer.IsEmpty) { return false; } - var lineBreakEnumerator = new LineBreakEnumerator(textRun.Text); + var characterBufferRange = new CharacterBufferRange(textRun.CharacterBufferReference, textRun.Length); + + var lineBreakEnumerator = new LineBreakEnumerator(characterBufferRange); while (lineBreakEnumerator.MoveNext()) { @@ -461,7 +459,7 @@ namespace Avalonia.Media.TextFormatting lineBreak = lineBreakEnumerator.Current; - return lineBreak.PositionWrap >= textRun.Text.Length || true; + return lineBreak.PositionWrap >= textRun.Length || true; } return false; @@ -480,7 +478,7 @@ namespace Avalonia.Media.TextFormatting { if(shapedTextCharacters.ShapedBuffer.Length > 0) { - var firstCluster = shapedTextCharacters.ShapedBuffer.GlyphClusters[0]; + var firstCluster = shapedTextCharacters.ShapedBuffer.GlyphInfos[0].GlyphCluster; var lastCluster = firstCluster; for (var i = 0; i < shapedTextCharacters.ShapedBuffer.Length; i++) @@ -498,7 +496,7 @@ namespace Avalonia.Media.TextFormatting currentWidth += glyphInfo.GlyphAdvance; } - measuredLength += currentRun.TextSourceLength; + measuredLength += currentRun.Length; } break; @@ -511,7 +509,7 @@ namespace Avalonia.Media.TextFormatting goto found; } - measuredLength += currentRun.TextSourceLength; + measuredLength += currentRun.Length; currentWidth += currentRun.Size.Width; break; @@ -533,11 +531,11 @@ namespace Avalonia.Media.TextFormatting var flowDirection = paragraphProperties.FlowDirection; var properties = paragraphProperties.DefaultTextRunProperties; var glyphTypeface = properties.Typeface.GlyphTypeface; - var text = new ReadOnlySlice(s_empty, firstTextSourceIndex, 1); var glyph = glyphTypeface.GetGlyph(s_empty[0]); var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex) }; - var shapedBuffer = new ShapedBuffer(text, glyphInfos, glyphTypeface, properties.FontRenderingEmSize, + var characterBufferRange = new CharacterBufferRange(new CharacterBufferReference(s_empty), s_empty.Length); + var shapedBuffer = new ShapedBuffer(characterBufferRange, glyphInfos, glyphTypeface, properties.FontRenderingEmSize, (sbyte)flowDirection); var textRuns = new List { new ShapedTextCharacters(shapedBuffer, properties) }; @@ -579,7 +577,9 @@ namespace Avalonia.Media.TextFormatting { var currentRun = textRuns[index]; - var lineBreaker = new LineBreakEnumerator(currentRun.Text); + var runText = new CharacterBufferRange(currentRun.CharacterBufferReference, currentRun.Length); + + var lineBreaker = new LineBreakEnumerator(runText); var breakFound = false; @@ -612,7 +612,7 @@ namespace Avalonia.Media.TextFormatting //Find next possible wrap position (overflow) if (index < textRuns.Count - 1) { - if (lineBreaker.Current.PositionWrap != currentRun.Text.Length) + if (lineBreaker.Current.PositionWrap != currentRun.Length) { //We already found the next possible wrap position. breakFound = true; @@ -626,7 +626,7 @@ namespace Avalonia.Media.TextFormatting { currentPosition += lineBreaker.Current.PositionWrap; - if (lineBreaker.Current.PositionWrap != currentRun.Text.Length) + if (lineBreaker.Current.PositionWrap != currentRun.Length) { break; } @@ -640,7 +640,9 @@ namespace Avalonia.Media.TextFormatting currentRun = textRuns[index]; - lineBreaker = new LineBreakEnumerator(currentRun.Text); + runText = new CharacterBufferRange(currentRun.CharacterBufferReference, currentRun.Length); + + lineBreaker = new LineBreakEnumerator(runText); } } else @@ -669,7 +671,7 @@ namespace Avalonia.Media.TextFormatting if (!breakFound) { - currentLength += currentRun.TextSourceLength; + currentLength += currentRun.Length; continue; } @@ -723,12 +725,12 @@ namespace Avalonia.Media.TextFormatting return false; } - if (Current.TextSourceLength == 0) + if (Current.Length == 0) { return false; } - _pos += Current.TextSourceLength; + _pos += Current.Length; return true; } @@ -754,7 +756,9 @@ namespace Avalonia.Media.TextFormatting var shaperOptions = new TextShaperOptions(glyphTypeface, fontRenderingEmSize, (sbyte)flowDirection, cultureInfo); - var shapedBuffer = textShaper.ShapeText(textRun.Text, shaperOptions); + var characterBuffer = textRun.CharacterBufferReference; + + var shapedBuffer = textShaper.ShapeText(characterBuffer, textRun.Length, shaperOptions); return new ShapedTextCharacters(shapedBuffer, textRun.Properties); } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index dc79e61333..f803001481 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs @@ -55,7 +55,7 @@ namespace Avalonia.Media.TextFormatting CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textDecorations, flowDirection, lineHeight, letterSpacing); - _textSource = new FormattedTextSource(text.AsMemory(), _paragraphProperties.DefaultTextRunProperties, textStyleOverrides); + _textSource = new FormattedTextSource(text ?? "", _paragraphProperties.DefaultTextRunProperties, textStyleOverrides); _textTrimming = textTrimming ?? TextTrimming.None; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs index 5a14eda245..2752af8f0c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { @@ -19,7 +18,7 @@ namespace Avalonia.Media.TextFormatting /// width in which collapsing is constrained to /// text run properties of ellipsis symbol public TextLeadingPrefixCharacterEllipsis( - ReadOnlySlice ellipsis, + string ellipsis, int prefixLength, double width, TextRunProperties textRunProperties) @@ -129,7 +128,7 @@ namespace Avalonia.Media.TextFormatting if (suffixCount > 0) { var splitSuffix = - endShapedRun.Split(run.TextSourceLength - suffixCount); + endShapedRun.Split(run.Length - suffixCount); collapsedRuns.Add(splitSuffix.Second!); } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 96f88d1f44..3241dfd12b 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -56,7 +56,7 @@ namespace Avalonia.Media.TextFormatting public override double Height => _textLineMetrics.Height; /// - public override int NewLineLength => _textLineMetrics.NewLineLength; + public override int NewLineLength => _textLineMetrics.NewlineLength; /// public override double OverhangAfter => 0; @@ -180,7 +180,7 @@ namespace Avalonia.Media.TextFormatting { var lastRun = _textRuns[_textRuns.Count - 1]; - return GetRunCharacterHit(lastRun, FirstTextSourceIndex + Length - lastRun.TextSourceLength, lastRun.Size.Width); + return GetRunCharacterHit(lastRun, FirstTextSourceIndex + Length - lastRun.Length, lastRun.Size.Width); } // process hit that happens within the line @@ -195,18 +195,18 @@ namespace Avalonia.Media.TextFormatting if (currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight) { var rightToLeftIndex = i; - currentPosition += currentRun.TextSourceLength; + currentPosition += currentRun.Length; while (rightToLeftIndex + 1 <= _textRuns.Count - 1) { - var nextShaped = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters; + var nextShaped = _textRuns[++rightToLeftIndex] as ShapedTextCharacters; if (nextShaped == null || nextShaped.ShapedBuffer.IsLeftToRight) { break; } - currentPosition += nextShaped.TextSourceLength; + currentPosition += nextShaped.Length; rightToLeftIndex++; } @@ -223,27 +223,26 @@ namespace Avalonia.Media.TextFormatting if (currentDistance + currentRun.Size.Width <= distance) { currentDistance += currentRun.Size.Width; - currentPosition -= currentRun.TextSourceLength; + currentPosition -= currentRun.Length; continue; } - characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); - - break; + return GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); } } - if (currentDistance + currentRun.Size.Width < distance) + characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); + + if (i < _textRuns.Count - 1 && currentDistance + currentRun.Size.Width < distance) { currentDistance += currentRun.Size.Width; - currentPosition += currentRun.TextSourceLength; + + currentPosition += currentRun.Length; continue; } - characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); - break; } @@ -264,10 +263,10 @@ namespace Avalonia.Media.TextFormatting if (shapedRun.GlyphRun.IsLeftToRight) { - offset = Math.Max(0, currentPosition - shapedRun.Text.Start); + offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster); } - characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength); + characterHit = new CharacterHit(offset + characterHit.FirstCharacterIndex, characterHit.TrailingLength); break; } @@ -279,7 +278,7 @@ namespace Avalonia.Media.TextFormatting } else { - characterHit = new CharacterHit(currentPosition, run.TextSourceLength); + characterHit = new CharacterHit(currentPosition, run.Length); } break; } @@ -334,14 +333,14 @@ namespace Avalonia.Media.TextFormatting rightToLeftWidth -= currentRun.Size.Width; - if (currentPosition + currentRun.TextSourceLength >= characterIndex) + if (currentPosition + currentRun.Length >= characterIndex) { break; } - currentPosition += currentRun.TextSourceLength; + currentPosition += currentRun.Length; - remainingLength -= currentRun.TextSourceLength; + remainingLength -= currentRun.Length; i--; } @@ -350,7 +349,7 @@ namespace Avalonia.Media.TextFormatting } } - if (currentPosition + currentRun.TextSourceLength >= characterIndex && + if (currentPosition + currentRun.Length >= characterIndex && TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength, flowDirection, out var distance, out _)) { return Math.Max(0, currentDistance + distance); @@ -358,8 +357,8 @@ namespace Avalonia.Media.TextFormatting //No hit hit found so we add the full width currentDistance += currentRun.Size.Width; - currentPosition += currentRun.TextSourceLength; - remainingLength -= currentRun.TextSourceLength; + currentPosition += currentRun.Length; + remainingLength -= currentRun.Length; } } else @@ -383,8 +382,8 @@ namespace Avalonia.Media.TextFormatting //No hit hit found so we add the full width currentDistance -= currentRun.Size.Width; - currentPosition += currentRun.TextSourceLength; - remainingLength -= currentRun.TextSourceLength; + currentPosition += currentRun.Length; + remainingLength -= currentRun.Length; } } @@ -412,16 +411,16 @@ namespace Avalonia.Media.TextFormatting { currentGlyphRun = shapedTextCharacters.GlyphRun; - if (currentPosition + remainingLength <= currentPosition + currentRun.Text.Length) + if (currentPosition + remainingLength <= currentPosition + currentRun.Length) { - characterHit = new CharacterHit(currentRun.Text.Start + remainingLength); + characterHit = new CharacterHit(currentPosition + remainingLength); distance = currentGlyphRun.GetDistanceFromCharacterHit(characterHit); return true; } - if (currentPosition + remainingLength == currentPosition + currentRun.Text.Length && isTrailingHit) + if (currentPosition + remainingLength == currentPosition + currentRun.Length && isTrailingHit) { if (currentGlyphRun.IsLeftToRight || flowDirection == FlowDirection.RightToLeft) { @@ -440,7 +439,7 @@ namespace Avalonia.Media.TextFormatting return true; } - if (characterIndex == currentPosition + currentRun.TextSourceLength) + if (characterIndex == currentPosition + currentRun.Length) { distance = currentRun.Size.Width; @@ -479,17 +478,22 @@ namespace Avalonia.Media.TextFormatting { case ShapedTextCharacters shapedRun: { - characterHit = shapedRun.GlyphRun.GetNextCaretCharacterHit(characterHit); + nextCharacterHit = shapedRun.GlyphRun.GetNextCaretCharacterHit(characterHit); break; } default: { - characterHit = new CharacterHit(currentPosition + currentRun.TextSourceLength); + nextCharacterHit = new CharacterHit(currentPosition + currentRun.Length); break; } } - return characterHit; + if (characterHit.FirstCharacterIndex + characterHit.TrailingLength == nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength) + { + return characterHit; + } + + return nextCharacterHit; } /// @@ -542,200 +546,182 @@ namespace Avalonia.Media.TextFormatting var characterLength = 0; var endX = startX; - var currentShapedRun = currentRun as ShapedTextCharacters; - TextRunBounds currentRunBounds; double combinedWidth; - if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex) - { - startX += currentRun.Size.Width; - - currentPosition += currentRun.TextSourceLength; - - continue; - } - - if (currentShapedRun != null && !currentShapedRun.ShapedBuffer.IsLeftToRight) + if (currentRun is ShapedTextCharacters currentShapedRun) { - var rightToLeftIndex = index; - var rightToLeftWidth = currentShapedRun.Size.Width; + var firstCluster = currentShapedRun.GlyphRun.Metrics.FirstCluster; - while (rightToLeftIndex + 1 <= _textRuns.Count - 1 && _textRuns[rightToLeftIndex + 1] is ShapedTextCharacters nextShapedRun) + if (currentPosition + currentRun.Length <= firstTextSourceIndex) { - if (nextShapedRun == null || nextShapedRun.ShapedBuffer.IsLeftToRight) - { - break; - } + startX += currentRun.Size.Width; - rightToLeftIndex++; + currentPosition += currentRun.Length; - rightToLeftWidth += nextShapedRun.Size.Width; - - if (currentPosition + nextShapedRun.TextSourceLength > firstTextSourceIndex + textLength) - { - break; - } - - currentShapedRun = nextShapedRun; + continue; } - startX = startX + rightToLeftWidth; + if (currentShapedRun.ShapedBuffer.IsLeftToRight) + { + var startIndex = firstCluster + Math.Max(0, firstTextSourceIndex - currentPosition); - currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength); + double startOffset; - remainingLength -= currentRunBounds.Length; - currentPosition = currentRunBounds.TextSourceCharacterIndex + currentRunBounds.Length; - endX = currentRunBounds.Rectangle.Right; - startX = currentRunBounds.Rectangle.Left; + double endOffset; - var rightToLeftRunBounds = new List { currentRunBounds }; + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); - for (int i = rightToLeftIndex - 1; i >= index; i--) - { - currentShapedRun = TextRuns[i] as ShapedTextCharacters; + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); - if(currentShapedRun == null) - { - continue; - } + startX += startOffset; - currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength); + endX += endOffset; - rightToLeftRunBounds.Insert(0, currentRunBounds); + var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); - remainingLength -= currentRunBounds.Length; - startX = currentRunBounds.Rectangle.Left; + var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); - currentPosition += currentRunBounds.Length; + characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength); + + currentDirection = FlowDirection.LeftToRight; } + else + { + var rightToLeftIndex = index; + var rightToLeftWidth = currentShapedRun.Size.Width; - combinedWidth = endX - startX; + while (rightToLeftIndex + 1 <= _textRuns.Count - 1 && _textRuns[rightToLeftIndex + 1] is ShapedTextCharacters nextShapedRun) + { + if (nextShapedRun == null || nextShapedRun.ShapedBuffer.IsLeftToRight) + { + break; + } - currentRect = new Rect(startX, 0, combinedWidth, Height); + rightToLeftIndex++; - currentDirection = FlowDirection.RightToLeft; + rightToLeftWidth += nextShapedRun.Size.Width; - if (!MathUtilities.IsZero(combinedWidth)) - { - result.Add(new TextBounds(currentRect, currentDirection, rightToLeftRunBounds)); - } + if (currentPosition + nextShapedRun.Length > firstTextSourceIndex + textLength) + { + break; + } - startX = endX; - } - else - { - if (currentShapedRun != null) - { - var offset = Math.Max(0, firstTextSourceIndex - currentPosition); + currentShapedRun = nextShapedRun; + } - currentPosition += offset; + startX += rightToLeftWidth; - var startIndex = currentRun.Text.Start + offset; + currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength); - double startOffset; - double endOffset; + remainingLength -= currentRunBounds.Length; + currentPosition = currentRunBounds.TextSourceCharacterIndex + currentRunBounds.Length; + endX = currentRunBounds.Rectangle.Right; + startX = currentRunBounds.Rectangle.Left; - if (currentShapedRun.ShapedBuffer.IsLeftToRight) - { - startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + var rightToLeftRunBounds = new List { currentRunBounds }; - endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); - } - else + for (int i = rightToLeftIndex - 1; i >= index; i--) { - endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); - - if (currentPosition < startIndex) - { - startOffset = endOffset; - } - else + if (TextRuns[i] is not ShapedTextCharacters) { - startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + continue; } - } - startX += startOffset; + currentShapedRun = (ShapedTextCharacters)TextRuns[i]; - endX += endOffset; + currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength); - var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); - var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); + rightToLeftRunBounds.Insert(0, currentRunBounds); - characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength); + remainingLength -= currentRunBounds.Length; + startX = currentRunBounds.Rectangle.Left; - currentDirection = FlowDirection.LeftToRight; - } - else - { - if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex) - { - startX += currentRun.Size.Width; + currentPosition += currentRunBounds.Length; + } - currentPosition += currentRun.TextSourceLength; + combinedWidth = endX - startX; - continue; - } + currentRect = new Rect(startX, 0, combinedWidth, Height); + + currentDirection = FlowDirection.RightToLeft; - if (currentPosition < firstTextSourceIndex) + if (!MathUtilities.IsZero(combinedWidth)) { - startX += currentRun.Size.Width; + result.Add(new TextBounds(currentRect, currentDirection, rightToLeftRunBounds)); } - if (currentPosition + currentRun.TextSourceLength <= characterIndex) - { - endX += currentRun.Size.Width; + startX = endX; + } + } + else + { + if (currentPosition + currentRun.Length <= firstTextSourceIndex) + { + startX += currentRun.Size.Width; - characterLength = currentRun.TextSourceLength; - } + currentPosition += currentRun.Length; + + continue; } - if (endX < startX) + if (currentPosition < firstTextSourceIndex) { - (endX, startX) = (startX, endX); + startX += currentRun.Size.Width; } - //Lines that only contain a linebreak need to be covered here - if (characterLength == 0) + if (currentPosition + currentRun.Length <= characterIndex) { - characterLength = NewLineLength; + endX += currentRun.Size.Width; + + characterLength = currentRun.Length; } + } - combinedWidth = endX - startX; + if (endX < startX) + { + (endX, startX) = (startX, endX); + } - currentRunBounds = new TextRunBounds(new Rect(startX, 0, combinedWidth, Height), currentPosition, characterLength, currentRun); + //Lines that only contain a linebreak need to be covered here + if (characterLength == 0) + { + characterLength = NewLineLength; + } - currentPosition += characterLength; + combinedWidth = endX - startX; - remainingLength -= characterLength; + currentRunBounds = new TextRunBounds(new Rect(startX, 0, combinedWidth, Height), currentPosition, characterLength, currentRun); - startX = endX; + currentPosition += characterLength; - if (currentRunBounds.TextRun != null && !MathUtilities.IsZero(combinedWidth) || NewLineLength > 0) - { - if (result.Count > 0 && lastDirection == currentDirection && MathUtilities.AreClose(currentRect.Left, lastRunBounds.Rectangle.Right)) - { - currentRect = currentRect.WithWidth(currentWidth + combinedWidth); + remainingLength -= characterLength; - var textBounds = result[result.Count - 1]; + startX = endX; - textBounds.Rectangle = currentRect; + if (currentRunBounds.TextRun != null && !MathUtilities.IsZero(combinedWidth) || NewLineLength > 0) + { + if (result.Count > 0 && lastDirection == currentDirection && MathUtilities.AreClose(currentRect.Left, lastRunBounds.Rectangle.Right)) + { + currentRect = currentRect.WithWidth(currentWidth + combinedWidth); - textBounds.TextRunBounds.Add(currentRunBounds); - } - else - { - currentRect = currentRunBounds.Rectangle; + var textBounds = result[result.Count - 1]; - result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); - } + textBounds.Rectangle = currentRect; + + textBounds.TextRunBounds.Add(currentRunBounds); } + else + { + currentRect = currentRunBounds.Rectangle; - lastRunBounds = currentRunBounds; + result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); + } } + lastRunBounds = currentRunBounds; + currentWidth += combinedWidth; if (remainingLength <= 0 || currentPosition >= characterIndex) @@ -771,11 +757,11 @@ namespace Avalonia.Media.TextFormatting continue; } - if (currentPosition + currentRun.TextSourceLength < firstTextSourceIndex) + if (currentPosition + currentRun.Length < firstTextSourceIndex) { startX -= currentRun.Size.Width; - currentPosition += currentRun.TextSourceLength; + currentPosition += currentRun.Length; continue; } @@ -789,7 +775,7 @@ namespace Avalonia.Media.TextFormatting currentPosition += offset; - var startIndex = currentRun.Text.Start + offset; + var startIndex = currentPosition; double startOffset; double endOffset; @@ -827,7 +813,7 @@ namespace Avalonia.Media.TextFormatting } else { - if (currentPosition + currentRun.TextSourceLength <= characterIndex) + if (currentPosition + currentRun.Length <= characterIndex) { endX -= currentRun.Size.Width; } @@ -836,7 +822,7 @@ namespace Avalonia.Media.TextFormatting { startX -= currentRun.Size.Width; - characterLength = currentRun.TextSourceLength; + characterLength = currentRun.Length; } } @@ -905,7 +891,7 @@ namespace Avalonia.Media.TextFormatting currentPosition += offset; - var startIndex = currentRun.Text.Start + offset; + var startIndex = currentPosition; double startOffset; double endOffset; @@ -1172,12 +1158,12 @@ namespace Avalonia.Media.TextFormatting return true; } - var characterIndex = codepointIndex - shapedRun.Text.Start; + //var characterIndex = codepointIndex - shapedRun.Text.Start; - if (characterIndex < 0 && shapedRun.ShapedBuffer.IsLeftToRight) - { - foundCharacterHit = new CharacterHit(foundCharacterHit.FirstCharacterIndex); - } + //if (characterIndex < 0 && shapedRun.ShapedBuffer.IsLeftToRight) + //{ + // foundCharacterHit = new CharacterHit(foundCharacterHit.FirstCharacterIndex); + //} nextCharacterHit = isAtEnd || characterHit.TrailingLength != 0 ? foundCharacterHit : @@ -1196,7 +1182,7 @@ namespace Avalonia.Media.TextFormatting if (textPosition == currentPosition) { - nextCharacterHit = new CharacterHit(currentPosition + currentRun.TextSourceLength); + nextCharacterHit = new CharacterHit(currentPosition + currentRun.Length); return true; } @@ -1205,7 +1191,7 @@ namespace Avalonia.Media.TextFormatting } } - currentPosition += currentRun.TextSourceLength; + currentPosition += currentRun.Length; runIndex++; } @@ -1271,7 +1257,7 @@ namespace Avalonia.Media.TextFormatting } default: { - if (characterIndex == currentPosition + currentRun.TextSourceLength) + if (characterIndex == currentPosition + currentRun.Length) { previousCharacterHit = new CharacterHit(currentPosition); @@ -1282,7 +1268,7 @@ namespace Avalonia.Media.TextFormatting } } - currentPosition -= currentRun.TextSourceLength; + currentPosition -= currentRun.Length; runIndex--; } @@ -1310,18 +1296,25 @@ namespace Avalonia.Media.TextFormatting { case ShapedTextCharacters shapedRun: { + var firstCluster = shapedRun.GlyphRun.Metrics.FirstCluster; + + if (firstCluster > codepointIndex) + { + break; + } + if (previousRun is ShapedTextCharacters previousShaped && !previousShaped.ShapedBuffer.IsLeftToRight) { if (shapedRun.ShapedBuffer.IsLeftToRight) { - if (currentRun.Text.Start >= codepointIndex) + if (firstCluster >= codepointIndex) { return --runIndex; } } else { - if (codepointIndex > currentRun.Text.Start + currentRun.Text.Length) + if (codepointIndex > firstCluster + currentRun.Length) { return --runIndex; } @@ -1330,15 +1323,15 @@ namespace Avalonia.Media.TextFormatting if (direction == LogicalDirection.Forward) { - if (codepointIndex >= currentRun.Text.Start && codepointIndex <= currentRun.Text.End) + if (codepointIndex >= firstCluster && codepointIndex <= firstCluster + currentRun.Length) { return runIndex; } } else { - if (codepointIndex > currentRun.Text.Start && - codepointIndex <= currentRun.Text.Start + currentRun.Text.Length) + if (codepointIndex > firstCluster && + codepointIndex <= firstCluster + currentRun.Length) { return runIndex; } @@ -1349,6 +1342,8 @@ namespace Avalonia.Media.TextFormatting return runIndex; } + textPosition += currentRun.Length; + break; } @@ -1364,13 +1359,14 @@ namespace Avalonia.Media.TextFormatting return runIndex; } + textPosition += currentRun.Length; + break; } } runIndex++; previousRun = currentRun; - textPosition += currentRun.TextSourceLength; } return runIndex; @@ -1401,7 +1397,7 @@ namespace Avalonia.Media.TextFormatting case ShapedTextCharacters textRun: { var textMetrics = - new TextMetrics(textRun.Properties.Typeface, textRun.Properties.FontRenderingEmSize); + new TextMetrics(textRun.Properties.Typeface.GlyphTypeface, textRun.Properties.FontRenderingEmSize); if (fontRenderingEmSize < textRun.Properties.FontRenderingEmSize) { @@ -1432,7 +1428,7 @@ namespace Avalonia.Media.TextFormatting { width = widthIncludingWhitespace + textRun.GlyphRun.Metrics.Width; trailingWhitespaceLength = textRun.GlyphRun.Metrics.TrailingWhitespaceLength; - newLineLength = textRun.GlyphRun.Metrics.NewlineLength; + newLineLength = textRun.GlyphRun.Metrics.NewLineLength; } widthIncludingWhitespace += textRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineMetrics.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineMetrics.cs index 1799c9d3db..40a7f6167a 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineMetrics.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineMetrics.cs @@ -6,13 +6,13 @@ /// public readonly struct TextLineMetrics { - public TextLineMetrics(bool hasOverflowed, double height, int newLineLength, double start, double textBaseline, + public TextLineMetrics(bool hasOverflowed, double height, int newlineLength, double start, double textBaseline, int trailingWhitespaceLength, double width, double widthIncludingTrailingWhitespace) { HasOverflowed = hasOverflowed; Height = height; - NewLineLength = newLineLength; + NewlineLength = newlineLength; Start = start; TextBaseline = textBaseline; TrailingWhitespaceLength = trailingWhitespaceLength; @@ -33,7 +33,7 @@ /// /// Gets the number of newline characters at the end of a line. /// - public int NewLineLength { get; } + public int NewlineLength { get; } /// /// Gets the distance from the start of a paragraph to the starting point of a line. diff --git a/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs b/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs index 0382e66b5a..dc21c9b6f2 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextMetrics.cs @@ -5,9 +5,9 @@ /// public readonly struct TextMetrics { - public TextMetrics(Typeface typeface, double fontRenderingEmSize) + public TextMetrics(IGlyphTypeface glyphTypeface, double fontRenderingEmSize) { - var fontMetrics = typeface.GlyphTypeface.Metrics; + var fontMetrics = glyphTypeface.Metrics; var scale = fontRenderingEmSize / fontMetrics.DesignEmHeight; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextRun.cs b/src/Avalonia.Base/Media/TextFormatting/TextRun.cs index 26c3f8947a..0306054767 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextRun.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextRun.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { @@ -14,12 +13,12 @@ namespace Avalonia.Media.TextFormatting /// /// Gets the text source length. /// - public virtual int TextSourceLength => DefaultTextSourceLength; + public virtual int Length => DefaultTextSourceLength; /// /// Gets the text run's text. /// - public virtual ReadOnlySlice Text => default; + public virtual CharacterBufferReference CharacterBufferReference => default; /// /// A set of properties shared by every characters in the run @@ -41,9 +40,11 @@ namespace Avalonia.Media.TextFormatting { unsafe { - fixed (char* charsPtr = _textRun.Text.Span) + var characterBuffer = _textRun.CharacterBufferReference.CharacterBuffer; + + fixed (char* charsPtr = characterBuffer.Span) { - return new string(charsPtr, 0, _textRun.Text.Length); + return new string(charsPtr, 0, characterBuffer.Span.Length); } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextShaper.cs b/src/Avalonia.Base/Media/TextFormatting/TextShaper.cs index 615b1553b6..4aacec7c48 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextShaper.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextShaper.cs @@ -1,7 +1,5 @@ using System; -using System.Globalization; using Avalonia.Platform; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { @@ -45,9 +43,14 @@ namespace Avalonia.Media.TextFormatting } /// - public ShapedBuffer ShapeText(ReadOnlySlice text, TextShaperOptions options) + public ShapedBuffer ShapeText(CharacterBufferReference text, int length, TextShaperOptions options = default) { - return _platformImpl.ShapeText(text, options); + return _platformImpl.ShapeText(text, length, options); + } + + public ShapedBuffer ShapeText(string text, TextShaperOptions options = default) + { + return ShapeText(new CharacterBufferReference(text), text.Length, options); } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs index 670d94e928..1de04ad061 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting { @@ -15,7 +14,7 @@ namespace Avalonia.Media.TextFormatting /// Text used as collapsing symbol. /// Width in which collapsing is constrained to. /// Text run properties of ellipsis symbol. - public TextTrailingCharacterEllipsis(ReadOnlySlice ellipsis, double width, TextRunProperties textRunProperties) + public TextTrailingCharacterEllipsis(string ellipsis, double width, TextRunProperties textRunProperties) { Width = width; Symbol = new TextCharacters(ellipsis, textRunProperties); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs index dbffbdf060..7c94715aa4 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs @@ -16,7 +16,7 @@ namespace Avalonia.Media.TextFormatting /// width in which collapsing is constrained to. /// text run properties of ellipsis symbol. public TextTrailingWordEllipsis( - ReadOnlySlice ellipsis, + string ellipsis, double width, TextRunProperties textRunProperties ) diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiData.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiData.cs index 72df2815a9..0c51b0898d 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiData.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiData.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. // Ported from: https://github.com/SixLabors/Fonts/ +using System; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting.Unicode @@ -63,7 +64,7 @@ namespace Avalonia.Media.TextFormatting.Unicode /// Appends text to the bidi data. /// /// The text to process. - public void Append(ReadOnlySlice text) + public void Append(CharacterBufferRange text) { _classes.Add(text.Length); _pairedBracketTypes.Add(text.Length); diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs index de40839853..9e5186552e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs @@ -1,6 +1,5 @@ -using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting.Unicode { @@ -166,11 +165,11 @@ namespace Avalonia.Media.TextFormatting.Unicode /// The index to read at. /// The count of character that were read. /// - public static Codepoint ReadAt(ReadOnlySpan text, int index, out int count) + public static Codepoint ReadAt(IReadOnlyList text, int index, out int count) { count = 1; - if (index >= text.Length) + if (index >= text.Count) { return ReplacementCodepoint; } @@ -184,7 +183,7 @@ namespace Avalonia.Media.TextFormatting.Unicode { hi = code; - if (index + 1 == text.Length) + if (index + 1 == text.Count) { return ReplacementCodepoint; } diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/CodepointEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/CodepointEnumerator.cs index 9e1f748ebb..330ead476a 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/CodepointEnumerator.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/CodepointEnumerator.cs @@ -1,12 +1,13 @@ -using Avalonia.Utilities; +using System; namespace Avalonia.Media.TextFormatting.Unicode { public ref struct CodepointEnumerator { - private ReadOnlySlice _text; + private CharacterBufferRange _text; + private int _pos; - public CodepointEnumerator(ReadOnlySlice text) + public CodepointEnumerator(CharacterBufferRange text) { _text = text; Current = Codepoint.ReplacementCodepoint; diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/Grapheme.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/Grapheme.cs index f268340eb9..69015fb17d 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/Grapheme.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/Grapheme.cs @@ -1,13 +1,13 @@ -using Avalonia.Utilities; +using System; namespace Avalonia.Media.TextFormatting.Unicode { /// /// Represents the smallest unit of a writing system of any given language. /// - public readonly struct Grapheme + public readonly ref struct Grapheme { - public Grapheme(Codepoint firstCodepoint, ReadOnlySlice text) + public Grapheme(Codepoint firstCodepoint, ReadOnlySpan text) { FirstCodepoint = firstCodepoint; Text = text; @@ -21,6 +21,6 @@ namespace Avalonia.Media.TextFormatting.Unicode /// /// The text that is representing the . /// - public ReadOnlySlice Text { get; } + public ReadOnlySpan Text { get; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/GraphemeEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/GraphemeEnumerator.cs index 1e4ac8fe0f..5ca120c856 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/GraphemeEnumerator.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/GraphemeEnumerator.cs @@ -3,16 +3,16 @@ // // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. +using System.Collections.Generic; using System.Runtime.InteropServices; -using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting.Unicode { public ref struct GraphemeEnumerator { - private ReadOnlySlice _text; + private CharacterBufferRange _text; - public GraphemeEnumerator(ReadOnlySlice text) + public GraphemeEnumerator(CharacterBufferRange text) { _text = text; Current = default; @@ -187,7 +187,7 @@ namespace Avalonia.Media.TextFormatting.Unicode var text = _text.Take(processor.CurrentCodeUnitOffset); - Current = new Grapheme(firstCodepoint, text); + Current = new Grapheme(firstCodepoint, text.Span); _text = _text.Skip(processor.CurrentCodeUnitOffset); @@ -197,10 +197,10 @@ namespace Avalonia.Media.TextFormatting.Unicode [StructLayout(LayoutKind.Auto)] private ref struct Processor { - private readonly ReadOnlySlice _buffer; + private readonly CharacterBufferRange _buffer; private int _codeUnitLengthOfCurrentScalar; - internal Processor(ReadOnlySlice buffer) + internal Processor(CharacterBufferRange buffer) { _buffer = buffer; _codeUnitLengthOfCurrentScalar = 0; diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs index e12a7c06f1..41a476c17e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs @@ -2,7 +2,8 @@ // Licensed under the Apache License, Version 2.0. // Ported from: https://github.com/SixLabors/Fonts/ -using Avalonia.Utilities; +using System; +using System.Collections.Generic; namespace Avalonia.Media.TextFormatting.Unicode { @@ -12,7 +13,7 @@ namespace Avalonia.Media.TextFormatting.Unicode /// public ref struct LineBreakEnumerator { - private readonly ReadOnlySlice _text; + private readonly IReadOnlyList _text; private int _position; private int _lastPosition; private LineBreakClass _currentClass; @@ -28,7 +29,7 @@ namespace Avalonia.Media.TextFormatting.Unicode private int _lb30a; private bool _lb31; - public LineBreakEnumerator(ReadOnlySlice text) + public LineBreakEnumerator(IReadOnlyList text) : this() { _text = text; @@ -62,7 +63,7 @@ namespace Avalonia.Media.TextFormatting.Unicode _lb30a = 0; } - while (_position < _text.Length) + while (_position < _text.Count) { _lastPosition = _position; var lastClass = _nextClass; @@ -92,11 +93,11 @@ namespace Avalonia.Media.TextFormatting.Unicode } } - if (_position >= _text.Length) + if (_position >= _text.Count) { - if (_lastPosition < _text.Length) + if (_lastPosition < _text.Count) { - _lastPosition = _text.Length; + _lastPosition = _text.Count; var required = false; diff --git a/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs b/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs index 19ca1a0198..7ba25eb005 100644 --- a/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs +++ b/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs @@ -1,21 +1,16 @@ using Avalonia.Media.TextFormatting; -using Avalonia.Utilities; namespace Avalonia.Media { public sealed class TextLeadingPrefixTrimming : TextTrimming { - private readonly ReadOnlySlice _ellipsis; + private readonly string _ellipsis; private readonly int _prefixLength; - public TextLeadingPrefixTrimming(char ellipsis, int prefixLength) : this(new[] { ellipsis }, prefixLength) - { - } - - public TextLeadingPrefixTrimming(char[] ellipsis, int prefixLength) + public TextLeadingPrefixTrimming(string ellipsis, int prefixLength) { _prefixLength = prefixLength; - _ellipsis = new ReadOnlySlice(ellipsis); + _ellipsis = ellipsis; } public override TextCollapsingProperties CreateCollapsingProperties(TextCollapsingCreateInfo createInfo) diff --git a/src/Avalonia.Base/Media/TextTrailingTrimming.cs b/src/Avalonia.Base/Media/TextTrailingTrimming.cs index 5bb35f0ba7..2edbaabbc6 100644 --- a/src/Avalonia.Base/Media/TextTrailingTrimming.cs +++ b/src/Avalonia.Base/Media/TextTrailingTrimming.cs @@ -1,21 +1,16 @@ using Avalonia.Media.TextFormatting; -using Avalonia.Utilities; namespace Avalonia.Media { public sealed class TextTrailingTrimming : TextTrimming { - private readonly ReadOnlySlice _ellipsis; + private readonly string _ellipsis; private readonly bool _isWordBased; - - public TextTrailingTrimming(char ellipsis, bool isWordBased) : this(new[] {ellipsis}, isWordBased) - { - } - public TextTrailingTrimming(char[] ellipsis, bool isWordBased) + public TextTrailingTrimming(string ellipsis, bool isWordBased) { _isWordBased = isWordBased; - _ellipsis = new ReadOnlySlice(ellipsis); + _ellipsis = ellipsis; } public override TextCollapsingProperties CreateCollapsingProperties(TextCollapsingCreateInfo createInfo) diff --git a/src/Avalonia.Base/Media/TextTrimming.cs b/src/Avalonia.Base/Media/TextTrimming.cs index e2737210be..34642c11df 100644 --- a/src/Avalonia.Base/Media/TextTrimming.cs +++ b/src/Avalonia.Base/Media/TextTrimming.cs @@ -8,7 +8,7 @@ namespace Avalonia.Media /// public abstract class TextTrimming { - internal const char DefaultEllipsisChar = '\u2026'; + internal const string DefaultEllipsisChar = "\u2026"; /// /// Text is not trimmed. diff --git a/src/Avalonia.Base/Platform/ITextShaperImpl.cs b/src/Avalonia.Base/Platform/ITextShaperImpl.cs index 10e58b7d0b..ff91097eda 100644 --- a/src/Avalonia.Base/Platform/ITextShaperImpl.cs +++ b/src/Avalonia.Base/Platform/ITextShaperImpl.cs @@ -1,6 +1,5 @@ using Avalonia.Media.TextFormatting; using Avalonia.Metadata; -using Avalonia.Utilities; namespace Avalonia.Platform { @@ -13,9 +12,9 @@ namespace Avalonia.Platform /// /// Shapes the specified region within the text and returns a shaped buffer. /// - /// The text. + /// The text buffer. /// Text shaper options to customize the shaping process. /// A shaped glyph run. - ShapedBuffer ShapeText(ReadOnlySlice text, TextShaperOptions options); + ShapedBuffer ShapeText(CharacterBufferReference text, int length, TextShaperOptions options); } } diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs index e9f58be095..986110b5e6 100644 --- a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs +++ b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs @@ -175,6 +175,10 @@ namespace Avalonia.Rendering.Composition.Animations public override void Activate() { + if (_finished) + { + return; + } TargetObject.Compositor.AddToClock(this); base.Activate(); } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs b/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs index 2019ad6faa..18cb7a6308 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Globalization; +using System.Linq; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Platform; @@ -31,7 +32,7 @@ internal class FpsCounter { var s = new string((char)c, 1); var glyph = typeface.GetGlyph((uint)(s[0])); - _runs[c - FirstChar] = new GlyphRun(typeface, 18, new ReadOnlySlice(s.AsMemory()), new ushort[] { glyph }); + _runs[c - FirstChar] = new GlyphRun(typeface, 18, s.ToArray(), new ushort[] { glyph }); } } diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 6fa2027e1e..6043175eee 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -81,6 +81,7 @@ namespace Avalonia private Styles? _styles; private bool _stylesApplied; private bool _themeApplied; + private bool _templatedParentThemeApplied; private AvaloniaObject? _templatedParent; private bool _dataContextUpdating; private ControlTheme? _implicitTheme; @@ -375,6 +376,12 @@ namespace Avalonia _themeApplied = true; } + if (!_templatedParentThemeApplied) + { + ApplyTemplatedParentControlTheme(); + _templatedParentThemeApplied = true; + } + if (!_stylesApplied) { ApplyStyles(this); @@ -613,26 +620,38 @@ namespace Avalonia base.OnPropertyChanged(change); if (change.Property == ThemeProperty) - { OnControlThemeChanged(); - _themeApplied = false; - } } private protected virtual void OnControlThemeChanged() { var values = GetValueStore(); values.BeginStyling(); - try { values.RemoveFrames(FrameType.Theme); } - finally { values.EndStyling(); } + + try + { + values.RemoveFrames(FrameType.Theme); + } + finally + { + values.EndStyling(); + _themeApplied = false; + } } internal virtual void OnTemplatedParentControlThemeChanged() { var values = GetValueStore(); values.BeginStyling(); - try { values.RemoveFrames(FrameType.TemplatedParentTheme); } - finally { values.EndStyling(); } + try + { + values.RemoveFrames(FrameType.TemplatedParentTheme); + } + finally + { + values.EndStyling(); + _templatedParentThemeApplied = false; + } } internal ControlTheme? GetEffectiveTheme() @@ -743,13 +762,13 @@ namespace Avalonia private void ApplyControlTheme() { - var theme = GetEffectiveTheme(); - - if (theme is not null) + if (GetEffectiveTheme() is { } theme) ApplyControlTheme(theme, FrameType.Theme); + } - if (TemplatedParent is StyledElement styleableParent && - styleableParent.GetEffectiveTheme() is { } parentTheme) + private void ApplyTemplatedParentControlTheme() + { + if ((TemplatedParent as StyledElement)?.GetEffectiveTheme() is { } parentTheme) { ApplyControlTheme(parentTheme, FrameType.TemplatedParentTheme); } @@ -793,6 +812,28 @@ namespace Avalonia ApplyStyle(child, host, type); } + private void ReevaluateImplicitTheme() + { + // We only need to check if the theme has changed when Theme isn't set (i.e. when we + // have an implicit theme). + if (Theme is not null) + return; + + // Refetch the implicit theme. + var oldImplicitTheme = _implicitTheme == s_invalidTheme ? null : _implicitTheme; + _implicitTheme = null; + GetEffectiveTheme(); + + var newImplicitTheme = _implicitTheme == s_invalidTheme ? null : _implicitTheme; + + // If the implicit theme has changed, detach the existing theme. + if (newImplicitTheme != oldImplicitTheme) + { + OnControlThemeChanged(); + _themeApplied = false; + } + } + private void OnAttachedToLogicalTreeCore(LogicalTreeAttachmentEventArgs e) { if (this.GetLogicalParent() == null && !(this is ILogicalRoot)) @@ -811,6 +852,7 @@ namespace Avalonia { _logicalRoot = e.Root; + ReevaluateImplicitTheme(); ApplyStyling(); NotifyResourcesChanged(propagate: false); @@ -835,7 +877,6 @@ namespace Avalonia if (_logicalRoot != null) { _logicalRoot = null; - _implicitTheme = null; InvalidateStyles(recurse: false); OnDetachedFromLogicalTree(e); DetachedFromLogicalTree?.Invoke(this, e); diff --git a/src/Avalonia.Base/Styling/StyleInstance.cs b/src/Avalonia.Base/Styling/StyleInstance.cs index 4985aa16c7..ca602167c0 100644 --- a/src/Avalonia.Base/Styling/StyleInstance.cs +++ b/src/Avalonia.Base/Styling/StyleInstance.cs @@ -70,6 +70,9 @@ namespace Avalonia.Styling _animationTrigger ??= new Subject(); foreach (var animation in _animations) animation.Apply(animatable, null, _animationTrigger); + + if (_activator is null) + _animationTrigger.OnNext(true); } } diff --git a/src/Avalonia.Base/Utilities/ArraySlice.cs b/src/Avalonia.Base/Utilities/ArraySlice.cs index 482f807fe0..39c0cd5556 100644 --- a/src/Avalonia.Base/Utilities/ArraySlice.cs +++ b/src/Avalonia.Base/Utilities/ArraySlice.cs @@ -111,14 +111,6 @@ namespace Avalonia.Utilities } } - /// - /// Defines an implicit conversion of a to a - /// - public static implicit operator ReadOnlySlice(ArraySlice slice) - { - return new ReadOnlySlice(slice._data, 0, slice.Length, slice.Start); - } - /// /// Defines an implicit conversion of an array to a /// diff --git a/src/Avalonia.Base/Utilities/ReadOnlySlice.cs b/src/Avalonia.Base/Utilities/ReadOnlySlice.cs deleted file mode 100644 index 583a3139b9..0000000000 --- a/src/Avalonia.Base/Utilities/ReadOnlySlice.cs +++ /dev/null @@ -1,239 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics; -using System.Runtime.CompilerServices; - -namespace Avalonia.Utilities -{ - /// - /// ReadOnlySlice enables the ability to work with a sequence within a region of memory and retains the position in within that region. - /// - /// The type of elements in the slice. - [DebuggerTypeProxy(typeof(ReadOnlySlice<>.ReadOnlySliceDebugView))] - public readonly struct ReadOnlySlice : IReadOnlyList where T : struct - { - private readonly int _bufferOffset; - - /// - /// Gets an empty - /// - public static ReadOnlySlice Empty => new ReadOnlySlice(Array.Empty()); - - private readonly ReadOnlyMemory _buffer; - - public ReadOnlySlice(ReadOnlyMemory buffer) : this(buffer, 0, buffer.Length) { } - - public ReadOnlySlice(ReadOnlyMemory buffer, int start, int length, int bufferOffset = 0) - { -#if DEBUG - if (start.CompareTo(0) < 0) - { - throw new ArgumentOutOfRangeException(nameof (start)); - } - - if (length.CompareTo(buffer.Length) > 0) - { - throw new ArgumentOutOfRangeException(nameof (length)); - } -#endif - - _buffer = buffer; - Start = start; - Length = length; - _bufferOffset = bufferOffset; - } - - /// - /// Gets the start. - /// - /// - /// The start. - /// - public int Start { get; } - - /// - /// Gets the end. - /// - /// - /// The end. - /// - public int End => Start + Length - 1; - - /// - /// Gets the length. - /// - /// - /// The length. - /// - public int Length { get; } - - /// - /// Gets a value that indicates whether this instance of is Empty. - /// - public bool IsEmpty => Length == 0; - - /// - /// Get the underlying span. - /// - public ReadOnlySpan Span => _buffer.Span.Slice(_bufferOffset, Length); - - /// - /// Get the buffer offset. - /// - public int BufferOffset => _bufferOffset; - - /// - /// Get the underlying buffer. - /// - public ReadOnlyMemory Buffer => _buffer; - - /// - /// Returns a value to specified element of the slice. - /// - /// The index of the element to return. - /// The . - /// - /// Thrown when index less than 0 or index greater than or equal to . - /// - public T this[int index] - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get - { -#if DEBUG - if (index.CompareTo(0) < 0 || index.CompareTo(Length) > 0) - { - throw new ArgumentOutOfRangeException(nameof (index)); - } -#endif - return Span[index]; - } - } - - /// - /// Returns a sub slice of elements that start at the specified index and has the specified number of elements. - /// - /// The start of the sub slice. - /// The length of the sub slice. - /// A that contains the specified number of elements from the specified start. - public ReadOnlySlice AsSlice(int start, int length) - { - if (IsEmpty) - { - return this; - } - - if (length == 0) - { - return Empty; - } - - if (start < 0 || _bufferOffset + start > _buffer.Length - 1) - { - throw new ArgumentOutOfRangeException(nameof(start)); - } - - if (_bufferOffset + start + length > _buffer.Length) - { - throw new ArgumentOutOfRangeException(nameof(length)); - } - - return new ReadOnlySlice(_buffer, start, length, _bufferOffset); - } - - /// - /// Returns a specified number of contiguous elements from the start of the slice. - /// - /// The number of elements to return. - /// A that contains the specified number of elements from the start of this slice. - public ReadOnlySlice Take(int length) - { - if (IsEmpty) - { - return this; - } - - if (length > Length) - { - throw new ArgumentOutOfRangeException(nameof(length)); - } - - return new ReadOnlySlice(_buffer, Start, length, _bufferOffset); - } - - /// - /// Bypasses a specified number of elements in the slice and then returns the remaining elements. - /// - /// The number of elements to skip before returning the remaining elements. - /// A that contains the elements that occur after the specified index in this slice. - public ReadOnlySlice Skip(int length) - { - if (IsEmpty) - { - return this; - } - - if (length > Length) - { - throw new ArgumentOutOfRangeException(nameof(length)); - } - - return new ReadOnlySlice(_buffer, Start + length, Length - length, _bufferOffset + length); - } - - /// - /// Returns an enumerator for the slice. - /// - public ImmutableReadOnlyListStructEnumerator GetEnumerator() - { - return new ImmutableReadOnlyListStructEnumerator(this); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - int IReadOnlyCollection.Count => Length; - - T IReadOnlyList.this[int index] => this[index]; - - public static implicit operator ReadOnlySlice(T[] array) - { - return new ReadOnlySlice(array); - } - - public static implicit operator ReadOnlySlice(ReadOnlyMemory memory) - { - return new ReadOnlySlice(memory); - } - - public static implicit operator ReadOnlySpan(ReadOnlySlice slice) => slice.Span; - - internal class ReadOnlySliceDebugView - { - private readonly ReadOnlySlice _readOnlySlice; - - public ReadOnlySliceDebugView(ReadOnlySlice readOnlySlice) - { - _readOnlySlice = readOnlySlice; - } - - public int Start => _readOnlySlice.Start; - - public int End => _readOnlySlice.End; - - public int Length => _readOnlySlice.Length; - - public bool IsEmpty => _readOnlySlice.IsEmpty; - - public ReadOnlySpan Items => _readOnlySlice.Span; - } - } -} diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 7694119589..e3dc4fbb75 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -89,6 +89,14 @@ namespace Avalonia public static readonly StyledProperty RenderTransformOriginProperty = AvaloniaProperty.Register(nameof(RenderTransformOrigin), defaultValue: RelativePoint.Center); + /// + /// Defines the property. + /// + public static readonly AttachedProperty FlowDirectionProperty = + AvaloniaProperty.RegisterAttached( + nameof(FlowDirection), + inherits: true); + /// /// Defines the property. /// @@ -263,6 +271,15 @@ namespace Avalonia set { SetValue(RenderTransformOriginProperty, value); } } + /// + /// Gets or sets the text flow direction. + /// + public FlowDirection FlowDirection + { + get => GetValue(FlowDirectionProperty); + set => SetValue(FlowDirectionProperty, value); + } + /// /// Gets or sets the Z index of the control. /// @@ -306,6 +323,36 @@ namespace Avalonia /// internal Visual? VisualParent => _visualParent; + /// + /// Gets a value indicating whether control bypass FlowDirecton policies. + /// + /// + /// Related to FlowDirection system and returns false as default, so if + /// is RTL then control will get a mirror presentation. + /// For controls that want to avoid this behavior, override this property and return true. + /// + protected virtual bool BypassFlowDirectionPolicies => false; + + /// + /// Gets the value of the attached on a control. + /// + /// The control. + /// The flow direction. + public static FlowDirection GetFlowDirection(Visual visual) + { + return visual.GetValue(FlowDirectionProperty); + } + + /// + /// Sets the value of the attached on a control. + /// + /// The control. + /// The property value to set. + public static void SetFlowDirection(Visual visual, FlowDirection value) + { + visual.SetValue(FlowDirectionProperty, value); + } + /// /// Invalidates the visual and queues a repaint. /// @@ -387,6 +434,22 @@ namespace Avalonia } } + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FlowDirectionProperty) + { + InvalidateMirrorTransform(); + + foreach (var child in VisualChildren) + { + child.InvalidateMirrorTransform(); + } + } + } + protected override void LogicalChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { base.LogicalChildrenCollectionChanged(sender, e); @@ -682,5 +745,32 @@ namespace Avalonia visual.SetVisualParent(parent); } } + + /// + /// Computes the value according to the + /// and + /// + public virtual void InvalidateMirrorTransform() + { + var flowDirection = this.FlowDirection; + var parentFlowDirection = FlowDirection.LeftToRight; + + bool bypassFlowDirectionPolicies = BypassFlowDirectionPolicies; + bool parentBypassFlowDirectionPolicies = false; + + var parent = VisualParent; + if (parent != null) + { + parentFlowDirection = parent.FlowDirection; + parentBypassFlowDirectionPolicies = parent.BypassFlowDirectionPolicies; + } + + bool thisShouldBeMirrored = flowDirection == FlowDirection.RightToLeft && !bypassFlowDirectionPolicies; + bool parentShouldBeMirrored = parentFlowDirection == FlowDirection.RightToLeft && !parentBypassFlowDirectionPolicies; + + bool shouldApplyMirrorTransform = thisShouldBeMirrored != parentShouldBeMirrored; + + HasMirrorTransform = shouldApplyMirrorTransform; + } } } diff --git a/src/Avalonia.Controls.DataGrid/DataGridTemplateColumn.cs b/src/Avalonia.Controls.DataGrid/DataGridTemplateColumn.cs index 3ec78d6d6a..24ae358dcc 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridTemplateColumn.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridTemplateColumn.cs @@ -64,9 +64,11 @@ namespace Avalonia.Controls protected override Control GenerateElement(DataGridCell cell, object dataItem) { - if(CellTemplate != null) + if (CellTemplate != null) { - return CellTemplate.Build(dataItem); + return (CellTemplate is IRecyclingDataTemplate recyclingDataTemplate) + ? recyclingDataTemplate.Build(dataItem, cell.Content as Control) + : CellTemplate.Build(dataItem); } if (Design.IsDesignMode) { diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 2b407cc42a..f02df2e9c1 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -454,10 +454,9 @@ namespace Avalonia.Controls { if (SelectionBoxItem is Rectangle rectangle) { - if ((rectangle.Fill as VisualBrush)?.Visual is Control content) + if ((rectangle.Fill as VisualBrush)?.Visual is Visual content) { - var flowDirection = (((Visual)content!).VisualParent as Control)?.FlowDirection ?? - FlowDirection.LeftToRight; + var flowDirection = content.VisualParent?.FlowDirection ?? FlowDirection.LeftToRight; rectangle.FlowDirection = flowDirection; } } diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 063e6ae7c8..88c9823952 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -91,13 +91,6 @@ namespace Avalonia.Controls RoutedEvent.Register( nameof(SizeChanged), RoutingStrategies.Direct); - /// - /// Defines the property. - /// - public static readonly AttachedProperty FlowDirectionProperty = - AvaloniaProperty.RegisterAttached( - nameof(FlowDirection), - inherits: true); // Note the following: // _loadedQueue : @@ -170,15 +163,6 @@ namespace Avalonia.Controls get => GetValue(TagProperty); set => SetValue(TagProperty, value); } - - /// - /// Gets or sets the text flow direction. - /// - public FlowDirection FlowDirection - { - get => GetValue(FlowDirectionProperty); - set => SetValue(FlowDirectionProperty, value); - } /// /// Occurs when the user has completed a context input gesture, such as a right-click. @@ -229,39 +213,9 @@ namespace Avalonia.Controls public new Control? Parent => (Control?)base.Parent; - /// - /// Gets the value of the attached on a control. - /// - /// The control. - /// The flow direction. - public static FlowDirection GetFlowDirection(Control control) - { - return control.GetValue(FlowDirectionProperty); - } - - /// - /// Sets the value of the attached on a control. - /// - /// The control. - /// The property value to set. - public static void SetFlowDirection(Control control, FlowDirection value) - { - control.SetValue(FlowDirectionProperty, value); - } - /// bool IDataTemplateHost.IsDataTemplatesInitialized => _dataTemplates != null; - /// - /// Gets a value indicating whether control bypass FlowDirecton policies. - /// - /// - /// Related to FlowDirection system and returns false as default, so if - /// is RTL then control will get a mirror presentation. - /// For controls that want to avoid this behavior, override this property and return true. - /// - protected virtual bool BypassFlowDirectionPolicies => false; - /// void ISetterValue.Initialize(ISetter setter) { @@ -571,45 +525,6 @@ namespace Avalonia.Controls RaiseEvent(sizeChangedEventArgs); } } - else if (change.Property == FlowDirectionProperty) - { - InvalidateMirrorTransform(); - - foreach (var visual in VisualChildren) - { - if (visual is Control child) - { - child.InvalidateMirrorTransform(); - } - } - } - } - - /// - /// Computes the value according to the - /// and - /// - public virtual void InvalidateMirrorTransform() - { - var flowDirection = this.FlowDirection; - var parentFlowDirection = FlowDirection.LeftToRight; - - bool bypassFlowDirectionPolicies = BypassFlowDirectionPolicies; - bool parentBypassFlowDirectionPolicies = false; - - var parent = this.VisualParent as Control; - if (parent != null) - { - parentFlowDirection = parent.FlowDirection; - parentBypassFlowDirectionPolicies = parent.BypassFlowDirectionPolicies; - } - - bool thisShouldBeMirrored = flowDirection == FlowDirection.RightToLeft && !bypassFlowDirectionPolicies; - bool parentShouldBeMirrored = parentFlowDirection == FlowDirection.RightToLeft && !parentBypassFlowDirectionPolicies; - - bool shouldApplyMirrorTransform = thisShouldBeMirrored != parentShouldBeMirrored; - - HasMirrorTransform = shouldApplyMirrorTransform; } } } diff --git a/src/Avalonia.Controls/Documents/LineBreak.cs b/src/Avalonia.Controls/Documents/LineBreak.cs index 108a38d86b..ee31b85be9 100644 --- a/src/Avalonia.Controls/Documents/LineBreak.cs +++ b/src/Avalonia.Controls/Documents/LineBreak.cs @@ -4,7 +4,7 @@ using System.Text; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; -namespace Avalonia.Controls.Documents +namespace Avalonia.Controls.Documents { /// /// LineBreak element that forces a line breaking. @@ -21,7 +21,7 @@ namespace Avalonia.Controls.Documents internal override void BuildTextRun(IList textRuns) { - var text = Environment.NewLine.AsMemory(); + var text = Environment.NewLine; var textRunProperties = CreateTextRunProperties(); diff --git a/src/Avalonia.Controls/Documents/Run.cs b/src/Avalonia.Controls/Documents/Run.cs index 2bd66b8a64..5d7b8998e6 100644 --- a/src/Avalonia.Controls/Documents/Run.cs +++ b/src/Avalonia.Controls/Documents/Run.cs @@ -52,7 +52,7 @@ namespace Avalonia.Controls.Documents internal override void BuildTextRun(IList textRuns) { - var text = (Text ?? "").AsMemory(); + var text = Text ?? ""; var textRunProperties = CreateTextRunProperties(); diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshCompletionDeferral.cs b/src/Avalonia.Controls/PullToRefresh/RefreshCompletionDeferral.cs new file mode 100644 index 0000000000..a18b3c2934 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshCompletionDeferral.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; + +namespace Avalonia.Controls +{ + /// + /// Deferral class for notify that a work done in RefreshRequested event is done. + /// + public class RefreshCompletionDeferral + { + private Action _deferredAction; + private int _deferCount; + + public RefreshCompletionDeferral(Action deferredAction) + { + _deferredAction = deferredAction; + } + + public void Complete() + { + Interlocked.Decrement(ref _deferCount); + + if (_deferCount == 0) + { + _deferredAction?.Invoke(); + } + } + + public RefreshCompletionDeferral Get() + { + Interlocked.Increment(ref _deferCount); + + return this; + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs new file mode 100644 index 0000000000..d0b8178add --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs @@ -0,0 +1,252 @@ +using System; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.PullToRefresh; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace Avalonia.Controls +{ + /// + /// Represents a container control that provides a and pull-to-refresh functionality for scrollable content. + /// + public class RefreshContainer : ContentControl + { + internal const int DefaultPullDimensionSize = 100; + + private bool _hasDefaultRefreshInfoProviderAdapter; + + private ScrollViewerIRefreshInfoProviderAdapter? _refreshInfoProviderAdapter; + private RefreshInfoProvider? _refreshInfoProvider; + private IDisposable? _visualizerSizeSubscription; + private Grid? _visualizerPresenter; + private RefreshVisualizer? _refreshVisualizer; + private bool _hasDefaultRefreshVisualizer; + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshRequestedEvent = + RoutedEvent.Register(nameof(RefreshRequested), RoutingStrategies.Bubble); + + internal static readonly DirectProperty RefreshInfoProviderAdapterProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshInfoProviderAdapter), + (s) => s.RefreshInfoProviderAdapter, (s, o) => s.RefreshInfoProviderAdapter = o); + + /// + /// Defines the event. + /// + public static readonly DirectProperty VisualizerProperty = + AvaloniaProperty.RegisterDirect(nameof(Visualizer), + s => s.Visualizer, (s, o) => s.Visualizer = o); + + /// + /// Defines the event. + /// + public static readonly StyledProperty PullDirectionProperty = + AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom); + + internal ScrollViewerIRefreshInfoProviderAdapter? RefreshInfoProviderAdapter + { + get => _refreshInfoProviderAdapter; set + { + _hasDefaultRefreshInfoProviderAdapter = false; + SetAndRaise(RefreshInfoProviderAdapterProperty, ref _refreshInfoProviderAdapter, value); + } + } + + /// + /// Gets or sets the for this container. + /// + public RefreshVisualizer? Visualizer + { + get => _refreshVisualizer; set + { + if (_refreshVisualizer != null) + { + _visualizerSizeSubscription?.Dispose(); + _refreshVisualizer.RefreshRequested -= Visualizer_RefreshRequested; + } + + SetAndRaise(VisualizerProperty, ref _refreshVisualizer, value); + } + } + + /// + /// Gets or sets a value that specifies the direction to pull to initiate a refresh. + /// + public PullDirection PullDirection + { + get => GetValue(PullDirectionProperty); + set => SetValue(PullDirectionProperty, value); + } + + /// + /// Occurs when an update of the content has been initiated. + /// + public event EventHandler? RefreshRequested + { + add => AddHandler(RefreshRequestedEvent, value); + remove => RemoveHandler(RefreshRequestedEvent, value); + } + + public RefreshContainer() + { + _hasDefaultRefreshInfoProviderAdapter = true; + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _visualizerPresenter = e.NameScope.Find("PART_RefreshVisualizerPresenter"); + + if (_refreshVisualizer == null) + { + _hasDefaultRefreshVisualizer = true; + Visualizer = new RefreshVisualizer(); + } + else + { + _hasDefaultRefreshVisualizer = false; + RaisePropertyChanged(VisualizerProperty, default, _refreshVisualizer); + } + + OnPullDirectionChanged(); + } + + private void OnVisualizerSizeChanged(Rect obj) + { + if (_hasDefaultRefreshInfoProviderAdapter) + { + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); + } + } + + private void Visualizer_RefreshRequested(object? sender, RefreshRequestedEventArgs e) + { + var ev = new RefreshRequestedEventArgs(e.GetDeferral(), RefreshRequestedEvent); + RaiseEvent(ev); + ev.DecrementCount(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RefreshInfoProviderAdapterProperty) + { + if (_refreshVisualizer != null) + { + if (_refreshInfoProvider != null) + { + _refreshVisualizer.RefreshInfoProvider = _refreshInfoProvider; + } + else + { + if (RefreshInfoProviderAdapter != null && _refreshVisualizer != null) + { + _refreshInfoProvider = RefreshInfoProviderAdapter?.AdaptFromTree(this, _refreshVisualizer.Bounds.Size); + + if (_refreshInfoProvider != null) + { + _refreshVisualizer.RefreshInfoProvider = _refreshInfoProvider; + RefreshInfoProviderAdapter?.SetAnimations(_refreshVisualizer); + } + } + } + } + } + else if (change.Property == VisualizerProperty) + { + if (_visualizerPresenter != null) + { + _visualizerPresenter.Children.Clear(); + if (_refreshVisualizer != null) + { + _visualizerPresenter.Children.Add(_refreshVisualizer); + } + } + + if (_refreshVisualizer != null) + { + _refreshVisualizer.RefreshRequested += Visualizer_RefreshRequested; + _visualizerSizeSubscription = _refreshVisualizer.GetObservable(Control.BoundsProperty).Subscribe(OnVisualizerSizeChanged); + } + } + else if (change.Property == PullDirectionProperty) + { + OnPullDirectionChanged(); + } + } + + private void OnPullDirectionChanged() + { + if (_visualizerPresenter != null && _refreshVisualizer != null) + { + switch (PullDirection) + { + case PullDirection.TopToBottom: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Top; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Stretch; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.TopToBottom; + _refreshVisualizer.Height = DefaultPullDimensionSize; + _refreshVisualizer.Width = double.NaN; + } + break; + case PullDirection.BottomToTop: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Bottom; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Stretch; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.BottomToTop; + _refreshVisualizer.Height = DefaultPullDimensionSize; + _refreshVisualizer.Width = double.NaN; + } + break; + case PullDirection.LeftToRight: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Stretch; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Left; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.LeftToRight; + _refreshVisualizer.Width = DefaultPullDimensionSize; + _refreshVisualizer.Height = double.NaN; + } + break; + case PullDirection.RightToLeft: + _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Stretch; + _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Right; + if (_hasDefaultRefreshVisualizer) + { + _refreshVisualizer.PullDirection = PullDirection.RightToLeft; + _refreshVisualizer.Width = DefaultPullDimensionSize; + _refreshVisualizer.Height = double.NaN; + } + break; + } + + if (_hasDefaultRefreshInfoProviderAdapter && + _hasDefaultRefreshVisualizer && + _refreshVisualizer.Bounds.Height == DefaultPullDimensionSize && + _refreshVisualizer.Bounds.Width == DefaultPullDimensionSize) + { + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); + } + } + } + + /// + /// Initiates an update of the content. + /// + public void RequestRefresh() + { + _refreshVisualizer?.RequestRefresh(); + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs new file mode 100644 index 0000000000..847f011fa9 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs @@ -0,0 +1,141 @@ +using System; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Controls.PullToRefresh +{ + internal class RefreshInfoProvider : Interactive + { + internal const double DefaultExecutionRatio = 0.8; + + private readonly PullDirection _refreshPullDirection; + private readonly Size _refreshVisualizerSize; + + private readonly CompositionVisual? _visual; + private bool _isInteractingForRefresh; + private double _interactionRatio; + private bool _entered; + + public DirectProperty IsInteractingForRefreshProperty = + AvaloniaProperty.RegisterDirect(nameof(IsInteractingForRefresh), + s => s.IsInteractingForRefresh, (s, o) => s.IsInteractingForRefresh = o); + + + public DirectProperty ExecutionRatioProperty = + AvaloniaProperty.RegisterDirect(nameof(ExecutionRatio), + s => s.ExecutionRatio); + + public DirectProperty InteractionRatioProperty = + AvaloniaProperty.RegisterDirect(nameof(InteractionRatio), + s => s.InteractionRatio, (s, o) => s.InteractionRatio = o); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshStartedEvent = + RoutedEvent.Register(nameof(RefreshStarted), RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshCompletedEvent = + RoutedEvent.Register(nameof(RefreshCompleted), RoutingStrategies.Bubble); + + public bool PeekingMode { get; internal set; } + + public bool IsInteractingForRefresh + { + get => _isInteractingForRefresh; + internal set + { + var isInteractingForRefresh = value && !PeekingMode; + + if (isInteractingForRefresh != _isInteractingForRefresh) + { + SetAndRaise(IsInteractingForRefreshProperty, ref _isInteractingForRefresh, isInteractingForRefresh); + } + } + } + + public double InteractionRatio + { + get => _interactionRatio; + set + { + SetAndRaise(InteractionRatioProperty, ref _interactionRatio, value); + } + } + + public double ExecutionRatio + { + get => DefaultExecutionRatio; + } + + internal CompositionVisual? Visual => _visual; + + public event EventHandler RefreshStarted + { + add => AddHandler(RefreshStartedEvent, value); + remove => RemoveHandler(RefreshStartedEvent, value); + } + + public event EventHandler RefreshCompleted + { + add => AddHandler(RefreshCompletedEvent, value); + remove => RemoveHandler(RefreshCompletedEvent, value); + } + + internal void InteractingStateEntered(object? sender, PullGestureEventArgs e) + { + if (!_entered) + { + IsInteractingForRefresh = true; + _entered = true; + } + + ValuesChanged(e.Delta); + } + + internal void InteractingStateExited(object? sender, PullGestureEndedEventArgs e) + { + IsInteractingForRefresh = false; + _entered = false; + + ValuesChanged(default); + } + + + public RefreshInfoProvider(PullDirection refreshPullDirection, Size? refreshVIsualizerSize, CompositionVisual? visual) + { + _refreshPullDirection = refreshPullDirection; + _refreshVisualizerSize = refreshVIsualizerSize ?? default; + _visual = visual; + } + + public void OnRefreshStarted() + { + RaiseEvent(new RoutedEventArgs(RefreshStartedEvent)); + } + + public void OnRefreshCompleted() + { + RaiseEvent(new RoutedEventArgs(RefreshCompletedEvent)); + } + + internal void ValuesChanged(Vector value) + { + switch (_refreshPullDirection) + { + case PullDirection.TopToBottom: + case PullDirection.BottomToTop: + InteractionRatio = _refreshVisualizerSize.Height == 0 ? 1 : Math.Min(1, value.Y / _refreshVisualizerSize.Height); + break; + case PullDirection.LeftToRight: + case PullDirection.RightToLeft: + InteractionRatio = _refreshVisualizerSize.Height == 0 ? 1 : Math.Min(1, value.X / _refreshVisualizerSize.Width); + break; + } + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshRequestedEventArgs.cs b/src/Avalonia.Controls/PullToRefresh/RefreshRequestedEventArgs.cs new file mode 100644 index 0000000000..4bb25d3b2c --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshRequestedEventArgs.cs @@ -0,0 +1,42 @@ +using System; +using Avalonia.Interactivity; + +namespace Avalonia.Controls +{ + /// + /// Provides event data for RefreshRequested events. + /// + public class RefreshRequestedEventArgs : RoutedEventArgs + { + private RefreshCompletionDeferral _refreshCompletionDeferral; + + /// + /// Gets a deferral object for managing the work done in the RefreshRequested event handler. + /// + /// A object + public RefreshCompletionDeferral GetDeferral() + { + return _refreshCompletionDeferral.Get(); + } + + public RefreshRequestedEventArgs(Action deferredAction, RoutedEvent? routedEvent) : base(routedEvent) + { + _refreshCompletionDeferral = new RefreshCompletionDeferral(deferredAction); + } + + public RefreshRequestedEventArgs(RefreshCompletionDeferral completionDeferral, RoutedEvent? routedEvent) : base(routedEvent) + { + _refreshCompletionDeferral = completionDeferral; + } + + internal void IncrementCount() + { + _refreshCompletionDeferral?.Get(); + } + + internal void DecrementCount() + { + _refreshCompletionDeferral?.Complete(); + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs new file mode 100644 index 0000000000..f2f735aaa9 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs @@ -0,0 +1,553 @@ +using System; +using System.Numerics; +using System.Reactive.Linq; +using Avalonia.Animation.Easings; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.PullToRefresh; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Animations; + +namespace Avalonia.Controls +{ + public class RefreshVisualizer : ContentControl + { + private const int DefaultIndicatorSize = 24; + private const float MinimumIndicatorOpacity = 0.4f; + private const float ParallaxPositionRatio = 0.5f; + private double _executingRatio = 0.8; + + private RefreshVisualizerState _refreshVisualizerState; + private RefreshInfoProvider? _refreshInfoProvider; + private IDisposable? _isInteractingSubscription; + private IDisposable? _interactionRatioSubscription; + private bool _isInteractingForRefresh; + private Grid? _root; + private Control? _content; + private RefreshVisualizerOrientation _orientation; + private float _startingRotationAngle; + private double _interactionRatio; + private bool _played; + private ScalarKeyFrameAnimation? _rotateAnimation; + + private bool IsPullDirectionVertical => PullDirection == PullDirection.TopToBottom || PullDirection == PullDirection.BottomToTop; + private bool IsPullDirectionFar => PullDirection == PullDirection.BottomToTop || PullDirection == PullDirection.RightToLeft; + + /// + /// Defines the property. + /// + public static readonly StyledProperty PullDirectionProperty = + AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent RefreshRequestedEvent = + RoutedEvent.Register(nameof(RefreshRequested), RoutingStrategies.Bubble); + + /// + /// Defines the property. + /// + public static readonly DirectProperty RefreshVisualizerStateProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshVisualizerState), + s => s.RefreshVisualizerState); + + /// + /// Defines the property. + /// + public static readonly DirectProperty OrientationProperty = + AvaloniaProperty.RegisterDirect(nameof(Orientation), + s => s.Orientation, (s, o) => s.Orientation = o); + + /// + /// Defines the property. + /// + internal DirectProperty RefreshInfoProviderProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshInfoProvider), + s => s.RefreshInfoProvider, (s, o) => s.RefreshInfoProvider = o); + + /// + /// Gets or sets a value that indicates the refresh state of the visualizer. + /// + protected RefreshVisualizerState RefreshVisualizerState + { + get + { + return _refreshVisualizerState; + } + private set + { + SetAndRaise(RefreshVisualizerStateProperty, ref _refreshVisualizerState, value); + UpdateContent(); + } + } + + /// + /// Gets or sets a value that indicates the orientation of the visualizer. + /// + public RefreshVisualizerOrientation Orientation + { + get + { + return _orientation; + } + set + { + SetAndRaise(OrientationProperty, ref _orientation, value); + } + } + + internal PullDirection PullDirection + { + get => GetValue(PullDirectionProperty); + set => SetValue(PullDirectionProperty, value); + } + + internal RefreshInfoProvider? RefreshInfoProvider + { + get => _refreshInfoProvider; + set + { + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.RenderTransform = null; + } + SetAndRaise(RefreshInfoProviderProperty, ref _refreshInfoProvider, value); + } + } + + /// + /// Occurs when an update of the content has been initiated. + /// + public event EventHandler? RefreshRequested + { + add => AddHandler(RefreshRequestedEvent, value); + remove => RemoveHandler(RefreshRequestedEvent, value); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + this.ClipToBounds = false; + + _root = e.NameScope.Find("PART_Root"); + + if (_root != null) + { + _content = Content as Control; + + if (_content == null) + { + _content = new PathIcon() + { + Height = DefaultIndicatorSize, + Width = DefaultIndicatorSize, + Name = "PART_Icon" + }; + + _content.Loaded += (s, e) => + { + var composition = ElementComposition.GetElementVisual(_content); + var compositor = composition!.Compositor; + composition.Opacity = 0; + + var smoothRotationAnimation + = compositor.CreateScalarKeyFrameAnimation(); + smoothRotationAnimation.Target = "RotationAngle"; + smoothRotationAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing()); + smoothRotationAnimation.Duration = TimeSpan.FromMilliseconds(100); + + var opacityAnimation + = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.Target = "Opacity"; + opacityAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing()); + opacityAnimation.Duration = TimeSpan.FromMilliseconds(100); + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing()); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(150); + + var scaleAnimation + = compositor.CreateVector3KeyFrameAnimation(); + scaleAnimation.Target = "Scale"; + scaleAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue", new LinearEasing()); + scaleAnimation.Duration = TimeSpan.FromMilliseconds(100); + + var animation = compositor.CreateImplicitAnimationCollection(); + animation["RotationAngle"] = smoothRotationAnimation; + animation["Offset"] = offsetAnimation; + animation["Scale"] = scaleAnimation; + animation["Opacity"] = opacityAnimation; + + composition.ImplicitAnimations = animation; + + UpdateContent(); + }; + + Content = _content; + } + else + { + RaisePropertyChanged(ContentProperty, null, Content, Data.BindingPriority.Style, false); + } + } + + OnOrientationChanged(); + + UpdateContent(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + UpdateContent(); + } + + private void UpdateContent() + { + if (_content != null && _root != null) + { + var root = _root; + var visual = _refreshInfoProvider?.Visual; + var contentVisual = ElementComposition.GetElementVisual(_content); + var visualizerVisual = ElementComposition.GetElementVisual(this); + if (visual != null && contentVisual != null && visualizerVisual != null) + { + contentVisual.CenterPoint = new Vector3((float)(_content.Bounds.Width / 2), (float)(_content.Bounds.Height / 2), 0); + switch (RefreshVisualizerState) + { + case RefreshVisualizerState.Idle: + _played = false; + if(_rotateAnimation != null) + { + _rotateAnimation.IterationBehavior = AnimationIterationBehavior.Count; + _rotateAnimation = null; + } + contentVisual.Opacity = MinimumIndicatorOpacity; + contentVisual.RotationAngle = _startingRotationAngle; + visualizerVisual.Offset = IsPullDirectionVertical ? + new Vector3(visualizerVisual.Offset.X, 0, 0) : + new Vector3(0, visualizerVisual.Offset.Y, 0); + visual.Offset = default; + _content.InvalidateMeasure(); + break; + case RefreshVisualizerState.Interacting: + _played = false; + contentVisual.Opacity = MinimumIndicatorOpacity; + contentVisual.RotationAngle = (float)(_startingRotationAngle + _interactionRatio * 2 * Math.PI); + Vector3 offset = default; + if (IsPullDirectionVertical) + { + offset = new Vector3(0, (float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0); + } + else + { + offset = new Vector3((float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0); + } + visual.Offset = offset; + visualizerVisual.Offset = IsPullDirectionVertical ? + new Vector3(visualizerVisual.Offset.X, offset.Y, 0) : + new Vector3(offset.X, visualizerVisual.Offset.Y, 0); + break; + case RefreshVisualizerState.Pending: + contentVisual.Opacity = 1; + contentVisual.RotationAngle = _startingRotationAngle + (float)(2 * Math.PI); + if (IsPullDirectionVertical) + { + offset = new Vector3(0, (float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0); + } + else + { + offset = new Vector3((float)(_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0); + } + visual.Offset = offset; + visualizerVisual.Offset = IsPullDirectionVertical ? + new Vector3(visualizerVisual.Offset.X, offset.Y, 0) : + new Vector3(offset.X, visualizerVisual.Offset.Y, 0); + + if (!_played) + { + _played = true; + var scaleAnimation = contentVisual.Compositor!.CreateVector3KeyFrameAnimation(); + scaleAnimation.Target = "Scale"; + scaleAnimation.InsertKeyFrame(0.5f, new Vector3(1.5f, 1.5f, 1)); + scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1)); + scaleAnimation.Duration = TimeSpan.FromSeconds(0.3); + + contentVisual.StartAnimation("Scale", scaleAnimation); + } + break; + case RefreshVisualizerState.Refreshing: + _rotateAnimation = contentVisual.Compositor!.CreateScalarKeyFrameAnimation(); + _rotateAnimation.Target = "RotationAngle"; + _rotateAnimation.InsertKeyFrame(0, _startingRotationAngle, new LinearEasing()); + _rotateAnimation.InsertKeyFrame(1, _startingRotationAngle + (float)(2 * Math.PI), new LinearEasing()); + _rotateAnimation.IterationBehavior = AnimationIterationBehavior.Forever; + _rotateAnimation.StopBehavior = AnimationStopBehavior.LeaveCurrentValue; + _rotateAnimation.Duration = TimeSpan.FromSeconds(0.5); + + contentVisual.StartAnimation("RotationAngle", _rotateAnimation); + contentVisual.Opacity = 1; + float translationRatio = (float)(_refreshInfoProvider != null ? (1.0f - _refreshInfoProvider.ExecutionRatio) * ParallaxPositionRatio : 1.0f) + * (IsPullDirectionFar ? -1f : 1f); + if (IsPullDirectionVertical) + { + offset = new Vector3(0, (float)(_executingRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0); + } + else + { + offset = new Vector3((float)(_executingRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0); + } + visual.Offset = offset; + contentVisual.Offset += IsPullDirectionVertical ? new Vector3(0, (float)(translationRatio * root.Bounds.Height), 0) : + new Vector3((float)(translationRatio * root.Bounds.Width), 0, 0); + visualizerVisual.Offset = IsPullDirectionVertical ? + new Vector3(visualizerVisual.Offset.X, offset.Y, 0) : + new Vector3(offset.X, visualizerVisual.Offset.Y, 0); + break; + case RefreshVisualizerState.Peeking: + contentVisual.Opacity = 1; + contentVisual.RotationAngle = _startingRotationAngle; + break; + } + } + } + } + + /// + /// Initiates an update of the content. + /// + public void RequestRefresh() + { + RefreshVisualizerState = RefreshVisualizerState.Refreshing; + RefreshInfoProvider?.OnRefreshStarted(); + + RaiseRefreshRequested(); + } + + private void RefreshCompleted() + { + RefreshVisualizerState = RefreshVisualizerState.Idle; + + RefreshInfoProvider?.OnRefreshCompleted(); + } + + private void RaiseRefreshRequested() + { + var refreshArgs = new RefreshRequestedEventArgs(RefreshCompleted, RefreshRequestedEvent); + + refreshArgs.IncrementCount(); + + RaiseEvent(refreshArgs); + + refreshArgs.DecrementCount(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RefreshInfoProviderProperty) + { + OnRefreshInfoProviderChanged(); + } + else if (change.Property == ContentProperty) + { + if (_root != null && _content != null) + { + _root.Children.Insert(0, _content); + _content.VerticalAlignment = Layout.VerticalAlignment.Center; + _content.HorizontalAlignment = Layout.HorizontalAlignment.Center; + } + + UpdateContent(); + } + else if (change.Property == OrientationProperty) + { + OnOrientationChanged(); + + UpdateContent(); + } + else if (change.Property == BoundsProperty) + { + switch (PullDirection) + { + case PullDirection.TopToBottom: + RenderTransform = new TranslateTransform(0, -Bounds.Height); + break; + case PullDirection.BottomToTop: + RenderTransform = new TranslateTransform(0, Bounds.Height); + break; + case PullDirection.LeftToRight: + RenderTransform = new TranslateTransform(-Bounds.Width, 0); + break; + case PullDirection.RightToLeft: + RenderTransform = new TranslateTransform(Bounds.Width, 0); + break; + } + + UpdateContent(); + } + else if(change.Property == PullDirectionProperty) + { + OnOrientationChanged(); + + UpdateContent(); + } + } + + private void OnOrientationChanged() + { + switch (_orientation) + { + case RefreshVisualizerOrientation.Auto: + switch (PullDirection) + { + case PullDirection.TopToBottom: + case PullDirection.BottomToTop: + _startingRotationAngle = 0.0f; + break; + case PullDirection.LeftToRight: + _startingRotationAngle = (float)(-Math.PI / 2); + break; + case PullDirection.RightToLeft: + _startingRotationAngle = (float)(Math.PI / 2); + break; + } + break; + case RefreshVisualizerOrientation.Normal: + _startingRotationAngle = 0.0f; + break; + case RefreshVisualizerOrientation.Rotate90DegreesCounterclockwise: + _startingRotationAngle = (float)(Math.PI / 2); + break; + case RefreshVisualizerOrientation.Rotate270DegreesCounterclockwise: + _startingRotationAngle = (float)(-Math.PI / 2); + break; + } + } + + private void OnRefreshInfoProviderChanged() + { + _isInteractingSubscription?.Dispose(); + _isInteractingSubscription = null; + _interactionRatioSubscription?.Dispose(); + _interactionRatioSubscription = null; + + if (RefreshInfoProvider != null) + { + _isInteractingSubscription = RefreshInfoProvider.GetObservable(RefreshInfoProvider.IsInteractingForRefreshProperty) + .Subscribe(InteractingForRefreshObserver); + + _interactionRatioSubscription = RefreshInfoProvider.GetObservable(RefreshInfoProvider.InteractionRatioProperty) + .Subscribe(InteractionRatioObserver); + + var visual = RefreshInfoProvider.Visual; + + _executingRatio = RefreshInfoProvider.ExecutionRatio; + } + else + { + _executingRatio = 1; + } + } + + private void InteractionRatioObserver(double obj) + { + var wasAtZero = _interactionRatio == 0.0; + _interactionRatio = obj; + + if (_isInteractingForRefresh) + { + if (RefreshVisualizerState == RefreshVisualizerState.Idle) + { + if (wasAtZero) + { + if (_interactionRatio > _executingRatio) + { + RefreshVisualizerState = RefreshVisualizerState.Pending; + } + else if (_interactionRatio > 0) + { + RefreshVisualizerState = RefreshVisualizerState.Interacting; + } + } + else if (_interactionRatio > 0) + { + RefreshVisualizerState = RefreshVisualizerState.Peeking; + } + } + else if (RefreshVisualizerState == RefreshVisualizerState.Interacting) + { + if (_interactionRatio <= 0) + { + RefreshVisualizerState = RefreshVisualizerState.Idle; + } + else if (_interactionRatio > _executingRatio) + { + RefreshVisualizerState = RefreshVisualizerState.Pending; + } + else + { + UpdateContent(); + } + } + else if (RefreshVisualizerState == RefreshVisualizerState.Pending) + { + if (_interactionRatio <= _executingRatio) + { + RefreshVisualizerState = RefreshVisualizerState.Interacting; + } + else if (_interactionRatio <= 0) + { + RefreshVisualizerState = RefreshVisualizerState.Idle; + } + else + { + UpdateContent(); + } + } + } + else + { + if (RefreshVisualizerState != RefreshVisualizerState.Refreshing) + { + if (_interactionRatio > 0) + { + RefreshVisualizerState = RefreshVisualizerState.Peeking; + } + else + { + RefreshVisualizerState = RefreshVisualizerState.Idle; + } + } + } + } + + private void InteractingForRefreshObserver(bool obj) + { + _isInteractingForRefresh = obj; + + if (!_isInteractingForRefresh) + { + switch (_refreshVisualizerState) + { + case RefreshVisualizerState.Pending: + RequestRefresh(); + break; + case RefreshVisualizerState.Refreshing: + // We don't want to interrupt a currently executing refresh. + break; + default: + RefreshVisualizerState = RefreshVisualizerState.Idle; + break; + } + } + } + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerOrientation.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerOrientation.cs new file mode 100644 index 0000000000..1ea37f67b9 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerOrientation.cs @@ -0,0 +1,13 @@ +namespace Avalonia.Controls +{ + /// + /// Defines constants that specify the orientation of a RefreshVisualizer. + /// + public enum RefreshVisualizerOrientation + { + Auto, + Normal, + Rotate90DegreesCounterclockwise, + Rotate270DegreesCounterclockwise + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerState.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerState.cs new file mode 100644 index 0000000000..5ab52f4de6 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizerState.cs @@ -0,0 +1,14 @@ +namespace Avalonia.Controls +{ + /// + /// Defines constants that specify the state of a RefreshVisualizer + /// + public enum RefreshVisualizerState + { + Idle, + Peeking, + Interacting, + Pending, + Refreshing + } +} diff --git a/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs new file mode 100644 index 0000000000..c3aebc82c5 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs @@ -0,0 +1,274 @@ +using System; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Rendering.Composition; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.PullToRefresh +{ + internal class ScrollViewerIRefreshInfoProviderAdapter + { + private const int MaxSearchDepth = 10; + private const int InitialOffsetThreshold = 1; + + private PullDirection _refreshPullDirection; + private ScrollViewer? _scrollViewer; + private RefreshInfoProvider? _refreshInfoProvider; + private PullGestureRecognizer? _pullGestureRecognizer; + private InputElement? _interactionSource; + private bool _isVisualizerInteractionSourceAttached; + + public ScrollViewerIRefreshInfoProviderAdapter(PullDirection pullDirection) + { + _refreshPullDirection = pullDirection; + } + + public RefreshInfoProvider? AdaptFromTree(Visual root, Size? refreshVIsualizerSize) + { + if (root is ScrollViewer scrollViewer) + { + return Adapt(scrollViewer, refreshVIsualizerSize); + } + else + { + int depth = 0; + while (depth < MaxSearchDepth) + { + var scroll = AdaptFromTreeRecursiveHelper(root, depth); + + if (scroll != null) + { + return Adapt(scroll, refreshVIsualizerSize); + } + + depth++; + } + } + + ScrollViewer? AdaptFromTreeRecursiveHelper(Visual root, int depth) + { + if (depth == 0) + { + foreach (var child in root.VisualChildren) + { + if (child is ScrollViewer viewer) + { + return viewer; + } + } + } + else + { + foreach (var child in root.VisualChildren) + { + var viewer = AdaptFromTreeRecursiveHelper(child, depth - 1); + if (viewer != null) + { + return viewer; + } + } + } + + return null; + } + + return null; + } + + public RefreshInfoProvider Adapt(ScrollViewer adaptee, Size? refreshVIsualizerSize) + { + if (adaptee == null) + { + throw new ArgumentNullException(nameof(adaptee), "Adaptee cannot be null"); + } + + if (_scrollViewer != null) + { + CleanUpScrollViewer(); + } + + if (_refreshInfoProvider != null && _interactionSource != null) + { + _interactionSource.RemoveHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered); + _interactionSource.RemoveHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); + } + + _refreshInfoProvider = null; + _scrollViewer = adaptee; + + if (_scrollViewer.Content == null) + { + throw new ArgumentException(nameof(adaptee), "Adaptee's content property cannot be null."); + } + + var content = adaptee.Content as Visual; + + if (content == null) + { + throw new ArgumentException(nameof(adaptee), "Adaptee's content property must be a Visual"); + } + + if (content.GetVisualParent() == null) + { + _scrollViewer.Loaded += ScrollViewer_Loaded; + } + else + { + ScrollViewer_Loaded(null, null); + + if (content.Parent is not InputElement) + { + throw new ArgumentException(nameof(adaptee), "Adaptee's content's parent must be a InputElement"); + } + } + + _refreshInfoProvider = new RefreshInfoProvider(_refreshPullDirection, refreshVIsualizerSize, ElementComposition.GetElementVisual(content)); + + _pullGestureRecognizer = new PullGestureRecognizer(_refreshPullDirection); + + if (_interactionSource != null) + { + _interactionSource.GestureRecognizers.Add(_pullGestureRecognizer); + _interactionSource.AddHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered); + _interactionSource.AddHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); + _isVisualizerInteractionSourceAttached = true; + } + + _scrollViewer.PointerPressed += ScrollViewer_PointerPressed; + _scrollViewer.PointerReleased += ScrollViewer_PointerReleased; + _scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged; + + return _refreshInfoProvider; + } + + private void ScrollViewer_ScrollChanged(object? sender, ScrollChangedEventArgs e) + { + if (_isVisualizerInteractionSourceAttached && _refreshInfoProvider != null && _refreshInfoProvider.IsInteractingForRefresh) + { + if (!IsWithinOffsetThreashold()) + { + _refreshInfoProvider.IsInteractingForRefresh = false; + } + } + } + + public void SetAnimations(RefreshVisualizer refreshVisualizer) + { + var visualizerComposition = ElementComposition.GetElementVisual(refreshVisualizer); + if (visualizerComposition != null) + { + var compositor = visualizerComposition.Compositor; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(150); + + var animation = compositor.CreateImplicitAnimationCollection(); + animation["Offset"] = offsetAnimation; + visualizerComposition.ImplicitAnimations = animation; + } + + if(_scrollViewer != null && _scrollViewer.Content is Visual visual) + { + var scollContentComposition = ElementComposition.GetElementVisual(visual); + + if(scollContentComposition != null) + { + var compositor = scollContentComposition.Compositor; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(150); + + var animation = compositor.CreateImplicitAnimationCollection(); + animation["Offset"] = offsetAnimation; + scollContentComposition.ImplicitAnimations = animation; + } + } + } + + private void ScrollViewer_Loaded(object? sender, RoutedEventArgs? e) + { + var content = _scrollViewer?.Content as Visual; + if (content == null) + { + throw new ArgumentException(nameof(_scrollViewer), "Adaptee's content property must be a Visual"); + } + + if (content.Parent is not InputElement) + { + throw new ArgumentException(nameof(_scrollViewer), "Adaptee's content parent must be an InputElement"); + } + + MakeInteractionSource(content.Parent as InputElement); + + if (_scrollViewer != null) + { + _scrollViewer.Loaded -= ScrollViewer_Loaded; + } + } + + private void MakeInteractionSource(InputElement? element) + { + _interactionSource = element; + + if (_pullGestureRecognizer != null && _refreshInfoProvider != null) + { + element?.GestureRecognizers.Add(_pullGestureRecognizer); + _interactionSource?.AddHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered); + _interactionSource?.AddHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); + _isVisualizerInteractionSourceAttached = true; + } + } + + private void ScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.IsInteractingForRefresh = false; + } + } + + private void ScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.PeekingMode = !IsWithinOffsetThreashold(); + } + } + + private bool IsWithinOffsetThreashold() + { + if (_scrollViewer != null) + { + var offset = _scrollViewer.Offset; + + switch (_refreshPullDirection) + { + case PullDirection.TopToBottom: + return offset.Y < InitialOffsetThreshold; + case PullDirection.LeftToRight: + return offset.X < InitialOffsetThreshold; + case PullDirection.RightToLeft: + return offset.X > _scrollViewer.Extent.Width - _scrollViewer.Viewport.Width - InitialOffsetThreshold; + case PullDirection.BottomToTop: + return offset.Y > _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height - InitialOffsetThreshold; + } + } + + return false; + } + + private void CleanUpScrollViewer() + { + if (_scrollViewer != null) + { + _scrollViewer.PointerPressed -= ScrollViewer_PointerPressed; + _scrollViewer.PointerReleased -= ScrollViewer_PointerReleased; + _scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged; + } + } + } +} diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index c8e05e5cb3..08156ae00f 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -630,7 +630,7 @@ namespace Avalonia.Controls } else { - textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties); + textSource = new SimpleTextSource(text ?? "", defaultProperties); } return new TextLayout( @@ -829,12 +829,12 @@ namespace Avalonia.Controls protected readonly struct SimpleTextSource : ITextSource { - private readonly ReadOnlySlice _text; + private readonly CharacterBufferRange _text; private readonly TextRunProperties _defaultProperties; - public SimpleTextSource(ReadOnlySlice text, TextRunProperties defaultProperties) + public SimpleTextSource(string text, TextRunProperties defaultProperties) { - _text = text; + _text = new CharacterBufferRange(new CharacterBufferReference(text), text.Length); _defaultProperties = defaultProperties; } @@ -852,7 +852,7 @@ namespace Avalonia.Controls return new TextEndOfParagraph(); } - return new TextCharacters(runText, _defaultProperties); + return new TextCharacters(runText.CharacterBufferReference, runText.Length, _defaultProperties); } } @@ -873,21 +873,28 @@ namespace Avalonia.Controls foreach (var textRun in _textRuns) { - if (textRun.TextSourceLength == 0) + if (textRun.Length == 0) { continue; } - if (textSourceIndex >= currentPosition + textRun.TextSourceLength) + if (textSourceIndex >= currentPosition + textRun.Length) { - currentPosition += textRun.TextSourceLength; + currentPosition += textRun.Length; continue; } - if (textRun is TextCharacters) + if (textRun is TextCharacters) { - return new TextCharacters(textRun.Text.Skip(Math.Max(0, textSourceIndex - currentPosition)), textRun.Properties!); + var characterBufferReference = textRun.CharacterBufferReference; + + var skip = Math.Max(0, textSourceIndex - currentPosition); + + return new TextCharacters( + new CharacterBufferReference(characterBufferReference.CharacterBuffer, characterBufferReference.OffsetToFirstChar + skip), + textRun.Length - skip, + textRun.Properties!); } return textRun; diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 699170df5a..1bdec878d9 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -961,7 +961,9 @@ namespace Avalonia.Controls var length = 0; - var graphemeEnumerator = new GraphemeEnumerator(input.AsMemory()); + var inputRange = new CharacterBufferRange(new CharacterBufferReference(input), input.Length); + + var graphemeEnumerator = new GraphemeEnumerator(inputRange); while (graphemeEnumerator.MoveNext()) { diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 6a79760011..52815b943d 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -77,12 +77,14 @@ namespace Avalonia.Controls foreach (var run in textLine.TextRuns) { - if(run.Text.Length > 0) + if(run.Length > 0) { + var characterBufferRange = new CharacterBufferRange(run.CharacterBufferReference, run.Length); + #if NET6_0 - builder.Append(run.Text.Span); + builder.Append(characterBufferRange.Span); #else - builder.Append(run.Text.Span.ToArray()); + builder.Append(characterBufferRange.Span.ToArray()); #endif } } diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 67da366727..2cd55dc3ab 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -347,12 +347,6 @@ namespace Avalonia.Controls /// protected virtual ILayoutManager CreateLayoutManager() => new LayoutManager(this); - public override void InvalidateMirrorTransform() - { - } - - protected override bool BypassFlowDirectionPolicies => true; - /// /// Handles a paint notification from . /// diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 2fa4a02fa2..ffdd32f95c 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -179,6 +179,26 @@ namespace Avalonia.Controls } } + /// + /// Collapse the specified all descendent s. + /// + /// The item to collapse. + public void CollapseSubTree(TreeViewItem item) + { + item.IsExpanded = false; + + if (item.Presenter?.Panel != null) + { + foreach (var child in item.Presenter.Panel.Children) + { + if (child is TreeViewItem treeViewItem) + { + CollapseSubTree(treeViewItem); + } + } + } + } + /// /// Selects all items in the . /// diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 9bfcf5adfa..18245bd682 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Linq; using Avalonia.Controls.Generators; using Avalonia.Controls.Metadata; @@ -166,30 +168,94 @@ namespace Avalonia.Controls { if (!e.Handled) { - switch (e.Key) + Func? handler = + e.Key switch + { + Key.Left => ApplyToItemOrRecursivelyIfCtrl(FocusAwareCollapseItem, e.KeyModifiers), + Key.Right => ApplyToItemOrRecursivelyIfCtrl(ExpandItem, e.KeyModifiers), + Key.Enter or Key.Space => ApplyToItemOrRecursivelyIfCtrl(IsExpanded ? CollapseItem : ExpandItem, e.KeyModifiers), + + // do not handle CTRL with numpad keys + Key.Subtract => FocusAwareCollapseItem, + Key.Add => ExpandItem, + Key.Divide => ApplyToSubtree(CollapseItem), + Key.Multiply => ApplyToSubtree(ExpandItem), + _ => null, + }; + + if (handler is not null) + { + e.Handled = handler(this); + } + + // NOTE: these local functions do not use the TreeView.Expand/CollapseSubtree + // function because we want to know if any items were in fact expanded to set the + // event handled status. Also the handling here avoids a potential infinite recursion/stack overflow. + static Func ApplyToSubtree(Func f) + { + // Calling toList enumerates all items before applying functions. This avoids a + // potential infinite loop if there is an infinite tree (the control catalog is + // lazily infinite). But also means a lazily loaded tree will not be expanded completely. + return t => SubTree(t) + .ToList() + .Select(treeViewItem => f(treeViewItem)) + .Aggregate(false, (p, c) => p || c); + } + + static Func ApplyToItemOrRecursivelyIfCtrl(Func f, KeyModifiers keyModifiers) + { + if (keyModifiers.HasAllFlags(KeyModifiers.Control)) + { + return ApplyToSubtree(f); + } + + return f; + } + + static bool ExpandItem(TreeViewItem treeViewItem) + { + if (treeViewItem.ItemCount > 0 && !treeViewItem.IsExpanded) + { + treeViewItem.IsExpanded = true; + return true; + } + + return false; + } + + static bool CollapseItem(TreeViewItem treeViewItem) { - case Key.Right: - if (Items != null && Items.Cast().Any() && !IsExpanded) + if (treeViewItem.ItemCount > 0 && treeViewItem.IsExpanded) + { + treeViewItem.IsExpanded = false; + return true; + } + + return false; + } + + static bool FocusAwareCollapseItem(TreeViewItem treeViewItem) + { + if (treeViewItem.ItemCount > 0 && treeViewItem.IsExpanded) + { + if (treeViewItem.IsFocused) { - IsExpanded = true; - e.Handled = true; + treeViewItem.IsExpanded = false; } - break; - - case Key.Left: - if (Items is not null && Items.Cast().Any() && IsExpanded) + else { - if (IsFocused) - { - IsExpanded = false; - } - else - { - FocusManager.Instance?.Focus(this, NavigationMethod.Directional); - } - e.Handled = true; + FocusManager.Instance?.Focus(treeViewItem, NavigationMethod.Directional); } - break; + + return true; + } + + return false; + } + + static IEnumerable SubTree(TreeViewItem treeViewItem) + { + return new[] { treeViewItem }.Concat(treeViewItem.LogicalChildren.OfType().SelectMany(child => SubTree(child))); } } @@ -198,8 +264,19 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { + if (_header is InputElement previousInputMethod) + { + previousInputMethod.DoubleTapped -= HeaderDoubleTapped; + } + _header = e.NameScope.Find("PART_Header"); _templateApplied = true; + + if (_header is InputElement im) + { + im.DoubleTapped += HeaderDoubleTapped; + } + if (_deferredBringIntoViewFlag) { _deferredBringIntoViewFlag = false; @@ -220,6 +297,15 @@ namespace Avalonia.Controls return logical != null ? result : @default; } + private void HeaderDoubleTapped(object? sender, TappedEventArgs e) + { + if (ItemCount > 0) + { + IsExpanded = !IsExpanded; + e.Handled = true; + } + } + private void OnParentChanged(AvaloniaPropertyChangedEventArgs e) { if (!((ILogical)this).IsAttachedToLogicalTree && e.NewValue is null) diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 559d674c02..a893c74324 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -176,6 +176,8 @@ namespace Avalonia.Controls private object? _dialogResult; private readonly Size _maxPlatformClientSize; private WindowStartupLocation _windowStartupLocation; + private bool _shown; + private bool _showingAsDialog; /// /// Initializes static members of the class. @@ -508,6 +510,8 @@ namespace Avalonia.Controls Owner = null; PlatformImpl?.Dispose(); + + _showingAsDialog = false; } private bool ShouldCancelClose(CancelEventArgs? args = null) @@ -563,29 +567,33 @@ namespace Avalonia.Controls /// public override void Hide() { - if (!IsVisible) + using (FreezeVisibilityChangeHandling()) { - return; - } + if (!_shown) + { + return; + } - Renderer?.Stop(); + Renderer?.Stop(); - if (Owner is Window owner) - { - owner.RemoveChild(this); - } + if (Owner is Window owner) + { + owner.RemoveChild(this); + } - if (_children.Count > 0) - { - foreach (var child in _children.ToArray()) + if (_children.Count > 0) { - child.child.Hide(); + foreach (var child in _children.ToArray()) + { + child.child.Hide(); + } } - } - Owner = null; - PlatformImpl?.Hide(); - IsVisible = false; + Owner = null; + PlatformImpl?.Hide(); + IsVisible = false; + _shown = false; + } } /// @@ -599,81 +607,124 @@ namespace Avalonia.Controls ShowCore(null); } + protected override void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) + { + if (!IgnoreVisibilityChanges) + { + var isVisible = e.GetNewValue(); + + if (_shown != isVisible) + { + if(!_shown) + { + Show(); + } + else + { + if (_showingAsDialog) + { + Close(false); + } + else + { + Hide(); + } + } + } + } + } + /// - /// Shows the window as a child of . + /// Shows the window as a child of . /// - /// Window that will be a parent of the shown window. + /// Window that will be the owner of the shown window. /// /// The window has already been closed. /// - public void Show(Window parent) + public void Show(Window owner) { - if (parent is null) + if (owner is null) { - throw new ArgumentNullException(nameof(parent), "Showing a child window requires valid parent."); + throw new ArgumentNullException(nameof(owner), "Showing a child window requires valid parent."); } - ShowCore(parent); + ShowCore(owner); } - private void ShowCore(Window? parent) + private void EnsureStateBeforeShow() { if (PlatformImpl == null) { throw new InvalidOperationException("Cannot re-show a closed window."); } + } + + private void EnsureParentStateBeforeShow(Window owner) + { + if (owner.PlatformImpl == null) + { + throw new InvalidOperationException("Cannot show a window with a closed owner."); + } - if (parent != null) + if (owner == this) { - if (parent.PlatformImpl == null) - { - throw new InvalidOperationException("Cannot show a window with a closed parent."); - } - else if (parent == this) - { - throw new InvalidOperationException("A Window cannot be its own parent."); - } - else if (!parent.IsVisible) - { - throw new InvalidOperationException("Cannot show window with non-visible parent."); - } + throw new InvalidOperationException("A Window cannot be its own owner."); } - if (IsVisible) + if (!owner.IsVisible) { - return; + throw new InvalidOperationException("Cannot show window with non-visible owner."); } + } - RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); + private void ShowCore(Window? owner) + { + using (FreezeVisibilityChangeHandling()) + { + EnsureStateBeforeShow(); - EnsureInitialized(); - ApplyStyling(); - IsVisible = true; + if (owner != null) + { + EnsureParentStateBeforeShow(owner); + } - var initialSize = new Size( - double.IsNaN(Width) ? Math.Max(MinWidth, ClientSize.Width) : Width, - double.IsNaN(Height) ? Math.Max(MinHeight, ClientSize.Height) : Height); + if (_shown) + { + return; + } - if (initialSize != ClientSize) - { - PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); - } + RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); - LayoutManager.ExecuteInitialLayoutPass(); + EnsureInitialized(); + ApplyStyling(); + _shown = true; + IsVisible = true; - if (PlatformImpl != null && parent?.PlatformImpl is not null) - { - PlatformImpl.SetParent(parent.PlatformImpl); - } + var initialSize = new Size( + double.IsNaN(Width) ? Math.Max(MinWidth, ClientSize.Width) : Width, + double.IsNaN(Height) ? Math.Max(MinHeight, ClientSize.Height) : Height); - Owner = parent; - parent?.AddChild(this, false); + if (initialSize != ClientSize) + { + PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); + } - SetWindowStartupLocation(parent?.PlatformImpl); + LayoutManager.ExecuteInitialLayoutPass(); - PlatformImpl?.Show(ShowActivated, false); - Renderer?.Start(); - OnOpened(EventArgs.Empty); + if (PlatformImpl != null && owner?.PlatformImpl is not null) + { + PlatformImpl.SetParent(owner.PlatformImpl); + } + + Owner = owner; + owner?.AddChild(this, false); + + SetWindowStartupLocation(owner?.PlatformImpl); + + PlatformImpl?.Show(ShowActivated, false); + Renderer?.Start(); + OnOpened(EventArgs.Empty); + } } /// @@ -703,68 +754,66 @@ namespace Avalonia.Controls /// public Task ShowDialog(Window owner) { - if (owner == null) - { - throw new ArgumentNullException(nameof(owner)); - } - else if (owner.PlatformImpl == null) - { - throw new InvalidOperationException("Cannot show a window with a closed owner."); - } - else if (owner == this) + using (FreezeVisibilityChangeHandling()) { - throw new InvalidOperationException("A Window cannot be its own owner."); - } - else if (IsVisible) - { - throw new InvalidOperationException("The window is already being shown."); - } - else if (!owner.IsVisible) - { - throw new InvalidOperationException("Cannot show window with non-visible parent."); - } + EnsureStateBeforeShow(); - RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); + if (owner == null) + { + throw new ArgumentNullException(nameof(owner)); + } - EnsureInitialized(); - ApplyStyling(); - IsVisible = true; + EnsureParentStateBeforeShow(owner); - var initialSize = new Size( - double.IsNaN(Width) ? ClientSize.Width : Width, - double.IsNaN(Height) ? ClientSize.Height : Height); + if (_shown) + { + throw new InvalidOperationException("The window is already being shown."); + } - if (initialSize != ClientSize) - { - PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); - } + RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); - LayoutManager.ExecuteInitialLayoutPass(); + EnsureInitialized(); + ApplyStyling(); + _shown = true; + _showingAsDialog = true; + IsVisible = true; - var result = new TaskCompletionSource(); + var initialSize = new Size( + double.IsNaN(Width) ? ClientSize.Width : Width, + double.IsNaN(Height) ? ClientSize.Height : Height); - PlatformImpl?.SetParent(owner.PlatformImpl); - Owner = owner; - owner.AddChild(this, true); + if (initialSize != ClientSize) + { + PlatformImpl?.Resize(initialSize, PlatformResizeReason.Layout); + } - SetWindowStartupLocation(owner.PlatformImpl); + LayoutManager.ExecuteInitialLayoutPass(); - PlatformImpl?.Show(ShowActivated, true); + var result = new TaskCompletionSource(); - Renderer?.Start(); + PlatformImpl?.SetParent(owner.PlatformImpl!); + Owner = owner; + owner.AddChild(this, true); - Observable.FromEventPattern( - x => Closed += x, - x => Closed -= x) - .Take(1) - .Subscribe(_ => - { - owner.Activate(); - result.SetResult((TResult)(_dialogResult ?? default(TResult)!)); - }); + SetWindowStartupLocation(owner.PlatformImpl); - OnOpened(EventArgs.Empty); - return result.Task; + PlatformImpl?.Show(ShowActivated, true); + + Renderer?.Start(); + + Observable.FromEventPattern( + x => Closed += x, + x => Closed -= x) + .Take(1) + .Subscribe(_ => + { + owner.Activate(); + result.SetResult((TResult)(_dialogResult ?? default(TResult)!)); + }); + + OnOpened(EventArgs.Empty); + return result.Task; + } } private void UpdateEnabled() diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index 8f1b2198ad..b71dc6df44 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -42,9 +42,11 @@ namespace Avalonia.Controls private bool _hasExecutedInitialLayoutPass; private bool _isActive; - private bool _ignoreVisibilityChange; + private int _ignoreVisibilityChanges; private WindowBase? _owner; + protected bool IgnoreVisibilityChanges => _ignoreVisibilityChanges > 0; + static WindowBase() { IsVisibleProperty.OverrideDefaultValue(false); @@ -66,6 +68,11 @@ namespace Avalonia.Controls impl.PositionChanged = HandlePositionChanged; } + protected IDisposable FreezeVisibilityChangeHandling() + { + return new IgnoreVisibilityChangesDisposable(this); + } + /// /// Fired when the window is activated. /// @@ -125,18 +132,12 @@ namespace Avalonia.Controls /// public virtual void Hide() { - _ignoreVisibilityChange = true; - - try + using (FreezeVisibilityChangeHandling()) { Renderer?.Stop(); PlatformImpl?.Hide(); IsVisible = false; } - finally - { - _ignoreVisibilityChange = false; - } } /// @@ -144,9 +145,7 @@ namespace Avalonia.Controls /// public virtual void Show() { - _ignoreVisibilityChange = true; - - try + using (FreezeVisibilityChangeHandling()) { EnsureInitialized(); ApplyStyling(); @@ -157,14 +156,11 @@ namespace Avalonia.Controls LayoutManager.ExecuteInitialLayoutPass(); _hasExecutedInitialLayoutPass = true; } + PlatformImpl?.Show(true, false); Renderer?.Start(); OnOpened(EventArgs.Empty); } - finally - { - _ignoreVisibilityChange = false; - } } /// @@ -202,23 +198,17 @@ namespace Avalonia.Controls protected override void HandleClosed() { - _ignoreVisibilityChange = true; - - try + using (FreezeVisibilityChangeHandling()) { IsVisible = false; - + if (this is IFocusScope scope) { FocusManager.Instance?.RemoveFocusScope(scope); } - + base.HandleClosed(); } - finally - { - _ignoreVisibilityChange = false; - } } /// @@ -318,9 +308,9 @@ namespace Avalonia.Controls Deactivated?.Invoke(this, EventArgs.Empty); } - private void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) + protected virtual void IsVisibleChanged(AvaloniaPropertyChangedEventArgs e) { - if (!_ignoreVisibilityChange) + if (_ignoreVisibilityChanges == 0) { if ((bool)e.NewValue!) { @@ -332,5 +322,21 @@ namespace Avalonia.Controls } } } + + private readonly struct IgnoreVisibilityChangesDisposable : IDisposable + { + private readonly WindowBase _windowBase; + + public IgnoreVisibilityChangesDisposable(WindowBase windowBase) + { + _windowBase = windowBase; + _windowBase._ignoreVisibilityChanges++; + } + + public void Dispose() + { + _windowBase._ignoreVisibilityChanges--; + } + } } } diff --git a/src/Avalonia.Headless/HeadlessPlatformStubs.cs b/src/Avalonia.Headless/HeadlessPlatformStubs.cs index 688b8e0398..1cc0fa73bb 100644 --- a/src/Avalonia.Headless/HeadlessPlatformStubs.cs +++ b/src/Avalonia.Headless/HeadlessPlatformStubs.cs @@ -145,13 +145,15 @@ namespace Avalonia.Headless class HeadlessTextShaperStub : ITextShaperImpl { - public ShapedBuffer ShapeText(ReadOnlySlice text, TextShaperOptions options) + public ShapedBuffer ShapeText(CharacterBufferReference text, int length, TextShaperOptions options) { var typeface = options.Typeface; var fontRenderingEmSize = options.FontRenderingEmSize; var bidiLevel = options.BidiLevel; - return new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel); + var characterBufferRange = new CharacterBufferRange(text, length); + + return new ShapedBuffer(characterBufferRange, length, typeface, fontRenderingEmSize, bidiLevel); } } diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml index 71a8bc3a3c..810065fc9b 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml @@ -636,4 +636,8 @@ + + + + diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml index d764e1616c..bccc47b9b8 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml @@ -631,4 +631,8 @@ - + + + + + \ No newline at end of file diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index a029be6b8d..9156d11a96 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -68,6 +68,8 @@ + + diff --git a/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml b/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml new file mode 100644 index 0000000000..8e29e6208f --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml b/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml new file mode 100644 index 0000000000..4e76bee9f5 --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml b/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml index 9171791a0f..0bed388ca4 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml @@ -75,6 +75,7 @@ MinHeight="{TemplateBinding MinHeight}" TemplatedControl.IsTemplateFocusTarget="True"> + + + diff --git a/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml b/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml index f96425cf06..77166a9d8a 100644 --- a/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml @@ -31,5 +31,7 @@ - + + + diff --git a/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml b/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml new file mode 100644 index 0000000000..04b2a1bf29 --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml b/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml new file mode 100644 index 0000000000..bd7e43530a --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml index 4aefa0136c..4bad556338 100644 --- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml +++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml @@ -65,6 +65,8 @@ + + diff --git a/src/Avalonia.Themes.Simple/Controls/TreeViewItem.xaml b/src/Avalonia.Themes.Simple/Controls/TreeViewItem.xaml index 6ca3262419..75da4bc579 100644 --- a/src/Avalonia.Themes.Simple/Controls/TreeViewItem.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TreeViewItem.xaml @@ -44,6 +44,7 @@ Focusable="True" TemplatedControl.IsTemplateFocusTarget="True"> text, TextShaperOptions options) + public ShapedBuffer ShapeText(CharacterBufferReference characterBufferReference, int length, TextShaperOptions options) { + var text = new CharacterBufferRange(characterBufferReference, length); var typeface = options.Typeface; var fontRenderingEmSize = options.FontRenderingEmSize; var bidiLevel = options.BidiLevel; @@ -21,21 +21,21 @@ namespace Avalonia.Skia using (var buffer = new Buffer()) { - buffer.AddUtf16(text.Buffer.Span, text.BufferOffset, text.Length); + buffer.AddUtf16(characterBufferReference.CharacterBuffer.Span, characterBufferReference.OffsetToFirstChar, length); MergeBreakPair(buffer); - + buffer.GuessSegmentProperties(); buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft; - buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture); + buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture); var font = ((GlyphTypefaceImpl)typeface).Font; font.Shape(buffer); - if(buffer.Direction == Direction.RightToLeft) + if (buffer.Direction == Direction.RightToLeft) { buffer.Reverse(); } @@ -64,12 +64,12 @@ namespace Avalonia.Skia var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - if(text.Buffer.Span[glyphCluster] == '\t') + if (text[i] == '\t') { glyphIndex = typeface.GetGlyph(' '); - glyphAdvance = options.IncrementalTabWidth > 0 ? - options.IncrementalTabWidth : + glyphAdvance = options.IncrementalTabWidth > 0 ? + options.IncrementalTabWidth : 4 * typeface.GetGlyphAdvance(glyphIndex) * textScale; } @@ -87,7 +87,7 @@ namespace Avalonia.Skia var length = buffer.Length; var glyphInfos = buffer.GetGlyphInfoSpan(); - + var second = glyphInfos[length - 1]; if (!new Codepoint(second.Codepoint).IsBreakChar) @@ -98,7 +98,7 @@ namespace Avalonia.Skia if (length > 1 && glyphInfos[length - 2].Codepoint == '\r' && second.Codepoint == '\n') { var first = glyphInfos[length - 2]; - + first.Codepoint = '\u200C'; second.Codepoint = '\u200C'; second.Cluster = first.Cluster; @@ -109,7 +109,7 @@ namespace Avalonia.Skia { *p = first; } - + fixed (GlyphInfo* p = &glyphInfos[length - 1]) { *p = second; diff --git a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs index 7f2cbc6182..6685dd00b9 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs @@ -3,7 +3,6 @@ using System.Globalization; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Platform; -using Avalonia.Utilities; using HarfBuzzSharp; using Buffer = HarfBuzzSharp.Buffer; using GlyphInfo = HarfBuzzSharp.GlyphInfo; @@ -12,7 +11,7 @@ namespace Avalonia.Direct2D1.Media { internal class TextShaperImpl : ITextShaperImpl { - public ShapedBuffer ShapeText(ReadOnlySlice text, TextShaperOptions options) + public ShapedBuffer ShapeText(CharacterBufferReference characterBufferReference, int length, TextShaperOptions options) { var typeface = options.Typeface; var fontRenderingEmSize = options.FontRenderingEmSize; @@ -21,7 +20,7 @@ namespace Avalonia.Direct2D1.Media using (var buffer = new Buffer()) { - buffer.AddUtf16(text.Buffer.Span, text.BufferOffset, text.Length); + buffer.AddUtf16(characterBufferReference.CharacterBuffer.Span, characterBufferReference.OffsetToFirstChar, length); MergeBreakPair(buffer); @@ -46,7 +45,9 @@ namespace Avalonia.Direct2D1.Media var bufferLength = buffer.Length; - var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel); + var characterBufferRange = new CharacterBufferRange(characterBufferReference, length); + + var shapedBuffer = new ShapedBuffer(characterBufferRange, bufferLength, typeface, fontRenderingEmSize, bidiLevel); var glyphInfos = buffer.GetGlyphInfoSpan(); @@ -64,7 +65,7 @@ namespace Avalonia.Direct2D1.Media var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - if (text.Buffer.Span[glyphCluster] == '\t') + if (characterBufferRange[i] == '\t') { glyphIndex = typeface.GetGlyph(' '); diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs index ca24b95e65..58e908aca9 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs @@ -181,7 +181,7 @@ namespace Avalonia.Base.UnitTests.Animation Assert.Equal(border.Width, 300d); } - [Fact(Skip = "See #6111")] + [Fact] public void Dispose_Subscription_Should_Stop_Animation() { var keyframe1 = new KeyFrame() @@ -310,7 +310,7 @@ namespace Avalonia.Base.UnitTests.Animation Assert.True(animationRun.IsCompleted); } - [Fact(Skip = "See #6111")] + [Fact] public void Cancellation_Should_Stop_Animation() { var keyframe1 = new KeyFrame() @@ -372,7 +372,6 @@ namespace Avalonia.Base.UnitTests.Animation clock.Step(TimeSpan.FromSeconds(1)); clock.Step(TimeSpan.FromSeconds(2)); clock.Step(TimeSpan.FromSeconds(3)); - //Assert.Equal(2, propertyChangedCount); animationRun.Wait(); diff --git a/tests/Avalonia.Controls.UnitTests/FlowDirectionTests.cs b/tests/Avalonia.Base.UnitTests/FlowDirectionTests.cs similarity index 70% rename from tests/Avalonia.Controls.UnitTests/FlowDirectionTests.cs rename to tests/Avalonia.Base.UnitTests/FlowDirectionTests.cs index 6c43103ecb..f790ed7412 100644 --- a/tests/Avalonia.Controls.UnitTests/FlowDirectionTests.cs +++ b/tests/Avalonia.Base.UnitTests/FlowDirectionTests.cs @@ -8,7 +8,7 @@ namespace Avalonia.Controls.UnitTests [Fact] public void HasMirrorTransform_Should_Be_True() { - var target = new Control + var target = new Visual { FlowDirection = FlowDirection.RightToLeft, }; @@ -19,31 +19,36 @@ namespace Avalonia.Controls.UnitTests [Fact] public void HasMirrorTransform_Of_LTR_Children_Should_Be_True_For_RTL_Parent() { - Control child; - var target = new Decorator + var child = new Visual() + { + FlowDirection = FlowDirection.LeftToRight, + }; + + var target = new Visual { FlowDirection = FlowDirection.RightToLeft, - Child = child = new Control() }; + target.VisualChildren.Add(child); - child.FlowDirection = FlowDirection.LeftToRight; + child.InvalidateMirrorTransform(); Assert.True(target.HasMirrorTransform); Assert.True(child.HasMirrorTransform); } [Fact] - public void HasMirrorTransform_Of_Children_Is_Updated_After_Parent_Changeed() + public void HasMirrorTransform_Of_Children_Is_Updated_After_Parent_Changed() { - Control child; + var child = new Visual() + { + FlowDirection = FlowDirection.LeftToRight, + }; + var target = new Decorator { FlowDirection = FlowDirection.LeftToRight, - Child = child = new Control() - { - FlowDirection = FlowDirection.LeftToRight, - } }; + target.VisualChildren.Add(child); Assert.False(target.HasMirrorTransform); Assert.False(child.HasMirrorTransform); diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index 2d8ee62ef2..e36ce21009 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -1,5 +1,7 @@ +using System; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.UnitTests; using Xunit; @@ -25,6 +27,33 @@ namespace Avalonia.Base.UnitTests.Input } } + [Fact] + public void Focus_Should_Not_Get_Restored_To_Enabled_Control() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var sp = new StackPanel(); + Button target = new Button(); + Button target1 = new Button(); + target.Click += (s, e) => target.IsEnabled = false; + target1.Click += (s, e) => target.IsEnabled = true; + sp.Children.Add(target); + sp.Children.Add(target1); + var root = new TestRoot + { + Child = sp + }; + + target.Focus(); + target.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent)); + Assert.False(target.IsEnabled); + Assert.False(target.IsFocused); + target1.RaiseEvent(new RoutedEventArgs(AccessKeyHandler.AccessKeyPressedEvent)); + Assert.True(target.IsEnabled); + Assert.False(target.IsFocused); + } + } + [Fact] public void Focus_Should_Be_Cleared_When_Control_Is_Removed_From_VisualTree() { diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs index df16c1b34f..363fb3f5b3 100644 --- a/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs @@ -181,9 +181,7 @@ namespace Avalonia.Base.UnitTests.Media var count = glyphAdvances.Length; var glyphIndices = new ushort[count]; - var start = bidiLevel == 0 ? glyphClusters[0] : glyphClusters[^1]; - - var characters = new ReadOnlySlice(Enumerable.Repeat('a', count).ToArray(), start, count); + var characters = Enumerable.Repeat('a', count).ToArray(); return new GlyphRun(new MockGlyphTypeface(), 10, characters, glyphIndices, glyphAdvances, glyphClusters: glyphClusters, biDiLevel: bidiLevel); diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs index d2cea45ce1..b2c40f4ff1 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Text; +using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Xunit; using Xunit.Abstractions; @@ -36,7 +37,7 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting var text = Encoding.UTF32.GetString(MemoryMarshal.Cast(t.CodePoints).ToArray()); // Append - bidiData.Append(text.AsMemory()); + bidiData.Append(new CharacterBufferRange(text)); // Act for (int i = 0; i < 10; i++) diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/GraphemeBreakClassTrieGeneratorTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/GraphemeBreakClassTrieGeneratorTests.cs index c9f869cea9..4e0207a85d 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/GraphemeBreakClassTrieGeneratorTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/GraphemeBreakClassTrieGeneratorTests.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using System.Text; +using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Visuals.UnitTests.Media.TextFormatting; using Xunit; @@ -37,11 +38,11 @@ namespace Avalonia.Base.UnitTests.Media.TextFormatting var text = Encoding.UTF32.GetString(MemoryMarshal.Cast(t.Codepoints).ToArray()); var grapheme = Encoding.UTF32.GetString(MemoryMarshal.Cast(t.Grapheme).ToArray()).AsSpan(); - var enumerator = new GraphemeEnumerator(text.AsMemory()); + var enumerator = new GraphemeEnumerator(new CharacterBufferRange(text)); enumerator.MoveNext(); - var actual = enumerator.Current.Text.Span; + var actual = enumerator.Current.Text; var pass = true; @@ -86,9 +87,7 @@ namespace Avalonia.Base.UnitTests.Media.TextFormatting { const string text = "ABCDEFGHIJ"; - var textMemory = text.AsMemory(); - - var enumerator = new GraphemeEnumerator(textMemory); + var enumerator = new GraphemeEnumerator(new CharacterBufferRange(text)); var count = 0; diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumuratorTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumuratorTests.cs index 15be1200c8..b2648bf348 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumuratorTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/LineBreakEnumuratorTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; +using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Xunit; using Xunit.Abstractions; @@ -22,7 +23,7 @@ namespace Avalonia.Base.UnitTests.Media.TextFormatting [Fact] public void BasicLatinTest() { - var lineBreaker = new LineBreakEnumerator("Hello World\r\nThis is a test.".AsMemory()); + var lineBreaker = new LineBreakEnumerator(new CharacterBufferRange("Hello World\r\nThis is a test.")); Assert.True(lineBreaker.MoveNext()); Assert.Equal(6, lineBreaker.Current.PositionWrap); @@ -55,7 +56,7 @@ namespace Avalonia.Base.UnitTests.Media.TextFormatting [Fact] public void ForwardTextWithOuterWhitespace() { - var lineBreaker = new LineBreakEnumerator(" Apples Pears Bananas ".AsMemory()); + var lineBreaker = new LineBreakEnumerator(new CharacterBufferRange(" Apples Pears Bananas ")); var positionsF = GetBreaks(lineBreaker); Assert.Equal(1, positionsF[0].PositionWrap); Assert.Equal(0, positionsF[0].PositionMeasure); @@ -82,7 +83,7 @@ namespace Avalonia.Base.UnitTests.Media.TextFormatting [Fact] public void ForwardTest() { - var lineBreaker = new LineBreakEnumerator("Apples Pears Bananas".AsMemory()); + var lineBreaker = new LineBreakEnumerator(new CharacterBufferRange("Apples Pears Bananas")); var positionsF = GetBreaks(lineBreaker); Assert.Equal(7, positionsF[0].PositionWrap); @@ -99,7 +100,7 @@ namespace Avalonia.Base.UnitTests.Media.TextFormatting { var text = string.Join(null, codePoints.Select(char.ConvertFromUtf32)); - var lineBreaker = new LineBreakEnumerator(text.AsMemory()); + var lineBreaker = new LineBreakEnumerator(new CharacterBufferRange(text)); var foundBreaks = new List(); diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyleTests.cs b/tests/Avalonia.Base.UnitTests/Styling/StyleTests.cs index 805b3e7aa6..4f5d1b8ec8 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyleTests.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyleTests.cs @@ -868,7 +868,53 @@ namespace Avalonia.Base.UnitTests.Styling } [Fact] - public void Animations_Should_Be_Activated_And_Deactivated() + public void Animations_Should_Be_Activated() + { + Style style = new Style(x => x.OfType()) + { + Animations = + { + new Avalonia.Animation.Animation + { + Duration = TimeSpan.FromSeconds(1), + Children = + { + new KeyFrame + { + Setters = + { + new Setter { Property = Class1.DoubleProperty, Value = 5.0 } + }, + }, + new KeyFrame + { + Setters = + { + new Setter { Property = Class1.DoubleProperty, Value = 10.0 } + }, + Cue = new Cue(1d) + } + }, + } + } + }; + + var clock = new TestClock(); + var target = new Class1 { Clock = clock }; + + StyleHelpers.TryAttach(style, target); + + Assert.Equal(0.0, target.Double); + + clock.Step(TimeSpan.Zero); + Assert.Equal(5.0, target.Double); + + clock.Step(TimeSpan.FromSeconds(0.5)); + Assert.Equal(7.5, target.Double); + } + + [Fact] + public void Animations_With_Trigger_Should_Be_Activated_And_Deactivated() { Style style = new Style(x => x.OfType().Class("foo")) { diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs index 672945cb24..b5a9b35134 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -359,16 +359,49 @@ public class StyledElementTests_Theming } [Fact] - public void Implicit_Theme_Is_Cleared_When_Removed_From_Logical_Tree() + public void Implicit_Theme_Is_Not_Detached_When_Removed_From_Logical_Tree() { var target = CreateTarget(); var root = CreateRoot(target); - - Assert.NotNull(target.GetEffectiveTheme()); + + Assert.Equal("theme", target.Tag); root.Child = null; - Assert.Null(target.GetEffectiveTheme()); + var border = Assert.IsType(target.VisualChild); + Assert.Equal("theme", target.Tag); + Assert.Equal("theme", border.Tag); + } + + [Fact] + public void Can_Attach_Then_Reattach_To_Same_Logical_Tree() + { + var target = CreateTarget(); + var root = CreateRoot(target); + + Assert.Equal("theme", target.Tag); + + root.Child = null; + root.Child = target; + + Assert.Equal("theme", target.Tag); + } + + [Fact] + public void Implicit_Theme_Is_Reevaluated_When_Removed_And_Added_To_Different_Logical_Tree() + { + var target = CreateTarget(); + var root1 = CreateRoot(target, "theme1"); + var root2 = CreateRoot(null, "theme2"); + + Assert.Equal("theme1", target.Tag); + + root1.Child = null; + root2.Child = target; + + var border = Assert.IsType(target.VisualChild); + Assert.Equal("theme2", target.Tag); + Assert.Equal("theme2", border.Tag); } [Fact] @@ -402,10 +435,10 @@ public class StyledElementTests_Theming private static ThemedControl CreateTarget() => new ThemedControl(); - private static TestRoot CreateRoot(Control child) + private static TestRoot CreateRoot(Control? child, string themeTag = "theme") { var result = new TestRoot(); - result.Resources.Add(typeof(ThemedControl), CreateTheme()); + result.Resources.Add(typeof(ThemedControl), CreateTheme(themeTag)); result.Child = child; result.LayoutManager.ExecuteInitialLayoutPass(); return result; @@ -530,7 +563,7 @@ public class StyledElementTests_Theming } } - private static ControlTheme CreateTheme() + private static ControlTheme CreateTheme(string tag = "theme") { var template = new FuncControlTemplate((o, n) => new Border()); @@ -539,7 +572,7 @@ public class StyledElementTests_Theming TargetType = typeof(ThemedControl), Setters = { - new Setter(Control.TagProperty, "theme"), + new Setter(Control.TagProperty, tag), new Setter(TemplatedControl.TemplateProperty, template), new Setter(TemplatedControl.CornerRadiusProperty, new CornerRadius(5)), }, @@ -550,7 +583,7 @@ public class StyledElementTests_Theming Setters = { new Setter(Border.BackgroundProperty, Brushes.Red), - new Setter(Control.TagProperty, "theme"), + new Setter(Control.TagProperty, tag), } }, new Style(x => x.Nesting().Class("foo").Template().OfType()) diff --git a/tests/Avalonia.Base.UnitTests/Utilities/ReadOnlySpanTests.cs b/tests/Avalonia.Base.UnitTests/Utilities/ReadOnlySpanTests.cs deleted file mode 100644 index da30ee9d02..0000000000 --- a/tests/Avalonia.Base.UnitTests/Utilities/ReadOnlySpanTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Linq; -using Avalonia.Utilities; -using Xunit; - -namespace Avalonia.Base.UnitTests.Utilities -{ - public class ReadOnlySpanTests - { - [Fact] - public void Should_Skip() - { - var buffer = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; - - var slice = new ReadOnlySlice(buffer); - - var skipped = slice.Skip(2); - - var expected = buffer.Skip(2); - - Assert.Equal(expected, skipped); - } - - [Fact] - public void Should_Take() - { - var buffer = new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; - - var slice = new ReadOnlySlice(buffer); - - var taken = slice.Take(8); - - var expected = buffer.Take(8); - - Assert.Equal(expected, taken); - } - } -} diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs index 8cc8e4c16f..8e06fbd831 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs @@ -46,7 +46,7 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.NotNull(target.TextLayout); var actual = string.Join(null, - target.TextLayout.TextLines.SelectMany(x => x.TextRuns).Select(x => x.Text.Span.ToString())); + target.TextLayout.TextLines.SelectMany(x => x.TextRuns).Select(x => x.CharacterBufferReference.CharacterBuffer.Span.ToString())); Assert.Equal("****", actual); } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 6b9105ccb5..bd6d5d55e2 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -59,6 +59,28 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void TextBox_Should_Lose_Focus_When_Disabled() + { + using (UnitTestApplication.Start(FocusServices)) + { + var target = new TextBox + { + Template = CreateTemplate() + }; + + target.ApplyTemplate(); + + var root = new TestRoot() { Child = target }; + + target.Focus(); + Assert.True(target.IsFocused); + target.IsEnabled = false; + Assert.False(target.IsFocused); + Assert.False(target.IsEnabled); + } + } + [Fact] public void Opening_Context_Flyout_Does_not_Lose_Selection() { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 4f0446d406..cd38bf556a 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -424,6 +424,587 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Double_Clicking_Item_Header_Should_Expand_It() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); + + var item = tree[0].Children[1]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + _mouse.DoubleClick(header); + + Assert.True(container.IsExpanded); + } + } + + [Fact] + public void Double_Clicking_Item_Header_With_No_Children_Does_Not_Expand_It() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); + + var item = tree[0].Children[1].Children[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + _mouse.DoubleClick(header); + + Assert.False(container.IsExpanded); + } + } + + [Fact] + public void Double_Clicking_Item_Header_Should_Collapse_It() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); + + var item = tree[0].Children[1]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + _mouse.DoubleClick(header); + + Assert.False(container.IsExpanded); + } + } + + [Fact] + public void Enter_Key_Should_Collapse_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.False(container.IsExpanded); + } + } + + [Fact] + public void Enter_plus_Ctrl_Key_Should_Collapse_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.False(container.IsExpanded); + + AssertEachItemWithChildrenIsCollapsed(item); + + void AssertEachItemWithChildrenIsCollapsed(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.False(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsCollapsed(c); + } + } + else + { + Assert.True(container.IsExpanded); + } + } + } + } + + [Fact] + public void Enter_Key_Should_Expand_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.True(container.IsExpanded); + } + } + + [Fact] + public void Enter_plus_Ctrl_Key_Should_Expand_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.True(container.IsExpanded); + + AssertEachItemWithChildrenIsExpanded(item); + + void AssertEachItemWithChildrenIsExpanded(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.True(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsExpanded(c); + } + } + else + { + Assert.False(container.IsExpanded); + } + } + } + } + + [Fact] + public void Space_Key_Should_Collapse_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.False(container.IsExpanded); + } + } + + [Fact] + public void Space_plus_Ctrl_Key_Should_Collapse_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.False(container.IsExpanded); + + AssertEachItemWithChildrenIsCollapsed(item); + + void AssertEachItemWithChildrenIsCollapsed(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.False(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsCollapsed(c); + } + } + else + { + Assert.True(container.IsExpanded); + } + } + } + } + + [Fact] + public void Space_Key_Should_Expand_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.True(container.IsExpanded); + } + } + + [Fact] + public void Space_plus_Ctrl_Key_Should_Expand_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.True(container.IsExpanded); + + AssertEachItemWithChildrenIsExpanded(item); + + void AssertEachItemWithChildrenIsExpanded(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.True(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsExpanded(c); + } + } + else + { + Assert.False(container.IsExpanded); + } + } + } + } + + [Fact] + public void Numpad_Star_Should_Expand_All_Children_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Multiply, + }); + + AssertEachItemWithChildrenIsExpanded(item); + + void AssertEachItemWithChildrenIsExpanded(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.True(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsExpanded(c); + } + } + else + { + Assert.False(container.IsExpanded); + } + } + } + } + + [Fact] + public void Numpad_Slash_Should_Collapse_All_Children_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Divide, + }); + + AssertEachItemWithChildrenIsCollapsed(item); + + void AssertEachItemWithChildrenIsCollapsed(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.False(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsCollapsed(c); + } + } + else + { + Assert.True(container.IsExpanded); + } + } + } + } + [Fact] public void Setting_SelectedItem_Should_Set_Container_Selected() { @@ -1313,10 +1894,14 @@ namespace Avalonia.Controls.UnitTests { Children = { - new ContentPresenter + new Border { - Name = "PART_HeaderPresenter", - [~ContentPresenter.ContentProperty] = parent[~TreeViewItem.HeaderProperty], + Name = "PART_Header", + Child = new ContentPresenter + { + Name = "PART_HeaderPresenter", + [~ContentPresenter.ContentProperty] = parent[~TreeViewItem.HeaderProperty], + }.RegisterInNameScope(scope) }.RegisterInNameScope(scope), new ItemsPresenter { @@ -1335,6 +1920,14 @@ namespace Avalonia.Controls.UnitTests } } + private void CollapseAll(TreeView tree) + { + foreach (var i in tree.ItemContainerGenerator.Containers) + { + tree.CollapseSubTree((TreeViewItem)i.ContainerControl); + } + } + private List ExtractItemHeader(TreeView tree, int level) { return ExtractItemContent(tree.Presenter.Panel, 0, level) diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index bb8a683a60..435d0d92ce 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -403,7 +403,7 @@ namespace Avalonia.Controls.UnitTests parent.Close(); var ex = Assert.Throws(() => target.Show(parent)); - Assert.Equal("Cannot show a window with a closed parent.", ex.Message); + Assert.Equal("Cannot show a window with a closed owner.", ex.Message); } } @@ -431,7 +431,7 @@ namespace Avalonia.Controls.UnitTests var target = new Window(); var ex = Assert.Throws(() => target.Show(parent)); - Assert.Equal("Cannot show window with non-visible parent.", ex.Message); + Assert.Equal("Cannot show window with non-visible owner.", ex.Message); } } @@ -444,7 +444,7 @@ namespace Avalonia.Controls.UnitTests var target = new Window(); var ex = await Assert.ThrowsAsync(() => target.ShowDialog(parent)); - Assert.Equal("Cannot show window with non-visible parent.", ex.Message); + Assert.Equal("Cannot show window with non-visible owner.", ex.Message); } } @@ -456,7 +456,7 @@ namespace Avalonia.Controls.UnitTests var target = new Window(); var ex = Assert.Throws(() => target.Show(target)); - Assert.Equal("A Window cannot be its own parent.", ex.Message); + Assert.Equal("A Window cannot be its own owner.", ex.Message); } } @@ -986,7 +986,46 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(SizeToContent.WidthAndHeight, target.SizeToContent); } } + + [Fact] + public void IsVisible_Should_Open_Window() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var target = new Window(); + var raised = false; + + target.Opened += (s, e) => raised = true; + target.IsVisible = true; + Assert.True(raised); + } + } + + [Fact] + public void IsVisible_Should_Close_DialogWindow() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var parent = new Window(); + parent.Show(); + + var target = new Window(); + + var raised = false; + + var task = target.ShowDialog(parent); + + target.Closed += (sender, args) => raised = true; + + target.IsVisible = false; + + Assert.True(raised); + + Assert.False(task.Result); + } + } + protected virtual void Show(Window window) { window.Show(); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BasicTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BasicTests.cs index c7afaee697..18a6dd9803 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BasicTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BasicTests.cs @@ -899,6 +899,17 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml Assert.Equal("Foo", target.Text); } + [Fact] + public void Should_Parse_And_Populate_Type_Without_Public_Ctor() + { + var xaml = @""; + var target = (ObjectWithoutPublicCtor)AvaloniaRuntimeXamlLoader.Load(xaml, rootInstance: new ObjectWithoutPublicCtor("Hello")); + + Assert.NotNull(target); + Assert.Equal("World", target.Test2); + Assert.Equal("Hello", target.Test1); + } + private class SelectedItemsViewModel : INotifyPropertyChanged { public string[] Items { get; set; } @@ -928,6 +939,18 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml Child = child; } } + + public class ObjectWithoutPublicCtor + { + public ObjectWithoutPublicCtor(string param) + { + Test1 = param; + } + + public string Test1 { get; set; } + + public string Test2 { get; set; } + } public class ObjectWithAddChildOfT : IAddChild, IAddChild { diff --git a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs index 3b9caa393e..4083a67b5e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs @@ -19,7 +19,7 @@ namespace Avalonia.Skia.UnitTests.Media { var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 10, direction, CultureInfo.CurrentCulture); var shapedBuffer = - TextShaper.Current.ShapeText(text.AsMemory(), options); + TextShaper.Current.ShapeText(new CharacterBufferReference(text), text.Length, options); var glyphRun = CreateGlyphRun(shapedBuffer); @@ -39,8 +39,6 @@ namespace Avalonia.Skia.UnitTests.Media } else { - shapedBuffer.GlyphInfos.Span.Reverse(); - foreach (var rect in rects) { characterHit = glyphRun.GetNextCaretCharacterHit(characterHit); @@ -62,7 +60,7 @@ namespace Avalonia.Skia.UnitTests.Media { var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 10, direction, CultureInfo.CurrentCulture); var shapedBuffer = - TextShaper.Current.ShapeText(text.AsMemory(), options); + TextShaper.Current.ShapeText(new CharacterBufferReference(text), text.Length, options); var glyphRun = CreateGlyphRun(shapedBuffer); @@ -84,8 +82,6 @@ namespace Avalonia.Skia.UnitTests.Media } else { - shapedBuffer.GlyphInfos.Span.Reverse(); - foreach (var rect in rects) { characterHit = glyphRun.GetPreviousCaretCharacterHit(characterHit); @@ -107,7 +103,7 @@ namespace Avalonia.Skia.UnitTests.Media { var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 10, direction, CultureInfo.CurrentCulture); var shapedBuffer = - TextShaper.Current.ShapeText(text.AsMemory(), options); + TextShaper.Current.ShapeText(new CharacterBufferReference(text), text.Length, options); var glyphRun = CreateGlyphRun(shapedBuffer); @@ -116,16 +112,14 @@ namespace Avalonia.Skia.UnitTests.Media var characterHit = glyphRun.GetCharacterHitFromDistance(glyphRun.Metrics.WidthIncludingTrailingWhitespace, out _); - Assert.Equal(glyphRun.Characters.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + Assert.Equal(glyphRun.Characters.Count, characterHit.FirstCharacterIndex + characterHit.TrailingLength); } else { - shapedBuffer.GlyphInfos.Span.Reverse(); - - var characterHit = + var characterHit = glyphRun.GetCharacterHitFromDistance(0, out _); - Assert.Equal(glyphRun.Characters.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + Assert.Equal(glyphRun.Characters.Count, characterHit.FirstCharacterIndex + characterHit.TrailingLength); } var rects = BuildRects(glyphRun); @@ -218,15 +212,22 @@ namespace Avalonia.Skia.UnitTests.Media private static GlyphRun CreateGlyphRun(ShapedBuffer shapedBuffer) { - return new GlyphRun( + var glyphRun = new GlyphRun( shapedBuffer.GlyphTypeface, shapedBuffer.FontRenderingEmSize, - shapedBuffer.Text, + shapedBuffer.CharacterBufferRange, shapedBuffer.GlyphIndices, shapedBuffer.GlyphAdvances, shapedBuffer.GlyphOffsets, shapedBuffer.GlyphClusters, shapedBuffer.BidiLevel); + + if(shapedBuffer.BidiLevel == 1) + { + shapedBuffer.GlyphInfos.Span.Reverse(); + } + + return glyphRun; } private static IDisposable Start() diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs index 005bcdf70e..aa499bb135 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs @@ -29,8 +29,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var runText = _runTexts[index]; - return new TextCharacters( - new ReadOnlySlice(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle); + return new TextCharacters(runText, _defaultStyle); } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs index dee4fe7f77..f12f42bd5e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs @@ -1,30 +1,33 @@ -using System; -using Avalonia.Media.TextFormatting; -using Avalonia.Utilities; +using Avalonia.Media.TextFormatting; namespace Avalonia.Skia.UnitTests.Media.TextFormatting { internal class SingleBufferTextSource : ITextSource { - private readonly ReadOnlySlice _text; + private readonly CharacterBufferRange _text; private readonly GenericTextRunProperties _defaultGenericPropertiesRunProperties; public SingleBufferTextSource(string text, GenericTextRunProperties defaultProperties) { - _text = text.AsMemory(); + _text = new CharacterBufferRange(text); _defaultGenericPropertiesRunProperties = defaultProperties; } public TextRun GetTextRun(int textSourceIndex) { - if (textSourceIndex > _text.Length) + if (textSourceIndex >= _text.Length) { return null; } - + var runText = _text.Skip(textSourceIndex); - return runText.IsEmpty ? null : new TextCharacters(runText, _defaultGenericPropertiesRunProperties); + if (runText.IsEmpty) + { + return null; + } + + return new TextCharacters(runText.CharacterBufferReference, runText.Length, _defaultGenericPropertiesRunProperties); } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 316926b00c..33d4fba5f1 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -37,7 +37,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(defaultProperties.ForegroundBrush, textRun.Properties.ForegroundBrush); - Assert.Equal(text.Length, textRun.Text.Length); + Assert.Equal(text.Length, textRun.Length); } } @@ -82,7 +82,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new ValueSpan(9, 1, defaultProperties) }; - var textSource = new FormattedTextSource(text.AsMemory(), defaultProperties, GenericTextRunPropertiesRuns); + var textSource = new FormattedTextSource(text, defaultProperties, GenericTextRunPropertiesRuns); var formatter = new TextFormatterImpl(); @@ -97,7 +97,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = textLine.TextRuns[i]; - Assert.Equal(GenericTextRunPropertiesRun.Length, textRun.Text.Length); + Assert.Equal(GenericTextRunPropertiesRun.Length, textRun.Length); } } } @@ -166,7 +166,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var firstRun = textLine.TextRuns[0]; - Assert.Equal(4, firstRun.Text.Length); + Assert.Equal(4, firstRun.Length); } } @@ -216,7 +216,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { using (Start()) { - var lineBreaker = new LineBreakEnumerator(text.AsMemory()); + var lineBreaker = new LineBreakEnumerator(new CharacterBufferRange(text)); var expected = new List(); @@ -369,7 +369,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new GenericTextRunProperties(new Typeface("Verdana", FontStyle.Italic),32)) }; - var textSource = new FormattedTextSource(text.AsMemory(), defaultProperties, styleSpans); + var textSource = new FormattedTextSource(text, defaultProperties, styleSpans); var formatter = new TextFormatterImpl(); @@ -389,7 +389,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting if (textLine.Width > 300 || currentHeight + textLine.Height > 240) { - textLine = textLine.Collapse(new TextTrailingWordEllipsis(new ReadOnlySlice(new[] { TextTrimming.DefaultEllipsisChar }), 300, defaultProperties)); + textLine = textLine.Collapse(new TextTrailingWordEllipsis(TextTrimming.DefaultEllipsisChar, 300, defaultProperties)); } currentHeight += textLine.Height; @@ -472,7 +472,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textLine = formatter.FormatLine(textSource, textPosition, 50, paragraphProperties, lastBreak); - Assert.Equal(textLine.Length, textLine.TextRuns.Sum(x => x.TextSourceLength)); + Assert.Equal(textLine.Length, textLine.TextRuns.Sum(x => x.Length)); textPosition += textLine.Length; @@ -534,7 +534,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground)) }; - var textSource = new FormattedTextSource(text.AsMemory(), defaultProperties, spans); + var textSource = new FormattedTextSource(text, defaultProperties, spans); var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties); @@ -614,8 +614,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting return new RectangleRun(new Rect(0, 0, 50, 50), Brushes.Green); } - return new TextCharacters(_text.AsMemory(), - new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black)); + return new TextCharacters(_text, 0, _text.Length, new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black)); } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index d6da2c77c4..a407b38eb1 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -60,9 +60,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = textLine.TextRuns[1]; - Assert.Equal(2, textRun.Text.Length); + Assert.Equal(2, textRun.Length); - var actual = textRun.Text.Span.ToString(); + var actual = new CharacterBufferRange(textRun).Span.ToString(); Assert.Equal("1 ", actual); @@ -144,8 +144,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var expectedGlyphs = expected.TextLines.Select(x => string.Join('|', x.TextRuns.Cast() .SelectMany(x => x.ShapedBuffer.GlyphIndices))).ToList(); - var outer = new GraphemeEnumerator(text.AsMemory()); - var inner = new GraphemeEnumerator(text.AsMemory()); + var outer = new GraphemeEnumerator(new CharacterBufferRange(text)); + var inner = new GraphemeEnumerator(new CharacterBufferRange(text)); var i = 0; var j = 0; @@ -190,7 +190,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting break; } - inner = new GraphemeEnumerator(text.AsMemory()); + inner = new GraphemeEnumerator(new CharacterBufferRange(text)); i += outer.Current.Text.Length; } @@ -223,10 +223,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = textLine.TextRuns[0]; - Assert.Equal(2, textRun.Text.Length); + Assert.Equal(2, textRun.Length); - var actual = SingleLineText.Substring(textRun.Text.Start, - textRun.Text.Length); + var actual = SingleLineText[..textRun.Length]; Assert.Equal("01", actual); @@ -260,9 +259,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = textLine.TextRuns[1]; - Assert.Equal(2, textRun.Text.Length); + Assert.Equal(2, textRun.Length); - var actual = textRun.Text.Span.ToString(); + var actual = new CharacterBufferRange(textRun).Span.ToString(); Assert.Equal("89", actual); @@ -296,7 +295,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = textLine.TextRuns[0]; - Assert.Equal(1, textRun.Text.Length); + Assert.Equal(1, textRun.Length); Assert.Equal(foreground, textRun.Properties.ForegroundBrush); } @@ -330,9 +329,9 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = textLine.TextRuns[1]; - Assert.Equal(2, textRun.Text.Length); + Assert.Equal(2, textRun.Length); - var actual = textRun.Text.Span.ToString(); + var actual = new CharacterBufferRange(textRun).Span.ToString(); Assert.Equal("😄", actual); @@ -369,7 +368,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal( MultiLineText.Length, layout.TextLines.Select(textLine => - textLine.TextRuns.Sum(textRun => textRun.Text.Length)) + textLine.TextRuns.Sum(textRun => textRun.Length)) .Sum()); } } @@ -402,7 +401,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal( text.Length, layout.TextLines.Select(textLine => - textLine.TextRuns.Sum(textRun => textRun.Text.Length)) + textLine.TextRuns.Sum(textRun => textRun.Length)) .Sum()); } } @@ -558,7 +557,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textRun = (ShapedTextCharacters)textLine.TextRuns[0]; - Assert.Equal(7, textRun.Text.Length); + Assert.Equal(7, textRun.Length); var replacementGlyph = Typeface.Default.GlyphTypeface.GetGlyph(Codepoint.ReplacementCodepoint); @@ -668,10 +667,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(5, layout.TextLines.Count); - Assert.Equal("123\r\n", layout.TextLines[0].TextRuns[0].Text); - Assert.Equal("\r\n", layout.TextLines[1].TextRuns[0].Text); - Assert.Equal("456\r\n", layout.TextLines[2].TextRuns[0].Text); - Assert.Equal("\r\n", layout.TextLines[3].TextRuns[0].Text); + Assert.Equal("123\r\n", new CharacterBufferRange(layout.TextLines[0].TextRuns[0])); + Assert.Equal("\r\n", new CharacterBufferRange(layout.TextLines[1].TextRuns[0])); + Assert.Equal("456\r\n", new CharacterBufferRange(layout.TextLines[2].TextRuns[0])); + Assert.Equal("\r\n", new CharacterBufferRange(layout.TextLines[3].TextRuns[0])); } } @@ -815,7 +814,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { Assert.True(textLine.Width <= maxWidth); - var actual = new string(textLine.TextRuns.Cast().OrderBy(x => x.Text.Start).SelectMany(x => x.Text).ToArray()); + var actual = new string(textLine.TextRuns.Cast() + .OrderBy(x => x.CharacterBufferReference.OffsetToFirstChar) + .SelectMany(x => new CharacterBufferRange(x.CharacterBufferReference, x.Length)).ToArray()); + var expected = text.Substring(textLine.FirstTextSourceIndex, textLine.Length); Assert.Equal(expected, actual); @@ -966,7 +968,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var i = 0; - var graphemeEnumerator = new GraphemeEnumerator(text.AsMemory()); + var graphemeEnumerator = new GraphemeEnumerator(new CharacterBufferRange(text)); while (graphemeEnumerator.MoveNext()) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 87de9ed11f..d6257a0de8 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -90,7 +90,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var clusters = new List(); - foreach (var textRun in textLine.TextRuns.OrderBy(x => x.Text.Start)) + foreach (var textRun in textLine.TextRuns.OrderBy(x => x.CharacterBufferReference.OffsetToFirstChar)) { var shapedRun = (ShapedTextCharacters)textRun; @@ -137,7 +137,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var clusters = new List(); - foreach (var textRun in textLine.TextRuns.OrderBy(x => x.Text.Start)) + foreach (var textRun in textLine.TextRuns.OrderBy(x => x.CharacterBufferReference.OffsetToFirstChar)) { var shapedRun = (ShapedTextCharacters)textRun; @@ -187,14 +187,16 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting formatter.FormatLine(textSource, 0, double.PositiveInfinity, new GenericTextParagraphProperties(defaultProperties)); - var clusters = textLine.TextRuns.Cast().SelectMany(x => x.ShapedBuffer.GlyphClusters) - .ToArray(); + var clusters = BuildGlyphClusters(textLine); var nextCharacterHit = new CharacterHit(0); - for (var i = 0; i < clusters.Length; i++) + for (var i = 0; i < clusters.Count; i++) { - Assert.Equal(clusters[i], nextCharacterHit.FirstCharacterIndex); + var expectedCluster = clusters[i]; + var actualCluster = nextCharacterHit.FirstCharacterIndex; + + Assert.Equal(expectedCluster, actualCluster); nextCharacterHit = textLine.GetNextCaretCharacterHit(nextCharacterHit); } @@ -406,7 +408,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.True(collapsedLine.HasCollapsed); - var trimmedText = collapsedLine.TextRuns.SelectMany(x => x.Text).ToArray(); + var trimmedText = collapsedLine.TextRuns.SelectMany(x => new CharacterBufferRange(x)).ToArray(); Assert.Equal(expected.Length, trimmedText.Length); @@ -450,8 +452,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting currentHit = textLine.GetNextCaretCharacterHit(currentHit); - Assert.Equal(3, currentHit.FirstCharacterIndex); - Assert.Equal(1, currentHit.TrailingLength); + Assert.Equal(4, currentHit.FirstCharacterIndex); + Assert.Equal(0, currentHit.TrailingLength); } } @@ -473,18 +475,18 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var currentHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(3, 1)); - Assert.Equal(3, currentHit.FirstCharacterIndex); - Assert.Equal(0, currentHit.TrailingLength); + Assert.Equal(2, currentHit.FirstCharacterIndex); + Assert.Equal(1, currentHit.TrailingLength); currentHit = textLine.GetPreviousCaretCharacterHit(currentHit); - Assert.Equal(2, currentHit.FirstCharacterIndex); - Assert.Equal(0, currentHit.TrailingLength); + Assert.Equal(1, currentHit.FirstCharacterIndex); + Assert.Equal(1, currentHit.TrailingLength); currentHit = textLine.GetPreviousCaretCharacterHit(currentHit); - Assert.Equal(1, currentHit.FirstCharacterIndex); - Assert.Equal(0, currentHit.TrailingLength); + Assert.Equal(0, currentHit.FirstCharacterIndex); + Assert.Equal(1, currentHit.TrailingLength); currentHit = textLine.GetPreviousCaretCharacterHit(currentHit); @@ -509,13 +511,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var characterHit = textLine.GetCharacterHitFromDistance(50); - Assert.Equal(3, characterHit.FirstCharacterIndex); + Assert.Equal(5, characterHit.FirstCharacterIndex); Assert.Equal(1, characterHit.TrailingLength); characterHit = textLine.GetCharacterHitFromDistance(32); - Assert.Equal(2, characterHit.FirstCharacterIndex); - Assert.Equal(1, characterHit.TrailingLength); + Assert.Equal(3, characterHit.FirstCharacterIndex); + Assert.Equal(0, characterHit.TrailingLength); } } @@ -649,7 +651,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var run = textRuns[i]; var bounds = runBounds[i]; - Assert.Equal(run.Text.Start, bounds.TextSourceCharacterIndex); + Assert.Equal(run.CharacterBufferReference.OffsetToFirstChar, bounds.TextSourceCharacterIndex); Assert.Equal(run, bounds.TextRun); Assert.Equal(run.Size.Width, bounds.Rectangle.Width); } @@ -683,13 +685,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting switch (textSourceIndex) { case 0: - return new TextCharacters(new ReadOnlySlice("aaaaaaaaaa".AsMemory()), new GenericTextRunProperties(Typeface.Default)); + return new TextCharacters("aaaaaaaaaa", new GenericTextRunProperties(Typeface.Default)); case 10: - return new TextCharacters(new ReadOnlySlice("bbbbbbbbbb".AsMemory()), new GenericTextRunProperties(Typeface.Default)); + return new TextCharacters("bbbbbbbbbb", new GenericTextRunProperties(Typeface.Default)); case 20: - return new TextCharacters(new ReadOnlySlice("cccccccccc".AsMemory()), new GenericTextRunProperties(Typeface.Default)); + return new TextCharacters("cccccccccc", new GenericTextRunProperties(Typeface.Default)); case 30: - return new TextCharacters(new ReadOnlySlice("dddddddddd".AsMemory()), new GenericTextRunProperties(Typeface.Default)); + return new TextCharacters("dddddddddd", new GenericTextRunProperties(Typeface.Default)); default: return null; } @@ -698,7 +700,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting private class DrawableRunTextSource : ITextSource { - const string Text = "_A_A"; + private const string Text = "_A_A"; public TextRun GetTextRun(int textSourceIndex) { @@ -707,11 +709,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting case 0: return new CustomDrawableRun(); case 1: - return new TextCharacters(new ReadOnlySlice(Text.AsMemory(), 1, 1, 1), new GenericTextRunProperties(Typeface.Default)); - case 2: + return new TextCharacters(Text, new GenericTextRunProperties(Typeface.Default)); + case 5: return new CustomDrawableRun(); - case 3: - return new TextCharacters(new ReadOnlySlice(Text.AsMemory(), 3, 1, 3), new GenericTextRunProperties(Typeface.Default)); + case 6: + return new TextCharacters(Text, new GenericTextRunProperties(Typeface.Default)); default: return null; } @@ -815,19 +817,19 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting using (Start()) { var defaultProperties = new GenericTextRunProperties(Typeface.Default); - var text = "0123".AsMemory(); + var text = "0123"; var shaperOption = new TextShaperOptions(Typeface.Default.GlyphTypeface, 10, 0, CultureInfo.CurrentCulture); - var firstRun = new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, 1, text.Length), shaperOption), defaultProperties); + var firstRun = new ShapedTextCharacters(TextShaper.Current.ShapeText(text, shaperOption), defaultProperties); var textRuns = new List { new CustomDrawableRun(), firstRun, new CustomDrawableRun(), - new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, text.Length + 2, text.Length), shaperOption), defaultProperties), + new ShapedTextCharacters(TextShaper.Current.ShapeText(text, shaperOption), defaultProperties), new CustomDrawableRun(), - new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, text.Length * 2 + 3, text.Length), shaperOption), defaultProperties) + new ShapedTextCharacters(TextShaper.Current.ShapeText(text, shaperOption), defaultProperties) }; var textSource = new FixedRunsTextSource(textRuns); @@ -838,7 +840,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting formatter.FormatLine(textSource, 0, double.PositiveInfinity, new GenericTextParagraphProperties(defaultProperties)); - var textBounds = textLine.GetTextBounds(0, text.Length * 3 + 3); + var textBounds = textLine.GetTextBounds(0, textLine.Length); Assert.Equal(6, textBounds.Count); Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width)); @@ -848,17 +850,17 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(1, textBounds.Count); Assert.Equal(14, textBounds[0].Rectangle.Width); - textBounds = textLine.GetTextBounds(0, firstRun.Text.Length + 1); + textBounds = textLine.GetTextBounds(0, firstRun.Length + 1); Assert.Equal(2, textBounds.Count); Assert.Equal(firstRun.Size.Width + 14, textBounds.Sum(x => x.Rectangle.Width)); - textBounds = textLine.GetTextBounds(1, firstRun.Text.Length); + textBounds = textLine.GetTextBounds(1, firstRun.Length); Assert.Equal(1, textBounds.Count); Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width); - textBounds = textLine.GetTextBounds(1, firstRun.Text.Length + 1); + textBounds = textLine.GetTextBounds(0, 1 + firstRun.Length); Assert.Equal(2, textBounds.Count); Assert.Equal(firstRun.Size.Width + 14, textBounds.Sum(x => x.Rectangle.Width)); @@ -878,7 +880,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textLine = formatter.FormatLine(textSource, 0, 200, - new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left, + new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left, true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0)); var textBounds = textLine.GetTextBounds(0, 3); @@ -899,11 +901,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(2, textBounds.Count); - Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width); + Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width); Assert.Equal(7.201171875, textBounds[1].Rectangle.Width); - Assert.Equal(firstRun.Size.Width, textBounds[1].Rectangle.Left); + Assert.Equal(firstRun.Size.Width, textBounds[1].Rectangle.Left); textBounds = textLine.GetTextBounds(0, text.Length); @@ -925,7 +927,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textLine = formatter.FormatLine(textSource, 0, 200, - new GenericTextParagraphProperties(FlowDirection.RightToLeft, TextAlignment.Left, + new GenericTextParagraphProperties(FlowDirection.RightToLeft, TextAlignment.Left, true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0)); var textBounds = textLine.GetTextBounds(0, 4); @@ -941,13 +943,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(1, textBounds.Count); - Assert.Equal(3, textBounds[0].TextRunBounds.Sum(x=> x.Length)); + Assert.Equal(3, textBounds[0].TextRunBounds.Sum(x => x.Length)); Assert.Equal(firstRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); textBounds = textLine.GetTextBounds(0, 5); Assert.Equal(2, textBounds.Count); - Assert.Equal(5, textBounds.Sum(x=> x.TextRunBounds.Sum(x => x.Length))); + Assert.Equal(5, textBounds.Sum(x => x.TextRunBounds.Sum(x => x.Length))); Assert.Equal(secondRun.Size.Width, textBounds[1].Rectangle.Width); Assert.Equal(7.201171875, textBounds[0].Rectangle.Width); @@ -960,7 +962,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(7, textBounds.Sum(x => x.TextRunBounds.Sum(x => x.Length))); Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width)); } - } + } private class FixedRunsTextSource : ITextSource { @@ -982,7 +984,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting return textRun; } - currentPosition += textRun.TextSourceLength; + currentPosition += textRun.Length; } return null; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs index 94933e334d..63e0083b1d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs @@ -14,11 +14,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { using (Start()) { - var text = "\n\r\n".AsMemory(); + var text = "\n\r\n"; var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 12,0, CultureInfo.CurrentCulture); var shapedBuffer = TextShaper.Current.ShapeText(text, options); - Assert.Equal(shapedBuffer.Text.Length, text.Length); + Assert.Equal(shapedBuffer.CharacterBufferRange.Length, text.Length); Assert.Equal(shapedBuffer.GlyphClusters.Count, text.Length); Assert.Equal(0, shapedBuffer.GlyphClusters[0]); Assert.Equal(1, shapedBuffer.GlyphClusters[1]); @@ -31,7 +31,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { using (Start()) { - var text = "\t".AsMemory(); + var text = "\t"; var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 12, 0, CultureInfo.CurrentCulture, 100); var shapedBuffer = TextShaper.Current.ShapeText(text, options); diff --git a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs index 7b7488bd5a..ae7e00aca1 100644 --- a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs +++ b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs @@ -11,7 +11,7 @@ namespace Avalonia.UnitTests { public class HarfBuzzTextShaperImpl : ITextShaperImpl { - public ShapedBuffer ShapeText(ReadOnlySlice text, TextShaperOptions options) + public ShapedBuffer ShapeText(CharacterBufferReference text, int textLength, TextShaperOptions options) { var typeface = options.Typeface; var fontRenderingEmSize = options.FontRenderingEmSize; @@ -20,7 +20,7 @@ namespace Avalonia.UnitTests using (var buffer = new Buffer()) { - buffer.AddUtf16(text.Buffer.Span, text.Start, text.Length); + buffer.AddUtf16(text.CharacterBuffer.Span, text.OffsetToFirstChar, textLength); MergeBreakPair(buffer); @@ -45,7 +45,9 @@ namespace Avalonia.UnitTests var bufferLength = buffer.Length; - var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel); + var characterBufferRange = new CharacterBufferRange(text, textLength); + + var shapedBuffer = new ShapedBuffer(characterBufferRange, bufferLength, typeface, fontRenderingEmSize, bidiLevel); var glyphInfos = buffer.GetGlyphInfoSpan(); diff --git a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs b/tests/Avalonia.UnitTests/MockTextShaperImpl.cs index 7c34bd192e..00bcef295a 100644 --- a/tests/Avalonia.UnitTests/MockTextShaperImpl.cs +++ b/tests/Avalonia.UnitTests/MockTextShaperImpl.cs @@ -1,24 +1,24 @@ using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Platform; -using Avalonia.Utilities; namespace Avalonia.UnitTests { public class MockTextShaperImpl : ITextShaperImpl { - public ShapedBuffer ShapeText(ReadOnlySlice text, TextShaperOptions options) + public ShapedBuffer ShapeText(CharacterBufferReference text, int length, TextShaperOptions options) { var typeface = options.Typeface; var fontRenderingEmSize = options.FontRenderingEmSize; var bidiLevel = options.BidiLevel; - - var shapedBuffer = new ShapedBuffer(text, text.Length, typeface, fontRenderingEmSize, bidiLevel); + var characterBufferRange = new CharacterBufferRange(text, length); + var shapedBuffer = new ShapedBuffer(characterBufferRange, length, typeface, fontRenderingEmSize, bidiLevel); for (var i = 0; i < shapedBuffer.Length;) { - var glyphCluster = i + text.Start; - var codepoint = Codepoint.ReadAt(text, i, out var count); + var glyphCluster = i + text.OffsetToFirstChar; + + var codepoint = Codepoint.ReadAt(characterBufferRange, i, out var count); var glyphIndex = typeface.GetGlyph(codepoint); diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs index c9e4274d15..d63327239b 100644 --- a/tests/Avalonia.UnitTests/MouseTestHelper.cs +++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs @@ -65,6 +65,7 @@ namespace Avalonia.UnitTests } public void Move(Interactive target, in Point position, KeyModifiers modifiers = default) => Move(target, target, position, modifiers); + public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default) { target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, (Visual)target, position, @@ -98,13 +99,26 @@ namespace Avalonia.UnitTests public void Click(Interactive target, MouseButton button = MouseButton.Left, Point position = default, KeyModifiers modifiers = default) => Click(target, target, button, position, modifiers); + public void Click(Interactive target, Interactive source, MouseButton button = MouseButton.Left, Point position = default, KeyModifiers modifiers = default) { Down(target, source, button, position, modifiers); Up(target, source, button, position, modifiers); } - + + public void DoubleClick(Interactive target, MouseButton button = MouseButton.Left, Point position = default, + KeyModifiers modifiers = default) + => DoubleClick(target, target, button, position, modifiers); + + public void DoubleClick(Interactive target, Interactive source, MouseButton button = MouseButton.Left, + Point position = default, KeyModifiers modifiers = default) + { + Down(target, source, button, position, modifiers, clickCount: 1); + Up(target, source, button, position, modifiers); + Down(target, source, button, position, modifiers, clickCount: 2); + } + public void Enter(Interactive target) { target.RaiseEvent(new PointerEventArgs(InputElement.PointerEnteredEvent, target, _pointer, (Visual)target, default,