diff --git a/samples/GpuInterop/MainWindow.axaml.cs b/samples/GpuInterop/MainWindow.axaml.cs index 8fc8926783..7cf0bc00e2 100644 --- a/samples/GpuInterop/MainWindow.axaml.cs +++ b/samples/GpuInterop/MainWindow.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Rendering; namespace GpuInterop { @@ -8,9 +9,9 @@ namespace GpuInterop { public MainWindow() { - this.InitializeComponent(); + InitializeComponent(); this.AttachDevTools(); - this.Renderer.DrawFps = true; + Renderer.Diagnostics.DebugOverlays = RendererDebugOverlays.Fps; } private void InitializeComponent() diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml index 20cc6c43e1..e1dbd20b07 100644 --- a/samples/RenderDemo/MainWindow.xaml +++ b/samples/RenderDemo/MainWindow.xaml @@ -26,6 +26,20 @@ IsHitTestVisible="False" /> + + + + + + + + + + diff --git a/samples/RenderDemo/MainWindow.xaml.cs b/samples/RenderDemo/MainWindow.xaml.cs index 877eb8016a..d85f3b6051 100644 --- a/samples/RenderDemo/MainWindow.xaml.cs +++ b/samples/RenderDemo/MainWindow.xaml.cs @@ -1,7 +1,9 @@ using System; +using System.Linq.Expressions; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Rendering; using RenderDemo.ViewModels; using MiniMvvm; @@ -11,13 +13,26 @@ namespace RenderDemo { public MainWindow() { - this.InitializeComponent(); + InitializeComponent(); this.AttachDevTools(); var vm = new MainWindowViewModel(); - vm.WhenAnyValue(x => x.DrawDirtyRects).Subscribe(x => Renderer.DrawDirtyRects = x); - vm.WhenAnyValue(x => x.DrawFps).Subscribe(x => Renderer.DrawFps = x); - this.DataContext = vm; + + void BindOverlay(Expression> expr, RendererDebugOverlays overlay) + => vm.WhenAnyValue(expr).Subscribe(x => + { + var diagnostics = Renderer.Diagnostics; + diagnostics.DebugOverlays = x ? + diagnostics.DebugOverlays | overlay : + diagnostics.DebugOverlays & ~overlay; + }); + + BindOverlay(x => x.DrawDirtyRects, RendererDebugOverlays.DirtyRects); + BindOverlay(x => x.DrawFps, RendererDebugOverlays.Fps); + BindOverlay(x => x.DrawLayoutTimeGraph, RendererDebugOverlays.LayoutTimeGraph); + BindOverlay(x => x.DrawRenderTimeGraph, RendererDebugOverlays.RenderTimeGraph); + + DataContext = vm; } private void InitializeComponent() diff --git a/samples/RenderDemo/ViewModels/MainWindowViewModel.cs b/samples/RenderDemo/ViewModels/MainWindowViewModel.cs index 19917c20df..caaa899b56 100644 --- a/samples/RenderDemo/ViewModels/MainWindowViewModel.cs +++ b/samples/RenderDemo/ViewModels/MainWindowViewModel.cs @@ -1,49 +1,66 @@ -using System.Reactive; -using System.Threading.Tasks; +using System.Threading.Tasks; using MiniMvvm; namespace RenderDemo.ViewModels { public class MainWindowViewModel : ViewModelBase { - private bool drawDirtyRects = false; - private bool drawFps = true; - private double width = 800; - private double height = 600; + private bool _drawDirtyRects; + private bool _drawFps = true; + private bool _drawLayoutTimeGraph; + private bool _drawRenderTimeGraph; + private double _width = 800; + private double _height = 600; public MainWindowViewModel() { ToggleDrawDirtyRects = MiniCommand.Create(() => DrawDirtyRects = !DrawDirtyRects); ToggleDrawFps = MiniCommand.Create(() => DrawFps = !DrawFps); + ToggleDrawLayoutTimeGraph = MiniCommand.Create(() => DrawLayoutTimeGraph = !DrawLayoutTimeGraph); + ToggleDrawRenderTimeGraph = MiniCommand.Create(() => DrawRenderTimeGraph = !DrawRenderTimeGraph); ResizeWindow = MiniCommand.CreateFromTask(ResizeWindowAsync); } public bool DrawDirtyRects { - get => drawDirtyRects; - set => this.RaiseAndSetIfChanged(ref drawDirtyRects, value); + get => _drawDirtyRects; + set => RaiseAndSetIfChanged(ref _drawDirtyRects, value); } public bool DrawFps { - get => drawFps; - set => this.RaiseAndSetIfChanged(ref drawFps, value); + get => _drawFps; + set => RaiseAndSetIfChanged(ref _drawFps, value); + } + + public bool DrawLayoutTimeGraph + { + get => _drawLayoutTimeGraph; + set => RaiseAndSetIfChanged(ref _drawLayoutTimeGraph, value); + } + + public bool DrawRenderTimeGraph + { + get => _drawRenderTimeGraph; + set => RaiseAndSetIfChanged(ref _drawRenderTimeGraph, value); } public double Width { - get => width; - set => this.RaiseAndSetIfChanged(ref width, value); + get => _width; + set => RaiseAndSetIfChanged(ref _width, value); } public double Height { - get => height; - set => this.RaiseAndSetIfChanged(ref height, value); + get => _height; + set => RaiseAndSetIfChanged(ref _height, value); } public MiniCommand ToggleDrawDirtyRects { get; } public MiniCommand ToggleDrawFps { get; } + public MiniCommand ToggleDrawLayoutTimeGraph { get; } + public MiniCommand ToggleDrawRenderTimeGraph { get; } public MiniCommand ResizeWindow { get; } private async Task ResizeWindowAsync() diff --git a/src/Avalonia.Base/Layout/LayoutManager.cs b/src/Avalonia.Base/Layout/LayoutManager.cs index 242d70821a..c4742bcba4 100644 --- a/src/Avalonia.Base/Layout/LayoutManager.cs +++ b/src/Avalonia.Base/Layout/LayoutManager.cs @@ -3,8 +3,9 @@ using System.Buffers; using System.Collections.Generic; using System.Diagnostics; using Avalonia.Logging; +using Avalonia.Rendering; using Avalonia.Threading; -using Avalonia.VisualTree; +using Avalonia.Utilities; #nullable enable @@ -24,6 +25,7 @@ namespace Avalonia.Layout private bool _disposed; private bool _queued; private bool _running; + private int _totalPassCount; public LayoutManager(ILayoutRoot owner) { @@ -33,6 +35,8 @@ namespace Avalonia.Layout public virtual event EventHandler? LayoutUpdated; + internal Action? LayoutPassTimed { get; set; } + /// public virtual void InvalidateMeasure(Layoutable control) { @@ -116,10 +120,9 @@ namespace Avalonia.Layout if (!_running) { - Stopwatch? stopwatch = null; - const LogEventLevel timingLogLevel = LogEventLevel.Information; - bool captureTiming = Logger.IsEnabled(timingLogLevel, LogArea.Layout); + var captureTiming = LayoutPassTimed is not null || Logger.IsEnabled(timingLogLevel, LogArea.Layout); + var startingTimestamp = 0L; if (captureTiming) { @@ -129,8 +132,7 @@ namespace Avalonia.Layout _toMeasure.Count, _toArrange.Count); - stopwatch = new Stopwatch(); - stopwatch.Start(); + startingTimestamp = Stopwatch.GetTimestamp(); } _toMeasure.BeginLoop(MaxPasses); @@ -139,6 +141,7 @@ namespace Avalonia.Layout try { _running = true; + ++_totalPassCount; for (var pass = 0; pass < MaxPasses; ++pass) { @@ -160,9 +163,10 @@ namespace Avalonia.Layout if (captureTiming) { - stopwatch!.Stop(); + var elapsed = StopwatchHelper.GetElapsedTime(startingTimestamp); + LayoutPassTimed?.Invoke(new LayoutPassTiming(_totalPassCount, elapsed)); - Logger.TryGet(timingLogLevel, LogArea.Layout)?.Log(this, "Layout pass finished in {Time}", stopwatch.Elapsed); + Logger.TryGet(timingLogLevel, LogArea.Layout)?.Log(this, "Layout pass finished in {Time}", elapsed); } } diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index c5d7ec61e0..668d650ffd 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -1,15 +1,12 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Numerics; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Avalonia.Collections; using Avalonia.Collections.Pooled; using Avalonia.Media; -using Avalonia.Rendering.Composition.Drawing; -using Avalonia.Rendering.Composition.Server; -using Avalonia.Threading; using Avalonia.VisualTree; // Special license applies License.md @@ -38,6 +35,9 @@ public class CompositingRenderer : IRendererWithCompositor /// public bool RenderOnlyOnRenderThread { get; set; } = true; + /// + public RendererDiagnostics Diagnostics { get; } + public CompositingRenderer(IRenderRoot root, Compositor compositor, Func> surfaces) { _root = root; @@ -46,20 +46,21 @@ public class CompositingRenderer : IRendererWithCompositor CompositionTarget = compositor.CreateCompositionTarget(surfaces); CompositionTarget.Root = ((Visual)root).AttachToCompositor(compositor); _update = Update; + Diagnostics = new RendererDiagnostics(); + Diagnostics.PropertyChanged += OnDiagnosticsPropertyChanged; } - /// - public bool DrawFps + private void OnDiagnosticsPropertyChanged(object? sender, PropertyChangedEventArgs e) { - get => CompositionTarget.DrawFps; - set => CompositionTarget.DrawFps = value; - } - - /// - public bool DrawDirtyRects - { - get => CompositionTarget.DrawDirtyRects; - set => CompositionTarget.DrawDirtyRects = value; + switch (e.PropertyName) + { + case nameof(RendererDiagnostics.DebugOverlays): + CompositionTarget.DebugOverlays = Diagnostics.DebugOverlays; + break; + case nameof(RendererDiagnostics.LastLayoutPassTiming): + CompositionTarget.LastLayoutPassTiming = Diagnostics.LastLayoutPassTiming; + break; + } } /// diff --git a/src/Avalonia.Base/Rendering/Composition/Server/DiagnosticTextRenderer.cs b/src/Avalonia.Base/Rendering/Composition/Server/DiagnosticTextRenderer.cs new file mode 100644 index 0000000000..6e97f00681 --- /dev/null +++ b/src/Avalonia.Base/Rendering/Composition/Server/DiagnosticTextRenderer.cs @@ -0,0 +1,66 @@ +using System; +using Avalonia.Media; +using Avalonia.Platform; + +namespace Avalonia.Rendering.Composition.Server +{ + /// + /// A class used to render diagnostic strings (only!), with caching of ASCII glyph runs. + /// + internal sealed class DiagnosticTextRenderer + { + private const char FirstChar = (char)32; + private const char LastChar = (char)126; + + private readonly GlyphRun[] _runs = new GlyphRun[LastChar - FirstChar + 1]; + + public double MaxHeight { get; } + + public DiagnosticTextRenderer(IGlyphTypeface typeface, double fontRenderingEmSize) + { + var chars = new char[LastChar - FirstChar + 1]; + for (var c = FirstChar; c <= LastChar; c++) + { + var index = c - FirstChar; + chars[index] = c; + var glyph = typeface.GetGlyph(c); + var run = new GlyphRun(typeface, fontRenderingEmSize, chars.AsMemory(index, 1), new[] { glyph }); + _runs[index] = run; + MaxHeight = Math.Max(run.Size.Height, MaxHeight); + } + } + + public Size MeasureAsciiText(ReadOnlySpan text) + { + var width = 0.0; + var height = 0.0; + + foreach (var c in text) + { + var effectiveChar = c is >= FirstChar and <= LastChar ? c : ' '; + var run = _runs[effectiveChar - FirstChar]; + width += run.Size.Width; + height = Math.Max(height, run.Size.Height); + } + + return new Size(width, height); + } + + public void DrawAsciiText(IDrawingContextImpl context, ReadOnlySpan text, IBrush foreground) + { + var offset = 0.0; + var originalTransform = context.Transform; + + foreach (var c in text) + { + var effectiveChar = c is >= FirstChar and <= LastChar ? c : ' '; + var run = _runs[effectiveChar - FirstChar]; + context.Transform = originalTransform * Matrix.CreateTranslation(offset, 0.0); + context.DrawGlyphRun(foreground, run.PlatformImpl); + offset += run.Size.Width; + } + + context.Transform = originalTransform; + } + } +} diff --git a/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs b/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs index ebab39cee8..03bd965fa8 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/FpsCounter.cs @@ -1,11 +1,8 @@ using System; using System.Diagnostics; using System.Globalization; -using System.Linq; using Avalonia.Media; -using Avalonia.Media.TextFormatting; using Avalonia.Platform; -using Avalonia.Utilities; // Special license applies License.md @@ -17,26 +14,18 @@ namespace Avalonia.Rendering.Composition.Server; internal class FpsCounter { private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + private readonly DiagnosticTextRenderer _textRenderer; + private int _framesThisSecond; private int _totalFrames; private int _fps; private TimeSpan _lastFpsUpdate; - const int FirstChar = 32; - const int LastChar = 126; - // ASCII chars - private GlyphRun[] _runs = new GlyphRun[LastChar - FirstChar + 1]; - - public FpsCounter(IGlyphTypeface typeface) - { - for (var c = FirstChar; c <= LastChar; c++) - { - var s = new string((char)c, 1); - var glyph = typeface.GetGlyph((uint)(s[0])); - _runs[c - FirstChar] = new GlyphRun(typeface, 18, s.AsMemory(), new ushort[] { glyph }); - } - } - public void FpsTick() => _framesThisSecond++; + public FpsCounter(DiagnosticTextRenderer textRenderer) + => _textRenderer = textRenderer; + + public void FpsTick() + => _framesThisSecond++; public void RenderFps(IDrawingContextImpl context, string aux) { @@ -53,27 +42,24 @@ internal class FpsCounter _lastFpsUpdate = now; } - var fpsLine = FormattableString.Invariant($"Frame #{_totalFrames:00000000} FPS: {_fps:000} ") + aux; - double width = 0; - double height = 0; - foreach (var ch in fpsLine) - { - var run = _runs[ch - FirstChar]; - width += run.Size.Width; - height = Math.Max(height, run.Size.Height); - } +#if NET6_0_OR_GREATER + var fpsLine = string.Create(CultureInfo.InvariantCulture, $"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}"); +#else + var fpsLine = FormattableString.Invariant($"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}"); +#endif - var rect = new Rect(0, 0, width + 3, height + 3); + var size = _textRenderer.MeasureAsciiText(fpsLine.AsSpan()); + var rect = new Rect(0.0, 0.0, size.Width + 3.0, size.Height + 3.0); context.DrawRectangle(Brushes.Black, null, rect); - double offset = 0; - foreach (var ch in fpsLine) - { - var run = _runs[ch - FirstChar]; - context.Transform = Matrix.CreateTranslation(offset, 0); - context.DrawGlyphRun(Brushes.White, run.PlatformImpl); - offset += run.Size.Width; - } + _textRenderer.DrawAsciiText(context, fpsLine.AsSpan(), Brushes.White); + } + + public void Reset() + { + _framesThisSecond = 0; + _totalFrames = 0; + _fps = 0; } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs b/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs new file mode 100644 index 0000000000..c926c75c52 --- /dev/null +++ b/src/Avalonia.Base/Rendering/Composition/Server/FrameTimeGraph.cs @@ -0,0 +1,176 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using Avalonia.Platform; + +namespace Avalonia.Rendering.Composition.Server; + +/// +/// Represents a simple time graph for diagnostics purpose, used to show layout and render times. +/// +internal sealed class FrameTimeGraph +{ + private const double HeaderPadding = 2.0; + + private readonly IPlatformRenderInterface _renderInterface; + private readonly ImmutableSolidColorBrush _borderBrush; + private readonly ImmutablePen _graphPen; + private readonly double[] _frameValues; + private readonly Size _size; + private readonly Size _headerSize; + private readonly Size _graphSize; + private readonly double _defaultMaxY; + private readonly string _title; + private readonly DiagnosticTextRenderer _textRenderer; + + private int _startFrameIndex; + private int _frameCount; + + public Size Size + => _size; + + public FrameTimeGraph(int maxFrames, Size size, double defaultMaxY, string title, + DiagnosticTextRenderer textRenderer) + { + Debug.Assert(maxFrames >= 1); + Debug.Assert(size.Width > 0.0); + Debug.Assert(size.Height > 0.0); + + _renderInterface = AvaloniaLocator.Current.GetRequiredService(); + _borderBrush = new ImmutableSolidColorBrush(0x80808080); + _graphPen = new ImmutablePen(Brushes.Blue); + _frameValues = new double[maxFrames]; + _size = size; + _headerSize = new Size(size.Width, textRenderer.MaxHeight + HeaderPadding * 2.0); + _graphSize = new Size(size.Width, size.Height - _headerSize.Height); + _defaultMaxY = defaultMaxY; + _title = title; + _textRenderer = textRenderer; + } + + public void AddFrameValue(double value) + { + if (_frameCount < _frameValues.Length) + { + _frameValues[_startFrameIndex + _frameCount] = value; + ++_frameCount; + } + else + { + // overwrite oldest value + _frameValues[_startFrameIndex] = value; + if (++_startFrameIndex == _frameValues.Length) + { + _startFrameIndex = 0; + } + } + } + + public void Reset() + { + _startFrameIndex = 0; + _frameCount = 0; + } + + public void Render(IDrawingContextImpl context) + { + var originalTransform = context.Transform; + context.PushClip(new Rect(_size)); + + context.DrawRectangle(_borderBrush, null, new RoundedRect(new Rect(_size))); + context.DrawRectangle(_borderBrush, null, new RoundedRect(new Rect(_headerSize))); + + context.Transform = originalTransform * Matrix.CreateTranslation(HeaderPadding, HeaderPadding); + _textRenderer.DrawAsciiText(context, _title.AsSpan(), Brushes.Black); + + if (_frameCount > 0) + { + var (min, avg, max) = GetYValues(); + + DrawLabelledValue(context, "Min", min, originalTransform, _headerSize.Width * 0.19); + DrawLabelledValue(context, "Avg", avg, originalTransform, _headerSize.Width * 0.46); + DrawLabelledValue(context, "Max", max, originalTransform, _headerSize.Width * 0.73); + + context.Transform = originalTransform * Matrix.CreateTranslation(0.0, _headerSize.Height); + context.DrawGeometry(null, _graphPen, BuildGraphGeometry(Math.Max(max, _defaultMaxY))); + } + + context.Transform = originalTransform; + context.PopClip(); + } + + private void DrawLabelledValue(IDrawingContextImpl context, string label, double value, in Matrix originalTransform, + double left) + { + context.Transform = originalTransform * Matrix.CreateTranslation(left + HeaderPadding, HeaderPadding); + + var brush = value <= _defaultMaxY ? Brushes.Black : Brushes.Red; + +#if NET6_0_OR_GREATER + Span buffer = stackalloc char[24]; + buffer.TryWrite(CultureInfo.InvariantCulture, $"{label}: {value,5:F2}ms", out var charsWritten); + _textRenderer.DrawAsciiText(context, buffer.Slice(0, charsWritten), brush); +#else + var text = FormattableString.Invariant($"{label}: {value,5:F2}ms"); + _textRenderer.DrawAsciiText(context, text.AsSpan(), brush); +#endif + } + + private IStreamGeometryImpl BuildGraphGeometry(double maxY) + { + Debug.Assert(_frameCount > 0); + + var graphGeometry = _renderInterface.CreateStreamGeometry(); + using var geometryContext = graphGeometry.Open(); + + var xRatio = _graphSize.Width / _frameValues.Length; + var yRatio = _graphSize.Height / maxY; + + geometryContext.BeginFigure(new Point(0.0, _graphSize.Height - GetFrameValue(0) * yRatio), false); + + for (var i = 1; i < _frameCount; ++i) + { + var x = Math.Round(i * xRatio); + var y = _graphSize.Height - GetFrameValue(i) * yRatio; + geometryContext.LineTo(new Point(x, y)); + } + + geometryContext.EndFigure(false); + return graphGeometry; + } + + private (double Min, double Average, double Max) GetYValues() + { + Debug.Assert(_frameCount > 0); + + var min = double.MaxValue; + var max = double.MinValue; + var total = 0.0; + + for (var i = 0; i < _frameCount; ++i) + { + var y = GetFrameValue(i); + + total += y; + + if (y < min) + { + min = y; + } + + if (y > max) + { + max = y; + } + } + + return (min, total / _frameCount, max); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetFrameValue(int frameOffset) + => _frameValues[(_startFrameIndex + frameOffset) % _frameValues.Length]; +} diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs index b172430fbb..8d959a9765 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Numerics; +using System.Diagnostics; using System.Threading; using Avalonia.Media; using Avalonia.Media.Imaging; @@ -22,10 +22,11 @@ namespace Avalonia.Rendering.Composition.Server private readonly ServerCompositor _compositor; private readonly Func> _surfaces; private static long s_nextId = 1; - public long Id { get; } - public ulong Revision { get; private set; } private IRenderTarget? _renderTarget; - private FpsCounter _fpsCounter = new FpsCounter(Typeface.Default.GlyphTypeface); + private DiagnosticTextRenderer? _diagnosticTextRenderer; + private FpsCounter? _fpsCounter; + private FrameTimeGraph? _renderTimeGraph; + private FrameTimeGraph? _layoutTimeGraph; private Rect _dirtyRect; private Random _random = new(); private Size _layerSize; @@ -35,10 +36,24 @@ namespace Avalonia.Rendering.Composition.Server private HashSet _attachedVisuals = new(); private Queue _adornerUpdateQueue = new(); + public long Id { get; } + public ulong Revision { get; private set; } public ICompositionTargetDebugEvents? DebugEvents { get; set; } public ReadbackIndices Readback { get; } = new(); public int RenderedVisuals { get; set; } + private DiagnosticTextRenderer DiagnosticTextRenderer + => _diagnosticTextRenderer ??= new DiagnosticTextRenderer(Typeface.Default.GlyphTypeface, 12.0); + + private FpsCounter FpsCounter + => _fpsCounter ??= new FpsCounter(DiagnosticTextRenderer); + + private FrameTimeGraph LayoutTimeGraph + => _layoutTimeGraph ??= CreateTimeGraph("Layout"); + + private FrameTimeGraph RenderTimeGraph + => _renderTimeGraph ??= CreateTimeGraph("Render"); + public ServerCompositionTarget(ServerCompositor compositor, Func> surfaces) : base(compositor) { @@ -47,6 +62,9 @@ namespace Avalonia.Rendering.Composition.Server Id = Interlocked.Increment(ref s_nextId); } + private FrameTimeGraph CreateTimeGraph(string title) + => new(360, new Size(360.0, 64.0), 1000.0 / 60.0, title, DiagnosticTextRenderer); + partial void OnIsEnabledChanged() { if (IsEnabled) @@ -62,7 +80,33 @@ namespace Avalonia.Rendering.Composition.Server v.Deactivate(); } } - + + partial void OnDebugOverlaysChanged() + { + if ((DebugOverlays & RendererDebugOverlays.Fps) == 0) + { + _fpsCounter?.Reset(); + } + + if ((DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) == 0) + { + _layoutTimeGraph?.Reset(); + } + + if ((DebugOverlays & RendererDebugOverlays.RenderTimeGraph) == 0) + { + _renderTimeGraph?.Reset(); + } + } + + partial void OnLastLayoutPassTimingChanged() + { + if ((DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) != 0) + { + LayoutTimeGraph.AddFrameValue(LastLayoutPassTiming.Elapsed.TotalMilliseconds); + } + } + partial void DeserializeChangesExtra(BatchStreamReader c) { _redrawRequested = true; @@ -92,7 +136,10 @@ namespace Avalonia.Rendering.Composition.Server return; Revision++; - + + var captureTiming = (DebugOverlays & RendererDebugOverlays.RenderTimeGraph) != 0; + var startingTimestamp = captureTiming ? Stopwatch.GetTimestamp() : 0L; + // Update happens in a separate phase to extend dirty rect if needed Root.Update(this); @@ -137,33 +184,69 @@ namespace Avalonia.Rendering.Composition.Server targetContext.DrawBitmap(RefCountable.CreateUnownedNotClonable(_layer), 1, new Rect(_layerSize), new Rect(Size), BitmapInterpolationMode.LowQuality); - - - if (DrawDirtyRects) - { - targetContext.DrawRectangle(new ImmutableSolidColorBrush( - new Color(30, (byte)_random.Next(255), (byte)_random.Next(255), - (byte)_random.Next(255))) - , null, _dirtyRect); - } - if (DrawFps) + if (DebugOverlays != RendererDebugOverlays.None) { - var nativeMem = ByteSizeHelper.ToString((ulong)( - (Compositor.BatchMemoryPool.CurrentUsage + Compositor.BatchMemoryPool.CurrentPool) * - Compositor.BatchMemoryPool.BufferSize), false); - var managedMem = ByteSizeHelper.ToString((ulong)( - (Compositor.BatchObjectPool.CurrentUsage + Compositor.BatchObjectPool.CurrentPool) * - Compositor.BatchObjectPool.ArraySize * - IntPtr.Size), false); - _fpsCounter.RenderFps(targetContext, FormattableString.Invariant($"M:{managedMem} / N:{nativeMem} R:{RenderedVisuals:0000}")); + if (captureTiming) + { + var elapsed = StopwatchHelper.GetElapsedTime(startingTimestamp); + RenderTimeGraph.AddFrameValue(elapsed.TotalMilliseconds); + } + + DrawOverlays(targetContext, layerSize); } + RenderedVisuals = 0; _dirtyRect = default; } } + private void DrawOverlays(IDrawingContextImpl targetContext, Size layerSize) + { + if ((DebugOverlays & RendererDebugOverlays.DirtyRects) != 0) + { + targetContext.DrawRectangle( + new ImmutableSolidColorBrush( + new Color(30, (byte)_random.Next(255), (byte)_random.Next(255), (byte)_random.Next(255))), + null, + _dirtyRect); + } + + if ((DebugOverlays & RendererDebugOverlays.Fps) != 0) + { + var nativeMem = ByteSizeHelper.ToString((ulong) ( + (Compositor.BatchMemoryPool.CurrentUsage + Compositor.BatchMemoryPool.CurrentPool) * + Compositor.BatchMemoryPool.BufferSize), false); + var managedMem = ByteSizeHelper.ToString((ulong) ( + (Compositor.BatchObjectPool.CurrentUsage + Compositor.BatchObjectPool.CurrentPool) * + Compositor.BatchObjectPool.ArraySize * + IntPtr.Size), false); + FpsCounter.RenderFps(targetContext, + FormattableString.Invariant($"M:{managedMem} / N:{nativeMem} R:{RenderedVisuals:0000}")); + } + + var top = 0.0; + + void DrawTimeGraph(FrameTimeGraph graph) + { + top += 8.0; + targetContext.Transform = Matrix.CreateTranslation(layerSize.Width - graph.Size.Width - 8.0, top); + graph.Render(targetContext); + top += graph.Size.Height; + } + + if ((DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) != 0) + { + DrawTimeGraph(LayoutTimeGraph); + } + + if ((DebugOverlays & RendererDebugOverlays.RenderTimeGraph) != 0) + { + DrawTimeGraph(RenderTimeGraph); + } + } + public Rect SnapToDevicePixels(Rect rect) => SnapToDevicePixels(rect, Scaling); private static Rect SnapToDevicePixels(Rect rect, double scale) diff --git a/src/Avalonia.Base/Rendering/IRenderer.cs b/src/Avalonia.Base/Rendering/IRenderer.cs index f3f5b5e99b..ba960ff5f3 100644 --- a/src/Avalonia.Base/Rendering/IRenderer.cs +++ b/src/Avalonia.Base/Rendering/IRenderer.cs @@ -1,5 +1,4 @@ using System; -using Avalonia.VisualTree; using System.Collections.Generic; using System.Threading.Tasks; using Avalonia.Rendering.Composition; @@ -12,15 +11,9 @@ namespace Avalonia.Rendering public interface IRenderer : IDisposable { /// - /// Gets or sets a value indicating whether the renderer should draw an FPS counter. + /// Gets a value indicating whether the renderer should draw specific diagnostics. /// - bool DrawFps { get; set; } - - /// - /// Gets or sets a value indicating whether the renderer should draw a visual representation - /// of its dirty rectangles. - /// - bool DrawDirtyRects { get; set; } + RendererDiagnostics Diagnostics { get; } /// /// Raised when a portion of the scene has been invalidated. diff --git a/src/Avalonia.Base/Rendering/LayoutPassTiming.cs b/src/Avalonia.Base/Rendering/LayoutPassTiming.cs new file mode 100644 index 0000000000..05dfdb7c42 --- /dev/null +++ b/src/Avalonia.Base/Rendering/LayoutPassTiming.cs @@ -0,0 +1,11 @@ +using System; + +namespace Avalonia.Rendering +{ + /// + /// Represents a single layout pass timing. + /// + /// The number of the layout pass. + /// The elapsed time during the layout pass. + public readonly record struct LayoutPassTiming(int PassCounter, TimeSpan Elapsed); +} diff --git a/src/Avalonia.Base/Rendering/RendererDebugOverlays.cs b/src/Avalonia.Base/Rendering/RendererDebugOverlays.cs new file mode 100644 index 0000000000..85932f1568 --- /dev/null +++ b/src/Avalonia.Base/Rendering/RendererDebugOverlays.cs @@ -0,0 +1,35 @@ +using System; + +namespace Avalonia.Rendering; + +/// +/// Represents the various types of overlays that can be drawn by a renderer. +/// +[Flags] +public enum RendererDebugOverlays +{ + /// + /// Do not draw any overlay. + /// + None = 0, + + /// + /// Draw a FPS counter. + /// + Fps = 1 << 0, + + /// + /// Draw invalidated rectangles each frame. + /// + DirtyRects = 1 << 1, + + /// + /// Draw a graph of past layout times. + /// + LayoutTimeGraph = 1 << 2, + + /// + /// Draw a graph of past render times. + /// + RenderTimeGraph = 1 << 3 +} diff --git a/src/Avalonia.Base/Rendering/RendererDiagnostics.cs b/src/Avalonia.Base/Rendering/RendererDiagnostics.cs new file mode 100644 index 0000000000..60165fcbe0 --- /dev/null +++ b/src/Avalonia.Base/Rendering/RendererDiagnostics.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; + +namespace Avalonia.Rendering +{ + /// + /// Manages configurable diagnostics that can be displayed by a renderer. + /// + public class RendererDiagnostics : INotifyPropertyChanged + { + private RendererDebugOverlays _debugOverlays; + private LayoutPassTiming _lastLayoutPassTiming; + private PropertyChangedEventArgs? _debugOverlaysChangedEventArgs; + private PropertyChangedEventArgs? _lastLayoutPassTimingChangedEventArgs; + + /// + /// Gets or sets which debug overlays are displayed by the renderer. + /// + public RendererDebugOverlays DebugOverlays + { + get => _debugOverlays; + set + { + if (_debugOverlays != value) + { + _debugOverlays = value; + OnPropertyChanged(_debugOverlaysChangedEventArgs ??= new(nameof(DebugOverlays))); + } + } + } + + /// + /// Gets or sets the last layout pass timing that the renderer may display. + /// + public LayoutPassTiming LastLayoutPassTiming + { + get => _lastLayoutPassTiming; + set + { + if (!_lastLayoutPassTiming.Equals(value)) + { + _lastLayoutPassTiming = value; + OnPropertyChanged(_lastLayoutPassTimingChangedEventArgs ??= new(nameof(LastLayoutPassTiming))); + } + } + } + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Called when a property changes on the object. + /// + /// The property change details. + protected virtual void OnPropertyChanged(PropertyChangedEventArgs args) + => PropertyChanged?.Invoke(this, args); + } +} diff --git a/src/Avalonia.Base/Utilities/StopwatchHelper.cs b/src/Avalonia.Base/Utilities/StopwatchHelper.cs new file mode 100644 index 0000000000..4719226ea4 --- /dev/null +++ b/src/Avalonia.Base/Utilities/StopwatchHelper.cs @@ -0,0 +1,19 @@ +using System; +using System.Diagnostics; + +namespace Avalonia.Utilities; + +/// +/// Allows using as timestamps without allocating. +/// +/// Equivalent to Stopwatch.GetElapsedTime in .NET 7. +internal static class StopwatchHelper +{ + private static readonly double s_timestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; + + public static TimeSpan GetElapsedTime(long startingTimestamp) + => GetElapsedTime(startingTimestamp, Stopwatch.GetTimestamp()); + + public static TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) + => new((long)((endingTimestamp - startingTimestamp) * s_timestampToTicks)); +} diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index a0dbf238dc..b1a1be1973 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -39,8 +39,8 @@ - - + + diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 06a829c418..cb2b8cfd1c 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -1,7 +1,7 @@ using System; +using System.ComponentModel; using Avalonia.Reactive; using Avalonia.Controls.Metadata; -using Avalonia.Controls.Notifications; using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; using Avalonia.Input; @@ -17,7 +17,6 @@ using Avalonia.Platform.Storage; using Avalonia.Rendering; using Avalonia.Styling; using Avalonia.Utilities; -using Avalonia.VisualTree; using Avalonia.Input.Platform; using System.Linq; @@ -106,6 +105,7 @@ namespace Avalonia.Controls private Border? _transparencyFallbackBorder; private TargetWeakEventSubscriber? _resourcesChangesSubscriber; private IStorageProvider? _storageProvider; + private LayoutDiagnosticBridge? _layoutDiagnosticBridge; /// /// Initializes static members of the class. @@ -194,7 +194,7 @@ namespace Avalonia.Controls ClientSize = impl.ClientSize; FrameSize = impl.FrameSize; - + this.GetObservable(PointerOverElementProperty) .Select( x => (x as InputElement)?.GetObservable(CursorProperty) ?? Observable.Empty()) @@ -328,8 +328,17 @@ namespace Avalonia.Controls { get { - if (_layoutManager == null) + if (_layoutManager is null) + { _layoutManager = CreateLayoutManager(); + + if (_layoutManager is LayoutManager typedLayoutManager && Renderer is not null) + { + _layoutDiagnosticBridge = new LayoutDiagnosticBridge(Renderer.Diagnostics, typedLayoutManager); + _layoutDiagnosticBridge.SetupBridge(); + } + } + return _layoutManager; } } @@ -435,6 +444,9 @@ namespace Avalonia.Controls Renderer?.Dispose(); Renderer = null!; + _layoutDiagnosticBridge?.Dispose(); + _layoutDiagnosticBridge = null; + _pointerOverPreProcessor?.OnCompleted(); _pointerOverPreProcessorSubscription?.Dispose(); _backGestureSubscription?.Dispose(); @@ -617,5 +629,49 @@ namespace Avalonia.Controls } ITextInputMethodImpl? ITextInputMethodRoot.InputMethod => PlatformImpl?.TryGetFeature(); + + /// + /// Provides layout pass timing from the layout manager to the renderer, for diagnostics purposes. + /// + private sealed class LayoutDiagnosticBridge : IDisposable + { + private readonly RendererDiagnostics _diagnostics; + private readonly LayoutManager _layoutManager; + private bool _isHandling; + + public LayoutDiagnosticBridge(RendererDiagnostics diagnostics, LayoutManager layoutManager) + { + _diagnostics = diagnostics; + _layoutManager = layoutManager; + + diagnostics.PropertyChanged += OnDiagnosticsPropertyChanged; + } + + public void SetupBridge() + { + var needsHandling = (_diagnostics.DebugOverlays & RendererDebugOverlays.LayoutTimeGraph) != 0; + if (needsHandling != _isHandling) + { + _isHandling = needsHandling; + _layoutManager.LayoutPassTimed = needsHandling + ? timing => _diagnostics.LastLayoutPassTiming = timing + : null; + } + } + + private void OnDiagnosticsPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(RendererDiagnostics.DebugOverlays)) + { + SetupBridge(); + } + } + + public void Dispose() + { + _diagnostics.PropertyChanged -= OnDiagnosticsPropertyChanged; + _layoutManager.LayoutPassTimed = null; + } + } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs index 3870cad7c5..3adad38ac6 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs @@ -1,11 +1,13 @@ using System; using System.ComponentModel; +using System.Runtime.CompilerServices; using Avalonia.Controls; using Avalonia.Diagnostics.Models; using Avalonia.Input; using Avalonia.Metadata; using Avalonia.Threading; using Avalonia.Reactive; +using Avalonia.Rendering; namespace Avalonia.Diagnostics.ViewModels { @@ -21,8 +23,6 @@ namespace Avalonia.Diagnostics.ViewModels private string? _focusedControl; private IInputElement? _pointerOverElement; private bool _shouldVisualizeMarginPadding = true; - private bool _shouldVisualizeDirtyRects; - private bool _showFpsOverlay; private bool _freezePopups; private string? _pointerOverElementName; private IInputRoot? _pointerOverRoot; @@ -75,69 +75,76 @@ namespace Avalonia.Diagnostics.ViewModels set => RaiseAndSetIfChanged(ref _shouldVisualizeMarginPadding, value); } - public bool ShouldVisualizeDirtyRects + public void ToggleVisualizeMarginPadding() + => ShouldVisualizeMarginPadding = !ShouldVisualizeMarginPadding; + + private IRenderer? TryGetRenderer() + => _root switch + { + TopLevel topLevel => topLevel.Renderer, + Controls.Application app => app.RendererRoot, + _ => null + }; + + private bool GetDebugOverlay(RendererDebugOverlays overlay) + => ((TryGetRenderer()?.Diagnostics.DebugOverlays ?? RendererDebugOverlays.None) & overlay) != 0; + + private void SetDebugOverlay(RendererDebugOverlays overlay, bool enable, + [CallerMemberName] string? propertyName = null) { - get => _shouldVisualizeDirtyRects; - set + if (TryGetRenderer() is not { } renderer) { - var changed = true; - if (_root is TopLevel topLevel && topLevel.Renderer is { }) - { - topLevel.Renderer.DrawDirtyRects = value; - } - else if (_root is Controls.Application app && app.RendererRoot is { }) - { - app.RendererRoot.DrawDirtyRects = value; - } - else - { - changed = false; - } - if (changed) - { - RaiseAndSetIfChanged(ref _shouldVisualizeDirtyRects, value); - } + return; } - } - public void ToggleVisualizeDirtyRects() - { - ShouldVisualizeDirtyRects = !ShouldVisualizeDirtyRects; + var oldValue = renderer.Diagnostics.DebugOverlays; + var newValue = enable ? oldValue | overlay : oldValue & ~overlay; + + if (oldValue == newValue) + { + return; + } + + renderer.Diagnostics.DebugOverlays = newValue; + RaisePropertyChanged(propertyName); } - public void ToggleVisualizeMarginPadding() + public bool ShowDirtyRectsOverlay { - ShouldVisualizeMarginPadding = !ShouldVisualizeMarginPadding; + get => GetDebugOverlay(RendererDebugOverlays.DirtyRects); + set => SetDebugOverlay(RendererDebugOverlays.DirtyRects, value); } + public void ToggleDirtyRectsOverlay() + => ShowDirtyRectsOverlay = !ShowDirtyRectsOverlay; + public bool ShowFpsOverlay { - get => _showFpsOverlay; - set - { - var changed = true; - if (_root is TopLevel topLevel && topLevel.Renderer is { }) - { - topLevel.Renderer.DrawFps = value; - } - else if (_root is Controls.Application app && app.RendererRoot is { }) - { - app.RendererRoot.DrawFps = value; - } - else - { - changed = false; - } - if(changed) - RaiseAndSetIfChanged(ref _showFpsOverlay, value); - } + get => GetDebugOverlay(RendererDebugOverlays.Fps); + set => SetDebugOverlay(RendererDebugOverlays.Fps, value); } public void ToggleFpsOverlay() + => ShowFpsOverlay = !ShowFpsOverlay; + + public bool ShowLayoutTimeGraphOverlay { - ShowFpsOverlay = !ShowFpsOverlay; + get => GetDebugOverlay(RendererDebugOverlays.LayoutTimeGraph); + set => SetDebugOverlay(RendererDebugOverlays.LayoutTimeGraph, value); } + public void ToggleLayoutTimeGraphOverlay() + => ShowLayoutTimeGraphOverlay = !ShowLayoutTimeGraphOverlay; + + public bool ShowRenderTimeGraphOverlay + { + get => GetDebugOverlay(RendererDebugOverlays.RenderTimeGraph); + set => SetDebugOverlay(RendererDebugOverlays.RenderTimeGraph, value); + } + + public void ToggleRenderTimeGraphOverlay() + => ShowRenderTimeGraphOverlay = !ShowRenderTimeGraphOverlay; + public ConsoleViewModel Console { get; } public ViewModelBase? Content @@ -254,10 +261,10 @@ namespace Avalonia.Diagnostics.ViewModels _pointerOverSubscription.Dispose(); _logicalTree.Dispose(); _visualTree.Dispose(); - if (_root is TopLevel top) + + if (TryGetRenderer() is { } renderer) { - top.Renderer.DrawDirtyRects = false; - top.Renderer.DrawFps = false; + renderer.Diagnostics.DebugOverlays = RendererDebugOverlays.None; } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs index a2ee37c625..ec88db6664 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs @@ -20,7 +20,7 @@ namespace Avalonia.Diagnostics.ViewModels { } - protected bool RaiseAndSetIfChanged([NotNullIfNotNull("value")] ref T field, T value, [CallerMemberName] string propertyName = null!) + protected bool RaiseAndSetIfChanged([NotNullIfNotNull("value")] ref T field, T value, [CallerMemberName] string? propertyName = null) { if (!EqualityComparer.Default.Equals(field, value)) { @@ -32,7 +32,7 @@ namespace Avalonia.Diagnostics.ViewModels return false; } - protected void RaisePropertyChanged([CallerMemberName] string propertyName = null!) + protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null) { var e = new PropertyChangedEventArgs(propertyName); OnPropertyChanged(e); diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml index 97e21079c1..eac807a5bc 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml @@ -65,28 +65,42 @@ - - + + - + - + + + + + + + + + + + diff --git a/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs index 466aba43ee..3d7dc66cc4 100644 --- a/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs @@ -1,15 +1,7 @@ -using System; -using System.Collections.Generic; -using Avalonia.Controls; -using Avalonia.Controls.Presenters; -using Avalonia.Controls.Templates; +using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Input.Raw; using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Rendering; using Avalonia.UnitTests; -using Moq; using Xunit; namespace Avalonia.Base.UnitTests.Input @@ -21,7 +13,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var device = new MouseDevice(); var impl = CreateTopLevelImplMock(renderer.Object); @@ -59,7 +51,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var device = new MouseDevice(); var impl = CreateTopLevelImplMock(renderer.Object); diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs index 1ac50446c0..629188800a 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerOverTests.cs @@ -22,7 +22,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var device = CreatePointerDeviceMock().Object; var impl = CreateTopLevelImplMock(renderer.Object); @@ -50,7 +50,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var device = CreatePointerDeviceMock().Object; var impl = CreateTopLevelImplMock(renderer.Object); @@ -93,7 +93,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var device = CreatePointerDeviceMock(pointerType: PointerType.Touch).Object; var impl = CreateTopLevelImplMock(renderer.Object); @@ -119,7 +119,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var pointer = new Mock(); var device = CreatePointerDeviceMock(pointer.Object).Object; var impl = CreateTopLevelImplMock(renderer.Object); @@ -155,7 +155,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var device = CreatePointerDeviceMock().Object; var impl = CreateTopLevelImplMock(renderer.Object); @@ -201,7 +201,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var deviceMock = CreatePointerDeviceMock(); var impl = CreateTopLevelImplMock(renderer.Object); var result = new List<(object?, string)>(); @@ -256,7 +256,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var deviceMock = CreatePointerDeviceMock(); var impl = CreateTopLevelImplMock(renderer.Object); var result = new List<(object?, string)>(); @@ -307,7 +307,7 @@ namespace Avalonia.Base.UnitTests.Input using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); var expectedPosition = new Point(15, 15); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var deviceMock = CreatePointerDeviceMock(); var impl = CreateTopLevelImplMock(renderer.Object); var result = new List<(object?, string, Point)>(); @@ -351,7 +351,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var deviceMock = CreatePointerDeviceMock(); var impl = CreateTopLevelImplMock(renderer.Object); @@ -405,7 +405,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var deviceMock = CreatePointerDeviceMock(); var impl = CreateTopLevelImplMock(renderer.Object); @@ -442,7 +442,7 @@ namespace Avalonia.Base.UnitTests.Input { using var app = UnitTestApplication.Start(new TestServices(inputManager: new InputManager())); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var deviceMock = CreatePointerDeviceMock(); var impl = CreateTopLevelImplMock(renderer.Object); diff --git a/tests/Avalonia.Base.UnitTests/VisualTests.cs b/tests/Avalonia.Base.UnitTests/VisualTests.cs index fb214a6b34..11bdc4bc68 100644 --- a/tests/Avalonia.Base.UnitTests/VisualTests.cs +++ b/tests/Avalonia.Base.UnitTests/VisualTests.cs @@ -150,7 +150,7 @@ namespace Avalonia.Base.UnitTests [Fact] public void Attaching_To_Visual_Tree_Should_Invalidate_Visual() { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var child = new Decorator(); var root = new TestRoot { @@ -165,7 +165,7 @@ namespace Avalonia.Base.UnitTests [Fact] public void Detaching_From_Visual_Tree_Should_Invalidate_Visual() { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var child = new Decorator(); var root = new TestRoot { @@ -307,7 +307,7 @@ namespace Avalonia.Base.UnitTests public void Changing_ZIndex_Should_InvalidateVisual() { Canvas canvas1; - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var root = new TestRoot { Child = new StackPanel @@ -331,7 +331,7 @@ namespace Avalonia.Base.UnitTests { Canvas canvas1; StackPanel stackPanel; - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var root = new TestRoot { Child = stackPanel = new StackPanel diff --git a/tests/Avalonia.Benchmarks/NullRenderer.cs b/tests/Avalonia.Benchmarks/NullRenderer.cs deleted file mode 100644 index feb325f630..0000000000 --- a/tests/Avalonia.Benchmarks/NullRenderer.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Avalonia.Rendering; -using Avalonia.VisualTree; - -namespace Avalonia.Benchmarks -{ - internal class NullRenderer : IRenderer - { - public bool DrawFps { get; set; } - public bool DrawDirtyRects { get; set; } -#pragma warning disable CS0067 - public event EventHandler SceneInvalidated; -#pragma warning restore CS0067 - public void AddDirty(Visual visual) - { - } - - public void Dispose() - { - } - - public IEnumerable HitTest(Point p, Visual root, Func filter) => null; - - public Visual HitTestFirst(Point p, Visual root, Func filter) => null; - - public void Paint(Rect rect) - { - } - - public void RecalculateChildren(Visual visual) - { - } - - public void Resized(Size size) - { - } - - public void Start() - { - } - - public void Stop() - { - } - - public ValueTask TryGetRenderInterfaceFeature(Type featureType) => new(0); - } -} diff --git a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs index 8bd51ec500..4ff98bdedd 100644 --- a/tests/Avalonia.Controls.UnitTests/ButtonTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ButtonTests.cs @@ -134,16 +134,16 @@ namespace Avalonia.Controls.UnitTests [Fact] public void Button_Raises_Click() { - var renderer = Mock.Of(); + var renderer = RendererMocks.CreateRenderer(); var pt = new Point(50, 50); - Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) + renderer.Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) .Returns>((p, r, f) => r.Bounds.Contains(p) ? new Visual[] { r } : new Visual[0]); var target = new TestButton() { Bounds = new Rect(0, 0, 100, 100), - Renderer = renderer + Renderer = renderer.Object }; bool clicked = false; @@ -166,16 +166,16 @@ namespace Avalonia.Controls.UnitTests [Fact] public void Button_Does_Not_Raise_Click_When_PointerReleased_Outside() { - var renderer = Mock.Of(); - - Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) + var renderer = RendererMocks.CreateRenderer(); + + renderer.Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) .Returns>((p, r, f) => r.Bounds.Contains(p) ? new Visual[] { r } : new Visual[0]); var target = new TestButton() { Bounds = new Rect(0, 0, 100, 100), - Renderer = renderer + Renderer = renderer.Object }; bool clicked = false; @@ -199,9 +199,9 @@ namespace Avalonia.Controls.UnitTests [Fact] public void Button_With_RenderTransform_Raises_Click() { - var renderer = Mock.Of(); + var renderer = RendererMocks.CreateRenderer(); var pt = new Point(150, 50); - Mock.Get(renderer).Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) + renderer.Setup(r => r.HitTest(It.IsAny(), It.IsAny(), It.IsAny>())) .Returns>((p, r, f) => r.Bounds.Contains(p.Transform(r.RenderTransform.Value.Invert())) ? new Visual[] { r } : new Visual[0]); @@ -210,7 +210,7 @@ namespace Avalonia.Controls.UnitTests { Bounds = new Rect(0, 0, 100, 100), RenderTransform = new TranslateTransform { X = 100, Y = 0 }, - Renderer = renderer + Renderer = renderer.Object }; //actual bounds of button should be 100,0,100,100 x -> translated 100 pixels diff --git a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs index baf933bd66..d99c90cb77 100644 --- a/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ContextMenuTests.cs @@ -595,7 +595,7 @@ namespace Avalonia.Controls.UnitTests private static Window PreparedWindow(object content = null) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var platform = AvaloniaLocator.Current.GetRequiredService(); var windowImpl = Mock.Get(platform.CreateWindow()); windowImpl.Setup(x => x.CreateRenderer(It.IsAny())).Returns(renderer.Object); diff --git a/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs b/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs index 3a2e1c08bd..8cd5816984 100644 --- a/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform; +using Avalonia.Rendering; using Avalonia.UnitTests; using Moq; using Xunit; @@ -189,6 +190,8 @@ namespace Avalonia.Controls.UnitTests public void Impl_Closing_Should_Remove_Window_From_OpenWindows() { var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.SetupProperty(x => x.Closed); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); diff --git a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs index 02767a21eb..7767de11c7 100644 --- a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs +++ b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs @@ -569,7 +569,7 @@ namespace Avalonia.Controls.UnitTests private static Window PreparedWindow(object content = null) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var platform = AvaloniaLocator.Current.GetRequiredService(); var windowImpl = Mock.Get(platform.CreateWindow()); windowImpl.Setup(x => x.CreateRenderer(It.IsAny())).Returns(renderer.Object); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index f4206959a9..4804b29fee 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -563,7 +563,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { using (CreateServices()) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var platform = AvaloniaLocator.Current.GetRequiredService(); var windowImpl = Mock.Get(platform.CreateWindow()); windowImpl.Setup(x => x.CreateRenderer(It.IsAny())).Returns(renderer.Object); diff --git a/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs b/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs index 8f9af52ed8..a10b1324d6 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs @@ -110,6 +110,8 @@ namespace Avalonia.Controls.UnitTests public void IsVisible_Should_Be_False_Atfer_Impl_Signals_Close() { var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); windowImpl.SetupProperty(x => x.Closed); @@ -129,6 +131,8 @@ namespace Avalonia.Controls.UnitTests public void Setting_IsVisible_True_Shows_Window() { var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); @@ -145,6 +149,8 @@ namespace Avalonia.Controls.UnitTests public void Setting_IsVisible_False_Hides_Window() { var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); @@ -163,7 +169,7 @@ namespace Avalonia.Controls.UnitTests { using (UnitTestApplication.Start(TestServices.StyledWindow)) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var target = new TestWindowBase(renderer.Object); target.Show(); @@ -194,7 +200,7 @@ namespace Avalonia.Controls.UnitTests using (UnitTestApplication.Start(TestServices.StyledWindow)) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var target = new TestWindowBase(renderer.Object); target.Show(); @@ -209,7 +215,7 @@ namespace Avalonia.Controls.UnitTests { using (UnitTestApplication.Start(TestServices.StyledWindow)) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var windowImpl = new Mock(); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); @@ -240,12 +246,15 @@ namespace Avalonia.Controls.UnitTests public bool IsClosed { get; private set; } public TestWindowBase(IRenderer renderer = null) - : base(Mock.Of(x => - x.RenderScaling == 1 && - x.CreateRenderer(It.IsAny()) == renderer)) + : base(CreateWindowsBaseImplMock(renderer ?? RendererMocks.CreateRenderer().Object)) { } + private static IWindowBaseImpl CreateWindowsBaseImplMock(IRenderer renderer) + => Mock.Of(x => + x.RenderScaling == 1 && + x.CreateRenderer(It.IsAny()) == renderer); + public TestWindowBase(IWindowBaseImpl impl) : base(impl) { diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index ca245005c2..014174990e 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -98,6 +98,8 @@ namespace Avalonia.Controls.UnitTests public void IsVisible_Should_Be_False_After_Impl_Signals_Close() { var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.SetupProperty(x => x.Closed); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); @@ -269,7 +271,7 @@ namespace Avalonia.Controls.UnitTests { using (UnitTestApplication.Start(TestServices.StyledWindow)) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var target = new Window(CreateImpl(renderer)); target.Show(); @@ -284,7 +286,7 @@ namespace Avalonia.Controls.UnitTests using (UnitTestApplication.Start(TestServices.StyledWindow)) { var parent = new Window(); - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var target = new Window(CreateImpl(renderer)); parent.Show(); @@ -317,7 +319,7 @@ namespace Avalonia.Controls.UnitTests { using (UnitTestApplication.Start(TestServices.StyledWindow)) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); var target = new Window(CreateImpl(renderer)); target.Show(); @@ -334,6 +336,8 @@ namespace Avalonia.Controls.UnitTests { var parent = new Window(); var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.SetupProperty(x => x.Closed); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); @@ -375,6 +379,8 @@ namespace Avalonia.Controls.UnitTests { var parent = new Window(); var windowImpl = new Mock(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.SetupProperty(x => x.Closed); windowImpl.Setup(x => x.DesktopScaling).Returns(1); windowImpl.Setup(x => x.RenderScaling).Returns(1); diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index 678fb5c163..c9f79871c9 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -462,7 +462,7 @@ namespace Avalonia.LeakTests { using (Start()) { - var renderer = new Mock(); + var renderer = RendererMocks.CreateRenderer(); renderer.Setup(x => x.Dispose()); var impl = new Mock(); impl.Setup(r => r.TryGetFeature(It.IsAny())).Returns(null); @@ -1029,46 +1029,5 @@ namespace Avalonia.LeakTests public IEnumerable Children { get; set; } } - private class NullRenderer : IRenderer - { - public bool DrawFps { get; set; } - public bool DrawDirtyRects { get; set; } -#pragma warning disable CS0067 - public event EventHandler SceneInvalidated; -#pragma warning restore CS0067 - public void AddDirty(Visual visual) - { - } - - public void Dispose() - { - } - - public IEnumerable HitTest(Point p, Visual root, Func filter) => null; - - public Visual HitTestFirst(Point p, Visual root, Func filter) => null; - - public void Paint(Rect rect) - { - } - - public void RecalculateChildren(Visual visual) - { - } - - public void Resized(Size size) - { - } - - public void Start() - { - } - - public void Stop() - { - } - - public ValueTask TryGetRenderInterfaceFeature(Type featureType) => new(null); - } } } diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index 7f28477d09..142a9cd8ee 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -1,6 +1,5 @@ using System; using Avalonia.Controls.Primitives.PopupPositioning; -using Avalonia.Input; using Moq; using Avalonia.Platform; using Avalonia.Rendering; @@ -28,6 +27,8 @@ namespace Avalonia.UnitTests var clientSize = new Size(initialWidth, initialHeight); windowImpl.SetupAllProperties(); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); windowImpl.Setup(x => x.ClientSize).Returns(() => clientSize); windowImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize); windowImpl.Setup(x => x.DesktopScaling).Returns(1); @@ -92,6 +93,8 @@ namespace Avalonia.UnitTests var positioner = new ManagedPopupPositioner(positionerHelper); popupImpl.SetupAllProperties(); + popupImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(() => RendererMocks.CreateRenderer().Object); popupImpl.Setup(x => x.ClientSize).Returns(() => clientSize); popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize); popupImpl.Setup(x => x.RenderScaling).Returns(1); diff --git a/tests/Avalonia.UnitTests/NullRenderer.cs b/tests/Avalonia.UnitTests/NullRenderer.cs new file mode 100644 index 0000000000..1b59aa30eb --- /dev/null +++ b/tests/Avalonia.UnitTests/NullRenderer.cs @@ -0,0 +1,57 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Rendering; + +namespace Avalonia.UnitTests; + +public sealed class NullRenderer : IRenderer +{ + public RendererDiagnostics Diagnostics { get; } = new(); + + public event EventHandler? SceneInvalidated; + + public NullRenderer() + { + } + + public void AddDirty(Visual visual) + { + } + + public void Dispose() + { + } + + public IEnumerable HitTest(Point p, Visual root, Func filter) + => Enumerable.Empty(); + + public Visual? HitTestFirst(Point p, Visual root, Func filter) + => null; + + public void Paint(Rect rect) + { + } + + public void RecalculateChildren(Visual visual) + { + } + + public void Resized(Size size) + { + } + + public void Start() + { + } + + public void Stop() + { + } + + public ValueTask TryGetRenderInterfaceFeature(Type featureType) + => new((object?) null); +} diff --git a/tests/Avalonia.UnitTests/RendererMocks.cs b/tests/Avalonia.UnitTests/RendererMocks.cs new file mode 100644 index 0000000000..d4808a7556 --- /dev/null +++ b/tests/Avalonia.UnitTests/RendererMocks.cs @@ -0,0 +1,15 @@ +using Avalonia.Rendering; +using Moq; + +namespace Avalonia.UnitTests +{ + public static class RendererMocks + { + public static Mock CreateRenderer() + { + var renderer = new Mock(); + renderer.SetupGet(r => r.Diagnostics).Returns(new RendererDiagnostics()); + return renderer; + } + } +} diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index 93c04057ef..875c5eb944 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -1,9 +1,7 @@ -using System; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; using Avalonia.LogicalTree; -using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering; using Avalonia.Styling; @@ -18,7 +16,7 @@ namespace Avalonia.UnitTests public TestRoot() { - Renderer = Mock.Of(); + Renderer = RendererMocks.CreateRenderer().Object; LayoutManager = new LayoutManager(this); IsVisible = true; KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);