diff --git a/native/Avalonia.Native/src/OSX/window.mm b/native/Avalonia.Native/src/OSX/window.mm index 0b88908252..40180274e1 100644 --- a/native/Avalonia.Native/src/OSX/window.mm +++ b/native/Avalonia.Native/src/OSX/window.mm @@ -1627,6 +1627,19 @@ NSArray* AllLoopModes = [NSArray arrayWithObjects: NSDefaultRunLoopMode, NSEvent return; } } + else if (type == Magnify) + { + delta.X = delta.Y = [event magnification]; + } + else if (type == Rotate) + { + delta.X = delta.Y = [event rotation]; + } + else if (type == Swipe) + { + delta.X = [event deltaX]; + delta.Y = [event deltaY]; + } auto timestamp = [event timestamp] * 1000; auto modifiers = [self getModifiers:[event modifierFlags]]; @@ -1753,6 +1766,24 @@ NSArray* AllLoopModes = [NSArray arrayWithObjects: NSDefaultRunLoopMode, NSEvent [super scrollWheel:event]; } +- (void)magnifyWithEvent:(NSEvent *)event +{ + [self mouseEvent:event withType:Magnify]; + [super magnifyWithEvent:event]; +} + +- (void)rotateWithEvent:(NSEvent *)event +{ + [self mouseEvent:event withType:Rotate]; + [super rotateWithEvent:event]; +} + +- (void)swipeWithEvent:(NSEvent *)event +{ + [self mouseEvent:event withType:Swipe]; + [super swipeWithEvent:event]; +} + - (void)mouseEntered:(NSEvent *)event { _isMouseOver = true; diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs index 8501ce3896..a77b482436 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs @@ -680,7 +680,7 @@ namespace Avalonia.Controls public void ClearSort() { //InvokeProcessSort is already validating if sorting is possible - _headerCell?.InvokeProcessSort(Input.KeyModifiers.Control); + _headerCell?.InvokeProcessSort(KeyboardHelper.GetPlatformCtrlOrCmdKeyModifier()); } /// diff --git a/src/Avalonia.Controls.DataGrid/Utils/KeyboardHelper.cs b/src/Avalonia.Controls.DataGrid/Utils/KeyboardHelper.cs index 351deceb48..d2b1fd4b8e 100644 --- a/src/Avalonia.Controls.DataGrid/Utils/KeyboardHelper.cs +++ b/src/Avalonia.Controls.DataGrid/Utils/KeyboardHelper.cs @@ -4,22 +4,29 @@ // All other rights reserved. using Avalonia.Input; +using Avalonia.Input.Platform; namespace Avalonia.Controls.Utils { internal static class KeyboardHelper { - public static void GetMetaKeyState(KeyModifiers modifiers, out bool ctrl, out bool shift) + public static void GetMetaKeyState(KeyModifiers modifiers, out bool ctrlOrCmd, out bool shift) { - ctrl = (modifiers & KeyModifiers.Control) == KeyModifiers.Control; - shift = (modifiers & KeyModifiers.Shift) == KeyModifiers.Shift; + ctrlOrCmd = modifiers.HasFlag(GetPlatformCtrlOrCmdKeyModifier()); + shift = modifiers.HasFlag(KeyModifiers.Shift); } - public static void GetMetaKeyState(KeyModifiers modifiers, out bool ctrl, out bool shift, out bool alt) + public static void GetMetaKeyState(KeyModifiers modifiers, out bool ctrlOrCmd, out bool shift, out bool alt) { - ctrl = (modifiers & KeyModifiers.Control) == KeyModifiers.Control; - shift = (modifiers & KeyModifiers.Shift) == KeyModifiers.Shift; - alt = (modifiers & KeyModifiers.Alt) == KeyModifiers.Alt; + ctrlOrCmd = modifiers.HasFlag(GetPlatformCtrlOrCmdKeyModifier()); + shift = modifiers.HasFlag(KeyModifiers.Shift); + alt = modifiers.HasFlag(KeyModifiers.Alt); + } + + public static KeyModifiers GetPlatformCtrlOrCmdKeyModifier() + { + var keymap = AvaloniaLocator.Current.GetService(); + return keymap?.CommandModifiers ?? KeyModifiers.Control; } } } diff --git a/src/Avalonia.Controls/ApiCompatBaseline.txt b/src/Avalonia.Controls/ApiCompatBaseline.txt index 9b7d37e108..a7560c37f2 100644 --- a/src/Avalonia.Controls/ApiCompatBaseline.txt +++ b/src/Avalonia.Controls/ApiCompatBaseline.txt @@ -36,6 +36,7 @@ CannotRemoveBaseTypeOrInterface : Type 'Avalonia.Controls.WindowBase' does not i InterfacesShouldHaveSameMembers : Interface member 'public System.EventHandler Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime.ShutdownRequested' is present in the implementation but not in the contract. InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime.add_ShutdownRequested(System.EventHandler)' is present in the implementation but not in the contract. InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime.remove_ShutdownRequested(System.EventHandler)' is present in the implementation but not in the contract. +InterfacesShouldHaveSameMembers : Interface member 'public System.Boolean Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime.TryShutdown(System.Int32)' is present in the implementation but not in the contract. CannotRemoveBaseTypeOrInterface : Type 'Avalonia.Controls.Embedding.EmbeddableControlRoot' does not implement interface 'Avalonia.Utilities.IWeakSubscriber' in the implementation but it does in the contract. MembersMustExist : Member 'public System.Action Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.Resized.get()' does not exist in the implementation but it does exist in the contract. MembersMustExist : Member 'public void Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.Resized.set(System.Action)' does not exist in the implementation but it does exist in the contract. @@ -62,4 +63,4 @@ InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platfor MembersMustExist : Member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size)' does not exist in the implementation but it does exist in the contract. InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.IWindowImpl.Resize(Avalonia.Size, Avalonia.Platform.PlatformResizeReason)' is present in the implementation but not in the contract. InterfacesShouldHaveSameMembers : Interface member 'public Avalonia.Platform.ITrayIconImpl Avalonia.Platform.IWindowingPlatform.CreateTrayIcon()' is present in the implementation but not in the contract. -Total Issues: 63 +Total Issues: 64 diff --git a/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs b/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs index 3a2fd68af5..edddf31d45 100644 --- a/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs +++ b/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs @@ -76,36 +76,21 @@ namespace Avalonia.Controls.ApplicationLifetimes return; if (ShutdownMode == ShutdownMode.OnLastWindowClose && _windows.Count == 0) - Shutdown(); - else if (ShutdownMode == ShutdownMode.OnMainWindowClose && window == MainWindow) - Shutdown(); + TryShutdown(); + else if (ShutdownMode == ShutdownMode.OnMainWindowClose && ReferenceEquals(window, MainWindow)) + TryShutdown(); } public void Shutdown(int exitCode = 0) { - if (_isShuttingDown) - throw new InvalidOperationException("Application is already shutting down."); - - _exitCode = exitCode; - _isShuttingDown = true; + DoShutdown(new ShutdownRequestedEventArgs(), true, exitCode); + } - try - { - foreach (var w in Windows) - w.Close(); - var e = new ControlledApplicationLifetimeExitEventArgs(exitCode); - Exit?.Invoke(this, e); - _exitCode = e.ApplicationExitCode; - } - finally - { - _cts?.Cancel(); - _cts = null; - _isShuttingDown = false; - } + public bool TryShutdown(int exitCode = 0) + { + return DoShutdown(new ShutdownRequestedEventArgs(), false, exitCode); } - public int Start(string[] args) { Startup?.Invoke(this, new ControlledApplicationLifetimeStartupEventArgs(args)); @@ -114,7 +99,10 @@ namespace Avalonia.Controls.ApplicationLifetimes if(options != null && options.ProcessUrlActivationCommandLine && args.Length > 0) { - ((IApplicationPlatformEvents)Application.Current).RaiseUrlsOpened(args); + if (Application.Current is IApplicationPlatformEvents events) + { + events.RaiseUrlsOpened(args); + } } var lifetimeEvents = AvaloniaLocator.Current.GetService(); @@ -145,23 +133,57 @@ namespace Avalonia.Controls.ApplicationLifetimes if (_activeLifetime == this) _activeLifetime = null; } - - private void OnShutdownRequested(object sender, ShutdownRequestedEventArgs e) + + private bool DoShutdown(ShutdownRequestedEventArgs e, bool force = false, int exitCode = 0) { - ShutdownRequested?.Invoke(this, e); + if (!force) + { + ShutdownRequested?.Invoke(this, e); - if (e.Cancel) - return; + if (e.Cancel) + return false; + + if (_isShuttingDown) + throw new InvalidOperationException("Application is already shutting down."); + } + + _exitCode = exitCode; + _isShuttingDown = true; - // When an OS shutdown request is received, try to close all non-owned windows. Windows can cancel - // shutdown by setting e.Cancel = true in the Closing event. Owned windows will be shutdown by their - // owners. - foreach (var w in Windows) - if (w.Owner is null) - w.Close(); - if (Windows.Count > 0) - e.Cancel = true; + try + { + // When an OS shutdown request is received, try to close all non-owned windows. Windows can cancel + // shutdown by setting e.Cancel = true in the Closing event. Owned windows will be shutdown by their + // owners. + foreach (var w in Windows) + { + if (w.Owner is null) + { + w.Close(); + } + } + + if (!force && Windows.Count > 0) + { + e.Cancel = true; + return false; + } + + var args = new ControlledApplicationLifetimeExitEventArgs(exitCode); + Exit?.Invoke(this, args); + _exitCode = args.ApplicationExitCode; + } + finally + { + _cts?.Cancel(); + _cts = null; + _isShuttingDown = false; + } + + return true; } + + private void OnShutdownRequested(object sender, ShutdownRequestedEventArgs e) => DoShutdown(e); } public class ClassicDesktopStyleApplicationLifetimeOptions diff --git a/src/Avalonia.Controls/ApplicationLifetimes/IClassicDesktopStyleApplicationLifetime.cs b/src/Avalonia.Controls/ApplicationLifetimes/IClassicDesktopStyleApplicationLifetime.cs index a70d5dd2f1..a83229b732 100644 --- a/src/Avalonia.Controls/ApplicationLifetimes/IClassicDesktopStyleApplicationLifetime.cs +++ b/src/Avalonia.Controls/ApplicationLifetimes/IClassicDesktopStyleApplicationLifetime.cs @@ -9,6 +9,12 @@ namespace Avalonia.Controls.ApplicationLifetimes /// public interface IClassicDesktopStyleApplicationLifetime : IControlledApplicationLifetime { + /// + /// Tries to Shutdown the application. event can be used to cancel the shutdown. + /// + /// An integer exit code for an application. The default exit code is 0. + bool TryShutdown(int exitCode = 0); + /// /// Gets the arguments passed to the /// diff --git a/src/Avalonia.Controls/LayoutTransformControl.cs b/src/Avalonia.Controls/LayoutTransformControl.cs index 83ad2b3638..a8e15ee463 100644 --- a/src/Avalonia.Controls/LayoutTransformControl.cs +++ b/src/Avalonia.Controls/LayoutTransformControl.cs @@ -18,7 +18,7 @@ namespace Avalonia.Controls AvaloniaProperty.Register(nameof(LayoutTransform)); public static readonly StyledProperty UseRenderTransformProperty = - AvaloniaProperty.Register(nameof(LayoutTransform)); + AvaloniaProperty.Register(nameof(UseRenderTransform)); static LayoutTransformControl() { diff --git a/src/Avalonia.Input/Gestures.cs b/src/Avalonia.Input/Gestures.cs index a5ee558f47..86e6e96a71 100644 --- a/src/Avalonia.Input/Gestures.cs +++ b/src/Avalonia.Input/Gestures.cs @@ -29,6 +29,18 @@ namespace Avalonia.Input public static readonly RoutedEvent ScrollGestureEndedEvent = RoutedEvent.Register( "ScrollGestureEnded", RoutingStrategies.Bubble, typeof(Gestures)); + + public static readonly RoutedEvent PointerTouchPadGestureMagnifyEvent = + RoutedEvent.Register( + "PointerMagnifyGesture", RoutingStrategies.Bubble, typeof(Gestures)); + + public static readonly RoutedEvent PointerTouchPadGestureRotateEvent = + RoutedEvent.Register( + "PointerRotateGesture", RoutingStrategies.Bubble, typeof(Gestures)); + + public static readonly RoutedEvent PointerTouchPadGestureSwipeEvent = + RoutedEvent.Register( + "PointerSwipeGesture", RoutingStrategies.Bubble, typeof(Gestures)); #pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. private static readonly WeakReference s_lastPress = new WeakReference(null); diff --git a/src/Avalonia.Input/InputElement.cs b/src/Avalonia.Input/InputElement.cs index def0a26b27..5aa1f1ff44 100644 --- a/src/Avalonia.Input/InputElement.cs +++ b/src/Avalonia.Input/InputElement.cs @@ -196,7 +196,7 @@ namespace Avalonia.Input /// Defines the event. /// public static readonly RoutedEvent DoubleTappedEvent = Gestures.DoubleTappedEvent; - + private bool _isEffectivelyEnabled = true; private bool _isFocused; private bool _isKeyboardFocusWithin; @@ -349,14 +349,14 @@ namespace Avalonia.Input } /// - /// Occurs when the mouse wheen is scrolled over the control. + /// Occurs when the mouse is scrolled over the control. /// public event EventHandler PointerWheelChanged { add { AddHandler(PointerWheelChangedEvent, value); } remove { RemoveHandler(PointerWheelChangedEvent, value); } } - + /// /// Occurs when a tap gesture occurs on the control. /// diff --git a/src/Avalonia.Input/MouseDevice.cs b/src/Avalonia.Input/MouseDevice.cs index 166af44d04..34d2038d66 100644 --- a/src/Avalonia.Input/MouseDevice.cs +++ b/src/Avalonia.Input/MouseDevice.cs @@ -181,6 +181,15 @@ namespace Avalonia.Input case RawPointerEventType.Wheel: e.Handled = MouseWheel(mouse, e.Timestamp, e.Root, e.Position, props, ((RawMouseWheelEventArgs)e).Delta, keyModifiers); break; + case RawPointerEventType.Magnify: + e.Handled = GestureMagnify(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers); + break; + case RawPointerEventType.Rotate: + e.Handled = GestureRotate(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers); + break; + case RawPointerEventType.Swipe: + e.Handled = GestureSwipe(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers); + break; } } @@ -335,6 +344,69 @@ namespace Avalonia.Input return false; } + + private bool GestureMagnify(IMouseDevice device, ulong timestamp, IInputRoot root, Point p, + PointerPointProperties props, Vector delta, KeyModifiers inputModifiers) + { + device = device ?? throw new ArgumentNullException(nameof(device)); + root = root ?? throw new ArgumentNullException(nameof(root)); + + var hit = HitTest(root, p); + + if (hit != null) + { + var source = GetSource(hit); + var e = new PointerDeltaEventArgs(Gestures.PointerTouchPadGestureMagnifyEvent, source, + _pointer, root, p, timestamp, props, inputModifiers, delta); + + source?.RaiseEvent(e); + return e.Handled; + } + + return false; + } + + private bool GestureRotate(IMouseDevice device, ulong timestamp, IInputRoot root, Point p, + PointerPointProperties props, Vector delta, KeyModifiers inputModifiers) + { + device = device ?? throw new ArgumentNullException(nameof(device)); + root = root ?? throw new ArgumentNullException(nameof(root)); + + var hit = HitTest(root, p); + + if (hit != null) + { + var source = GetSource(hit); + var e = new PointerDeltaEventArgs(Gestures.PointerTouchPadGestureRotateEvent, source, + _pointer, root, p, timestamp, props, inputModifiers, delta); + + source?.RaiseEvent(e); + return e.Handled; + } + + return false; + } + + private bool GestureSwipe(IMouseDevice device, ulong timestamp, IInputRoot root, Point p, + PointerPointProperties props, Vector delta, KeyModifiers inputModifiers) + { + device = device ?? throw new ArgumentNullException(nameof(device)); + root = root ?? throw new ArgumentNullException(nameof(root)); + + var hit = HitTest(root, p); + + if (hit != null) + { + var source = GetSource(hit); + var e = new PointerDeltaEventArgs(Gestures.PointerTouchPadGestureSwipeEvent, source, + _pointer, root, p, timestamp, props, inputModifiers, delta); + + source?.RaiseEvent(e); + return e.Handled; + } + + return false; + } private IInteractive? GetSource(IVisual? hit) { diff --git a/src/Avalonia.Input/PointerDeltaEventArgs.cs b/src/Avalonia.Input/PointerDeltaEventArgs.cs new file mode 100644 index 0000000000..b3085a038d --- /dev/null +++ b/src/Avalonia.Input/PointerDeltaEventArgs.cs @@ -0,0 +1,19 @@ +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace Avalonia.Input +{ + public class PointerDeltaEventArgs : PointerEventArgs + { + public Vector Delta { get; set; } + + public PointerDeltaEventArgs(RoutedEvent routedEvent, IInteractive? source, + IPointer pointer, IVisual rootVisual, Point rootVisualPosition, ulong timestamp, + PointerPointProperties properties, KeyModifiers modifiers, Vector delta) + : base(routedEvent, source, pointer, rootVisual, rootVisualPosition, + timestamp, properties, modifiers) + { + Delta = delta; + } + } +} diff --git a/src/Avalonia.Input/Raw/RawPointerEventArgs.cs b/src/Avalonia.Input/Raw/RawPointerEventArgs.cs index 6cb8a10cf3..1fe19d9c55 100644 --- a/src/Avalonia.Input/Raw/RawPointerEventArgs.cs +++ b/src/Avalonia.Input/Raw/RawPointerEventArgs.cs @@ -22,7 +22,10 @@ namespace Avalonia.Input.Raw TouchBegin, TouchUpdate, TouchEnd, - TouchCancel + TouchCancel, + Magnify, + Rotate, + Swipe } /// diff --git a/src/Avalonia.Input/Raw/RawPointerGestureEventArgs.cs b/src/Avalonia.Input/Raw/RawPointerGestureEventArgs.cs new file mode 100644 index 0000000000..5a6dbda9a7 --- /dev/null +++ b/src/Avalonia.Input/Raw/RawPointerGestureEventArgs.cs @@ -0,0 +1,19 @@ +namespace Avalonia.Input.Raw +{ + public class RawPointerGestureEventArgs : RawPointerEventArgs + { + public RawPointerGestureEventArgs( + IInputDevice device, + ulong timestamp, + IInputRoot root, + RawPointerEventType gestureType, + Point position, + Vector delta, RawInputModifiers inputModifiers) + : base(device, timestamp, root, gestureType, position, inputModifiers) + { + Delta = delta; + } + + public Vector Delta { get; private set; } + } +} diff --git a/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs b/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs index de6ba30a85..d8753efe25 100644 --- a/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs +++ b/src/Avalonia.Native/AvaloniaNativeMenuExporter.cs @@ -133,9 +133,13 @@ namespace Avalonia.Native var quitItem = new NativeMenuItem("Quit") { Gesture = new KeyGesture(Key.Q, KeyModifiers.Meta) }; quitItem.Click += (_, _) => { - if (Application.Current is { ApplicationLifetime: IControlledApplicationLifetime lifetime }) + if (Application.Current is { ApplicationLifetime: IClassicDesktopStyleApplicationLifetime lifetime }) { - lifetime.Shutdown(); + lifetime.TryShutdown(); + } + else if(Application.Current is {ApplicationLifetime: IControlledApplicationLifetime controlledLifetime}) + { + controlledLifetime.Shutdown(); } }; diff --git a/src/Avalonia.Native/WindowImplBase.cs b/src/Avalonia.Native/WindowImplBase.cs index 87b7a7608e..1917b1575d 100644 --- a/src/Avalonia.Native/WindowImplBase.cs +++ b/src/Avalonia.Native/WindowImplBase.cs @@ -306,11 +306,28 @@ namespace Avalonia.Native switch (type) { case AvnRawMouseEventType.Wheel: - Input?.Invoke(new RawMouseWheelEventArgs(_mouse, timeStamp, _inputRoot, point.ToAvaloniaPoint(), new Vector(delta.X, delta.Y), (RawInputModifiers)modifiers)); + Input?.Invoke(new RawMouseWheelEventArgs(_mouse, timeStamp, _inputRoot, + point.ToAvaloniaPoint(), new Vector(delta.X, delta.Y), (RawInputModifiers)modifiers)); + break; + + case AvnRawMouseEventType.Magnify: + Input?.Invoke(new RawPointerGestureEventArgs(_mouse, timeStamp, _inputRoot, RawPointerEventType.Magnify, + point.ToAvaloniaPoint(), new Vector(delta.X, delta.Y), (RawInputModifiers)modifiers)); + break; + + case AvnRawMouseEventType.Rotate: + Input?.Invoke(new RawPointerGestureEventArgs(_mouse, timeStamp, _inputRoot, RawPointerEventType.Rotate, + point.ToAvaloniaPoint(), new Vector(delta.X, delta.Y), (RawInputModifiers)modifiers)); + break; + + case AvnRawMouseEventType.Swipe: + Input?.Invoke(new RawPointerGestureEventArgs(_mouse, timeStamp, _inputRoot, RawPointerEventType.Swipe, + point.ToAvaloniaPoint(), new Vector(delta.X, delta.Y), (RawInputModifiers)modifiers)); break; default: - var e = new RawPointerEventArgs(_mouse, timeStamp, _inputRoot, (RawPointerEventType)type, point.ToAvaloniaPoint(), (RawInputModifiers)modifiers); + var e = new RawPointerEventArgs(_mouse, timeStamp, _inputRoot, (RawPointerEventType)type, + point.ToAvaloniaPoint(), (RawInputModifiers)modifiers); if(!ChromeHitTest(e)) { diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 112b4f636c..f2b9d4997e 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -295,7 +295,10 @@ enum AvnRawMouseEventType TouchBegin, TouchUpdate, TouchEnd, - TouchCancel + TouchCancel, + Magnify, + Rotate, + Swipe, } enum AvnRawKeyEventType diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs index 7c6af4eaa7..c97e36d5ff 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs @@ -193,7 +193,7 @@ namespace Avalonia.Media.TextFormatting { var currentRun = textRuns[i]; - if (currentLength + currentRun.GlyphRun.Characters.Length < length) + if (currentLength + currentRun.GlyphRun.Characters.Length <= length) { currentLength += currentRun.GlyphRun.Characters.Length; continue; @@ -283,26 +283,26 @@ namespace Avalonia.Media.TextFormatting { var shapedCharacters = previousLineBreak.RemainingCharacters[index]; - if (shapedCharacters == null) - { - continue; - } - textRuns.Add(shapedCharacters); if (TryGetLineBreak(shapedCharacters, out var runLineBreak)) { var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap); + if (splitResult.Second == null) + { + return splitResult.First; + } + if (++index < previousLineBreak.RemainingCharacters.Count) { for (; index < previousLineBreak.RemainingCharacters.Count; index++) { - splitResult.Second!.Add(previousLineBreak.RemainingCharacters[index]); + splitResult.Second.Add(previousLineBreak.RemainingCharacters[index]); } } - nextLineBreak = new TextLineBreak(splitResult.Second!); + nextLineBreak = new TextLineBreak(splitResult.Second); return splitResult.First; } @@ -346,7 +346,10 @@ namespace Avalonia.Media.TextFormatting { var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap); - nextLineBreak = new TextLineBreak(splitResult.Second!); + if (splitResult.Second != null) + { + nextLineBreak = new TextLineBreak(splitResult.Second); + } return splitResult.First; } @@ -532,7 +535,7 @@ namespace Avalonia.Media.TextFormatting /// The text range that is covered by the text runs. private static TextRange GetTextRange(IReadOnlyList textRuns) { - if (textRuns is null || textRuns.Count == 0) + if (textRuns.Count == 0) { return new TextRange(); } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 40e12c8e99..0ed06e4e57 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -401,14 +401,12 @@ namespace Avalonia.Media.TextFormatting previousLine = textLine; - if (currentPosition != _text.Length || textLine.TextLineBreak?.RemainingCharacters == null) + if (currentPosition == _text.Length && textLine.NewLineLength > 0) { - continue; - } + var emptyTextLine = CreateEmptyTextLine(currentPosition); - var emptyTextLine = CreateEmptyTextLine(currentPosition); - - textLines.Add(emptyTextLine); + textLines.Add(emptyTextLine); + } } Size = new Size(width, height); diff --git a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs index 881ddfd89f..ebff097199 100644 --- a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs @@ -11,4 +11,5 @@ using Avalonia.Metadata; [assembly: InternalsVisibleTo("Avalonia.Direct2D1.RenderTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Skia.RenderTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Skia.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] +[assembly: InternalsVisibleTo("Avalonia.Web.Blazor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] diff --git a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs index 7b9c515b97..c453181f65 100644 --- a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs @@ -304,7 +304,7 @@ namespace Avalonia.Rendering } } - private void Render(bool forceComposite) + internal void Render(bool forceComposite) { using (var l = _lock.TryLock()) { diff --git a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs index 7644514687..dc8b091563 100644 --- a/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs +++ b/src/Web/Avalonia.Web.Blazor/AvaloniaView.razor.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Embedding; using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; +using Avalonia.Rendering; using Avalonia.Web.Blazor.Interop; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -299,6 +300,18 @@ namespace Avalonia.Web.Blazor _topLevel.Prepare(); _topLevel.Renderer.Start(); + + // Note: this is technically a hack, but it's a kinda unique use case when + // we want to blit the previous frame + // renderer doesn't have much control over the render target + // we render on the UI thread + // We also don't want to have it as a meaningful public API. + // Therefore we have InternalsVisibleTo hack here. + if (_topLevel.Renderer is DeferredRenderer dr) + { + dr.Render(true); + } + Invalidate(); }); } @@ -327,6 +340,11 @@ namespace Avalonia.Web.Blazor _dpi = newDpi; _topLevelImpl.SetClientSize(_canvasSize, _dpi); + + if (_topLevel.Renderer is DeferredRenderer dr) + { + dr.Render(true); + } Invalidate(); } @@ -334,9 +352,16 @@ namespace Avalonia.Web.Blazor private void OnSizeChanged(SKSize newSize) { _canvasSize = newSize; + + _interop.SetCanvasSize((int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi)); _topLevelImpl.SetClientSize(_canvasSize, _dpi); + if (_topLevel.Renderer is DeferredRenderer dr) + { + dr.Render(true); + } + Invalidate(); } @@ -348,7 +373,7 @@ namespace Avalonia.Web.Blazor return; } - _interop.RequestAnimationFrame(true, (int)(_canvasSize.Width * _dpi), (int)(_canvasSize.Height * _dpi)); + _interop.RequestAnimationFrame(true); } public void SetActive(bool active) diff --git a/src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderTarget.cs b/src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderTarget.cs index ee7374634f..fa6a39f210 100644 --- a/src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderTarget.cs +++ b/src/Web/Avalonia.Web.Blazor/BlazorSkiaGpuRenderTarget.cs @@ -34,14 +34,6 @@ namespace Avalonia.Web.Blazor return new BlazorSkiaGpuRenderSession(_blazorSkiaSurface, _renderTarget); } - public bool IsCorrupted - { - get - { - var result = _size.Width != _renderTarget.Width || _size.Height != _renderTarget.Height; - - return result; - } - } + public bool IsCorrupted => _blazorSkiaSurface.Size != _size; } } diff --git a/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs b/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs index 4f5d4cdf70..9cbbf24086 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs +++ b/src/Web/Avalonia.Web.Blazor/Interop/SKHtmlCanvasInterop.cs @@ -11,6 +11,7 @@ namespace Avalonia.Web.Blazor.Interop private const string InitRasterSymbol = "SKHtmlCanvas.initRaster"; private const string DeinitSymbol = "SKHtmlCanvas.deinit"; private const string RequestAnimationFrameSymbol = "SKHtmlCanvas.requestAnimationFrame"; + private const string SetCanvasSizeSymbol = "SKHtmlCanvas.setCanvasSize"; private const string PutImageDataSymbol = "SKHtmlCanvas.putImageData"; private readonly ElementReference htmlCanvas; @@ -68,8 +69,11 @@ namespace Avalonia.Web.Blazor.Interop callbackReference?.Dispose(); } - public void RequestAnimationFrame(bool enableRenderLoop, int rawWidth, int rawHeight) => - Invoke(RequestAnimationFrameSymbol, htmlCanvas, enableRenderLoop, rawWidth, rawHeight); + public void RequestAnimationFrame(bool enableRenderLoop) => + Invoke(RequestAnimationFrameSymbol, htmlCanvas, enableRenderLoop); + + public void SetCanvasSize(int rawWidth, int rawHeight) => + Invoke(SetCanvasSizeSymbol, htmlCanvas, rawWidth, rawHeight); public void PutImageData(IntPtr intPtr, SKSizeI rawSize) => Invoke(PutImageDataSymbol, htmlCanvas, intPtr.ToInt64(), rawSize.Width, rawSize.Height); diff --git a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts index 147e2a963f..04d57a7756 100644 --- a/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts +++ b/src/Web/Avalonia.Web.Blazor/Interop/Typescript/SKHtmlCanvas.ts @@ -25,6 +25,8 @@ export class SKHtmlCanvas { renderFrameCallback: DotNet.DotNetObjectReference; renderLoopEnabled: boolean = false; renderLoopRequest: number = 0; + newWidth: number; + newHeight: number; public static initGL(element: HTMLCanvasElement, elementId: string, callback: DotNet.DotNetObjectReference): SKGLViewInfo { var view = SKHtmlCanvas.init(true, element, elementId, callback); @@ -75,13 +77,22 @@ export class SKHtmlCanvas { htmlCanvas.SKHtmlCanvas = undefined; } - public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean, width?: number, height?: number) { + public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean) { const htmlCanvas = element as SKHtmlCanvasElement; if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) return; - htmlCanvas.SKHtmlCanvas.requestAnimationFrame(renderLoop, width, height); + htmlCanvas.SKHtmlCanvas.requestAnimationFrame(renderLoop); } + + public static setCanvasSize(element: HTMLCanvasElement, width: number, height: number) + { + const htmlCanvas = element as SKHtmlCanvasElement; + if (!htmlCanvas || !htmlCanvas.SKHtmlCanvas) + return; + + htmlCanvas.SKHtmlCanvas.setCanvasSize(width, height); + } public static setEnableRenderLoop(element: HTMLCanvasElement, enable: boolean) { const htmlCanvas = element as SKHtmlCanvasElement; @@ -128,28 +139,53 @@ export class SKHtmlCanvas { public deinit() { this.setEnableRenderLoop(false); } - - public requestAnimationFrame(renderLoop?: boolean, width?: number, height?: number) { + + public setCanvasSize(width: number, height: number) + { + this.newWidth = width; + this.newHeight = height; + + if(this.htmlCanvas.width != this.newWidth) + { + this.htmlCanvas.width = this.newWidth; + } + + if(this.htmlCanvas.height != this.newHeight) + { + this.htmlCanvas.height = this.newHeight; + } + + if (this.glInfo) { + // make current + GL.makeContextCurrent(this.glInfo.context); + } + } + + public requestAnimationFrame(renderLoop?: boolean) { // optionally update the render loop if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop) this.setEnableRenderLoop(renderLoop); - // make sure the canvas is scaled correctly for the drawing - if (width && height) { - this.htmlCanvas.width = width; - this.htmlCanvas.height = height; - } - // skip because we have a render loop if (this.renderLoopRequest !== 0) return; // add the draw to the next frame this.renderLoopRequest = window.requestAnimationFrame(() => { - if (this.glInfo) { - // make current - GL.makeContextCurrent(this.glInfo.context); - } + if (this.glInfo) { + // make current + GL.makeContextCurrent(this.glInfo.context); + } + + if(this.htmlCanvas.width != this.newWidth) + { + this.htmlCanvas.width = this.newWidth; + } + + if(this.htmlCanvas.height != this.newHeight) + { + this.htmlCanvas.height = this.newHeight; + } this.renderFrameCallback.invokeMethod('Invoke'); this.renderLoopRequest = 0; diff --git a/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs b/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs index 1d667c0f0c..3cadbfaa60 100644 --- a/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs +++ b/src/Web/Avalonia.Web.Blazor/RazorViewTopLevelImpl.cs @@ -44,6 +44,16 @@ namespace Avalonia.Web.Blazor { var newSize = new Size(size.Width, size.Height); + if (Math.Abs(RenderScaling - dpi) > 0.0001) + { + if (_currentSurface is { }) + { + _currentSurface.Scaling = dpi; + } + + ScalingChanged?.Invoke(dpi); + } + if (newSize != _clientSize) { _clientSize = newSize; diff --git a/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs b/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs index f7a3bdea1c..3a2e1c08bd 100644 --- a/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform; -using Avalonia.Threading; using Avalonia.UnitTests; using Moq; using Xunit; @@ -57,7 +55,7 @@ namespace Avalonia.Controls.UnitTests var hasExit = false; - lifetime.Exit += (s, e) => hasExit = true; + lifetime.Exit += (_, _) => hasExit = true; var windowA = new Window(); @@ -91,7 +89,7 @@ namespace Avalonia.Controls.UnitTests var hasExit = false; - lifetime.Exit += (s, e) => hasExit = true; + lifetime.Exit += (_, _) => hasExit = true; var mainWindow = new Window(); @@ -119,7 +117,7 @@ namespace Avalonia.Controls.UnitTests var hasExit = false; - lifetime.Exit += (s, e) => hasExit = true; + lifetime.Exit += (_, _) => hasExit = true; var windowA = new Window(); @@ -226,7 +224,7 @@ namespace Avalonia.Controls.UnitTests window.Show(); - lifetime.ShutdownRequested += (s, e) => + lifetime.ShutdownRequested += (_, e) => { e.Cancel = true; ++raised; @@ -238,5 +236,171 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new[] { window }, lifetime.Windows); } } + + [Fact] + public void MainWindow_Closed_Shutdown_Should_Be_Cancellable() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) + { + lifetime.ShutdownMode = ShutdownMode.OnMainWindowClose; + + var hasExit = false; + + lifetime.Exit += (_, _) => hasExit = true; + + var mainWindow = new Window(); + + mainWindow.Show(); + + lifetime.MainWindow = mainWindow; + + var window = new Window(); + + window.Show(); + + var raised = 0; + + lifetime.ShutdownRequested += (_, e) => + { + e.Cancel = true; + ++raised; + }; + + mainWindow.Close(); + + Assert.Equal(1, raised); + Assert.False(hasExit); + } + } + + [Fact] + public void LastWindow_Closed_Shutdown_Should_Be_Cancellable() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) + { + lifetime.ShutdownMode = ShutdownMode.OnLastWindowClose; + + var hasExit = false; + + lifetime.Exit += (_, _) => hasExit = true; + + var windowA = new Window(); + + windowA.Show(); + + var windowB = new Window(); + + windowB.Show(); + + var raised = 0; + + lifetime.ShutdownRequested += (_, e) => + { + e.Cancel = true; + ++raised; + }; + + windowA.Close(); + + Assert.False(hasExit); + + windowB.Close(); + + Assert.Equal(1, raised); + Assert.False(hasExit); + } + } + + [Fact] + public void TryShutdown_Cancellable_By_Preventing_Window_Close() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) + { + var hasExit = false; + + lifetime.Exit += (_, _) => hasExit = true; + + var windowA = new Window(); + + windowA.Show(); + + var windowB = new Window(); + + windowB.Show(); + + var raised = 0; + + windowA.Closing += (_, e) => + { + e.Cancel = true; + ++raised; + }; + + lifetime.TryShutdown(); + + Assert.Equal(1, raised); + Assert.False(hasExit); + } + } + + [Fact] + public void Shutdown_NotCancellable_By_Preventing_Window_Close() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) + { + var hasExit = false; + + lifetime.Exit += (_, _) => hasExit = true; + + var windowA = new Window(); + + windowA.Show(); + + var windowB = new Window(); + + windowB.Show(); + + var raised = 0; + + windowA.Closing += (_, e) => + { + e.Cancel = true; + ++raised; + }; + + lifetime.Shutdown(); + + Assert.Equal(1, raised); + Assert.True(hasExit); + } + } + + [Fact] + public void Shutdown_Doesnt_Raise_Shutdown_Requested() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) + { + var hasExit = false; + + lifetime.Exit += (_, _) => hasExit = true; + + var raised = 0; + + lifetime.ShutdownRequested += (_, _) => + { + ++raised; + }; + + lifetime.Shutdown(); + + Assert.Equal(0, raised); + Assert.True(hasExit); + } + } } } diff --git a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj index d4abf9416a..f5e502bca8 100644 --- a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj +++ b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj @@ -29,4 +29,5 @@ + diff --git a/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs new file mode 100644 index 0000000000..002da66070 --- /dev/null +++ b/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Media; +using Avalonia.Media.Fonts; +using Avalonia.Platform; + +namespace Avalonia.UnitTests +{ + public class HarfBuzzFontManagerImpl : IFontManagerImpl + { + private readonly Typeface[] _customTypefaces; + private readonly string _defaultFamilyName; + + private static readonly Typeface _defaultTypeface = + new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono"); + private static readonly Typeface _italicTypeface = + new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Sans"); + private static readonly Typeface _emojiTypeface = + new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Twitter Color Emoji"); + + public HarfBuzzFontManagerImpl(string defaultFamilyName = "resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono") + { + _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface }; + _defaultFamilyName = defaultFamilyName; + } + + public string GetDefaultFontFamilyName() + { + return _defaultFamilyName; + } + + public IEnumerable GetInstalledFontFamilyNames(bool checkForUpdates = false) + { + return _customTypefaces.Select(x => x.FontFamily!.Name); + } + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontFamily fontFamily, + CultureInfo culture, out Typeface fontKey) + { + foreach (var customTypeface in _customTypefaces) + { + var glyphTypeface = customTypeface.GlyphTypeface; + + if (!glyphTypeface.TryGetGlyph((uint)codepoint, out _)) + { + continue; + } + + fontKey = customTypeface; + + return true; + } + + fontKey = default; + + return false; + } + + public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface) + { + var fontFamily = typeface.FontFamily; + + if (fontFamily == null) + { + return null; + } + + if (fontFamily.IsDefault) + { + fontFamily = _defaultTypeface.FontFamily; + } + + if (fontFamily!.Key == null) + { + return null; + } + + var fontAssets = FontFamilyLoader.LoadFontAssets(fontFamily.Key); + + var asset = fontAssets.First(); + + var assetLoader = AvaloniaLocator.Current.GetService(); + + if (assetLoader == null) + { + throw new NotSupportedException("IAssetLoader is not registered."); + } + + var stream = assetLoader.Open(asset); + + return new HarfBuzzGlyphTypefaceImpl(stream); + } + } +} diff --git a/tests/Avalonia.UnitTests/HarfBuzzGlyphTypefaceImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzGlyphTypefaceImpl.cs new file mode 100644 index 0000000000..32e0434cd4 --- /dev/null +++ b/tests/Avalonia.UnitTests/HarfBuzzGlyphTypefaceImpl.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using Avalonia.Platform; +using HarfBuzzSharp; + +namespace Avalonia.UnitTests +{ + public class HarfBuzzGlyphTypefaceImpl : IGlyphTypefaceImpl + { + private bool _isDisposed; + private Blob _blob; + + public HarfBuzzGlyphTypefaceImpl(Stream data, bool isFakeBold = false, bool isFakeItalic = false) + { + _blob = Blob.FromStream(data); + + Face = new Face(_blob, 0); + + Font = new Font(Face); + + Font.SetFunctionsOpenType(); + + Font.GetScale(out var scale, out _); + + DesignEmHeight = (short)scale; + + var metrics = Font.OpenTypeMetrics; + + const double defaultFontRenderingEmSize = 12.0; + + Ascent = (int)(metrics.GetXVariation(OpenTypeMetricsTag.HorizontalAscender) / defaultFontRenderingEmSize * DesignEmHeight); + + Descent = (int)(metrics.GetXVariation(OpenTypeMetricsTag.HorizontalDescender) / defaultFontRenderingEmSize * DesignEmHeight); + + LineGap = (int)(metrics.GetXVariation(OpenTypeMetricsTag.HorizontalLineGap) / defaultFontRenderingEmSize * DesignEmHeight); + + UnderlinePosition = (int)(metrics.GetXVariation(OpenTypeMetricsTag.UnderlineOffset) / defaultFontRenderingEmSize * DesignEmHeight); + + UnderlineThickness = (int)(metrics.GetXVariation(OpenTypeMetricsTag.UnderlineSize) / defaultFontRenderingEmSize * DesignEmHeight); + + StrikethroughPosition = (int)(metrics.GetXVariation(OpenTypeMetricsTag.StrikeoutOffset) / defaultFontRenderingEmSize * DesignEmHeight); + + StrikethroughThickness = (int)(metrics.GetXVariation(OpenTypeMetricsTag.StrikeoutSize) / defaultFontRenderingEmSize * DesignEmHeight); + + IsFixedPitch = GetGlyphAdvance(GetGlyph('a')) == GetGlyphAdvance(GetGlyph('b')); + + IsFakeBold = isFakeBold; + + IsFakeItalic = isFakeItalic; + } + + public Face Face { get; } + + public Font Font { get; } + + /// + public short DesignEmHeight { get; } + + /// + public int Ascent { get; } + + /// + public int Descent { get; } + + /// + public int LineGap { get; } + + /// + public int UnderlinePosition { get; } + + /// + public int UnderlineThickness { get; } + + /// + public int StrikethroughPosition { get; } + + /// + public int StrikethroughThickness { get; } + + /// + public bool IsFixedPitch { get; } + + public bool IsFakeBold { get; } + + public bool IsFakeItalic { get; } + + /// + public ushort GetGlyph(uint codepoint) + { + if (Font.TryGetGlyph(codepoint, out var glyph)) + { + return (ushort)glyph; + } + + return 0; + } + + /// + public ushort[] GetGlyphs(ReadOnlySpan codepoints) + { + var glyphs = new ushort[codepoints.Length]; + + for (var i = 0; i < codepoints.Length; i++) + { + if (Font.TryGetGlyph(codepoints[i], out var glyph)) + { + glyphs[i] = (ushort)glyph; + } + } + + return glyphs; + } + + /// + public int GetGlyphAdvance(ushort glyph) + { + return Font.GetHorizontalGlyphAdvance(glyph); + } + + /// + public int[] GetGlyphAdvances(ReadOnlySpan glyphs) + { + var glyphIndices = new uint[glyphs.Length]; + + for (var i = 0; i < glyphs.Length; i++) + { + glyphIndices[i] = glyphs[i]; + } + + return Font.GetHorizontalGlyphAdvances(glyphIndices); + } + + private void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + if (!disposing) + { + return; + } + + Font?.Dispose(); + Face?.Dispose(); + _blob?.Dispose(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs new file mode 100644 index 0000000000..687fddd71a --- /dev/null +++ b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs @@ -0,0 +1,147 @@ +using System; +using System.Globalization; +using Avalonia.Media; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; +using Avalonia.Utilities; +using HarfBuzzSharp; +using Buffer = HarfBuzzSharp.Buffer; + +namespace Avalonia.UnitTests +{ + public class HarfBuzzTextShaperImpl : ITextShaperImpl + { + public GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize, + CultureInfo culture) + { + using (var buffer = new Buffer()) + { + FillBuffer(buffer, text); + + buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture); + + buffer.GuessSegmentProperties(); + + var glyphTypeface = typeface.GlyphTypeface; + + var font = ((HarfBuzzGlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font; + + font.Shape(buffer); + + font.GetScale(out var scaleX, out _); + + var textScale = fontRenderingEmSize / scaleX; + + var bufferLength = buffer.Length; + + var glyphInfos = buffer.GetGlyphInfoSpan(); + + var glyphPositions = buffer.GetGlyphPositionSpan(); + + var glyphIndices = new ushort[bufferLength]; + + var clusters = new ushort[bufferLength]; + + double[] glyphAdvances = null; + + Vector[] glyphOffsets = null; + + for (var i = 0; i < bufferLength; i++) + { + glyphIndices[i] = (ushort)glyphInfos[i].Codepoint; + + clusters[i] = (ushort)glyphInfos[i].Cluster; + + if (!glyphTypeface.IsFixedPitch) + { + SetAdvance(glyphPositions, i, textScale, ref glyphAdvances); + } + + SetOffset(glyphPositions, i, textScale, ref glyphOffsets); + } + + return new GlyphRun(glyphTypeface, fontRenderingEmSize, + new ReadOnlySlice(glyphIndices), + new ReadOnlySlice(glyphAdvances), + new ReadOnlySlice(glyphOffsets), + text, + new ReadOnlySlice(clusters), + buffer.Direction == Direction.LeftToRight ? 0 : 1); + } + } + + private static void FillBuffer(Buffer buffer, ReadOnlySlice text) + { + buffer.ContentType = ContentType.Unicode; + + var i = 0; + + while (i < text.Length) + { + var codepoint = Codepoint.ReadAt(text, i, out var count); + + var cluster = (uint)(text.Start + i); + + if (codepoint.IsBreakChar) + { + if (i + 1 < text.Length) + { + var nextCodepoint = Codepoint.ReadAt(text, i + 1, out _); + + if (nextCodepoint == '\n' && codepoint == '\r') + { + count++; + + buffer.Add('\u200C', cluster); + + buffer.Add('\u200D', cluster); + } + else + { + buffer.Add('\u200C', cluster); + } + } + else + { + buffer.Add('\u200C', cluster); + } + } + else + { + buffer.Add(codepoint, cluster); + } + + i += count; + } + } + + private static void SetOffset(ReadOnlySpan glyphPositions, int index, double textScale, + ref Vector[] offsetBuffer) + { + var position = glyphPositions[index]; + + if (position.XOffset == 0 && position.YOffset == 0) + { + return; + } + + offsetBuffer ??= new Vector[glyphPositions.Length]; + + var offsetX = position.XOffset * textScale; + + var offsetY = position.YOffset * textScale; + + offsetBuffer[index] = new Vector(offsetX, offsetY); + } + + private static void SetAdvance(ReadOnlySpan glyphPositions, int index, double textScale, + ref double[] advanceBuffer) + { + advanceBuffer ??= new double[glyphPositions.Length]; + + // Depends on direction of layout + // advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale; + advanceBuffer[index] = glyphPositions[index].XAdvance * textScale; + } + } +} diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index 1e586e3bb1..da678fd74b 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -58,6 +58,12 @@ namespace Avalonia.UnitTests public static readonly TestServices RealStyler = new TestServices( styler: new Styler()); + public static readonly TestServices TextServices = new TestServices( + assetLoader: new AssetLoader(), + renderInterface: new MockPlatformRenderInterface(), + fontManagerImpl: new HarfBuzzFontManagerImpl(), + textShaperImpl: new HarfBuzzTextShaperImpl()); + public TestServices( IAssetLoader assetLoader = null, IFocusManager focusManager = null, diff --git a/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs index 6e5b8eb637..a48639a426 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs @@ -48,7 +48,16 @@ namespace Avalonia.Visuals.UnitTests.Media [Fact] public void Should_Use_FontManagerOptions_FontFallback() { - var options = new FontManagerOptions { FontFallbacks = new[] { new FontFallback { FontFamily = new FontFamily("MyFont"), UnicodeRange = UnicodeRange.Default} } }; + var options = new FontManagerOptions + { + FontFallbacks = new[] + { + new FontFallback + { + FontFamily = new FontFamily("MyFont"), UnicodeRange = UnicodeRange.Default + } + } + }; using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface .With(fontManagerImpl: new MockFontManagerImpl()))) diff --git a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs index 58feb4714a..f52bdc39c8 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs @@ -22,6 +22,7 @@ namespace Avalonia.Visuals.UnitTests.Media [Theory] public void Should_Get_Distance_From_CharacterHit(double[] advances, ushort[] clusters, int start, int trailingLength, double expectedDistance) { + using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) using (var glyphRun = CreateGlyphRun(advances, clusters)) { var characterHit = new CharacterHit(start, trailingLength); @@ -40,6 +41,7 @@ namespace Avalonia.Visuals.UnitTests.Media public void Should_Get_CharacterHit_FromDistance(double[] advances, ushort[] clusters, double distance, int start, int trailingLengthExpected, bool isInsideExpected) { + using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) using (var glyphRun = CreateGlyphRun(advances, clusters)) { var textBounds = glyphRun.GetCharacterHitFromDistance(distance, out var isInside); @@ -63,6 +65,7 @@ namespace Avalonia.Visuals.UnitTests.Media public void Should_Find_Nearest_CharacterHit(double[] advances, ushort[] clusters, int bidiLevel, int index, int expectedIndex, int expectedLength, double expectedWidth) { + using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel)) { var textBounds = glyphRun.FindNearestCharacterHit(index, out var width); @@ -87,6 +90,7 @@ namespace Avalonia.Visuals.UnitTests.Media int nextIndex, int nextLength, int bidiLevel) { + using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel)) { var characterHit = glyphRun.GetNextCaretCharacterHit(new CharacterHit(currentIndex, currentLength)); @@ -109,6 +113,7 @@ namespace Avalonia.Visuals.UnitTests.Media int previousIndex, int previousLength, int bidiLevel) { + using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel)) { var characterHit = glyphRun.GetPreviousCaretCharacterHit(new CharacterHit(currentIndex, currentLength)); @@ -128,6 +133,7 @@ namespace Avalonia.Visuals.UnitTests.Media [Theory] public void Should_Find_Glyph_Index(double[] advances, ushort[] clusters, int bidiLevel) { + using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel)) { if (glyphRun.IsLeftToRight)