diff --git a/Avalonia.sln b/Avalonia.sln index ac678ba9ba..16fc4d0121 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -199,6 +199,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.ReactiveUI.UnitTes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Controls.DataGrid", "src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj", "{3278F3A9-9509-4A3F-A15B-BDC8B5BFF632}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless", "src\Avalonia.Headless\Avalonia.Headless.csproj", "{8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Headless.Vnc", "src\Avalonia.Headless.Vnc\Avalonia.Headless.Vnc.csproj", "{B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution src\Shared\RenderHelpers\RenderHelpers.projitems*{3c4c0cb4-0c0f-4450-a37b-148c84ff905f}*SharedItemsImports = 13 @@ -1842,6 +1846,54 @@ Global {3278F3A9-9509-4A3F-A15B-BDC8B5BFF632}.Release|iPhone.Build.0 = Release|Any CPU {3278F3A9-9509-4A3F-A15B-BDC8B5BFF632}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {3278F3A9-9509-4A3F-A15B-BDC8B5BFF632}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.AppStore|iPhone.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Debug|iPhone.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Release|Any CPU.Build.0 = Release|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Release|iPhone.ActiveCfg = Release|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Release|iPhone.Build.0 = Release|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.AppStore|iPhone.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Debug|iPhone.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Release|Any CPU.Build.0 = Release|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Release|iPhone.ActiveCfg = Release|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Release|iPhone.Build.0 = Release|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {B859AE7C-F34F-4A9E-88AE-E0E7229FDE1E}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj b/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj index 6a40f7187d..d9ce776ffd 100644 --- a/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj +++ b/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj @@ -7,11 +7,12 @@ + - + diff --git a/samples/ControlCatalog.NetCore/Program.cs b/samples/ControlCatalog.NetCore/Program.cs index 40321496c0..91f05ca719 100644 --- a/samples/ControlCatalog.NetCore/Program.cs +++ b/samples/ControlCatalog.NetCore/Program.cs @@ -6,6 +6,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Skia; using Avalonia.ReactiveUI; +using Avalonia.Threading; namespace ControlCatalog.NetCore { @@ -32,6 +33,10 @@ namespace ControlCatalog.NetCore System.Threading.ThreadPool.QueueUserWorkItem(_ => ConsoleSilencer()); return builder.StartLinuxFramebuffer(args); } + else if (args.Contains("--vnc")) + { + return builder.StartWithHeadlessVncPlatform(null, 5901, args, ShutdownMode.OnMainWindowClose); + } else return builder.StartWithClassicDesktopLifetime(args); } diff --git a/src/Avalonia.Headless.Vnc/Avalonia.Headless.Vnc.csproj b/src/Avalonia.Headless.Vnc/Avalonia.Headless.Vnc.csproj new file mode 100644 index 0000000000..4aac862ae8 --- /dev/null +++ b/src/Avalonia.Headless.Vnc/Avalonia.Headless.Vnc.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + + + + + + + + diff --git a/src/Avalonia.Headless.Vnc/HeadlessVncFramebufferSource.cs b/src/Avalonia.Headless.Vnc/HeadlessVncFramebufferSource.cs new file mode 100644 index 0000000000..df68807195 --- /dev/null +++ b/src/Avalonia.Headless.Vnc/HeadlessVncFramebufferSource.cs @@ -0,0 +1,96 @@ +using System; +using System.Runtime.InteropServices; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using RemoteViewing.Vnc; +using RemoteViewing.Vnc.Server; + +namespace Avalonia.Headless.Vnc +{ + public class HeadlessVncFramebufferSource : IVncFramebufferSource + { + public IHeadlessWindow Window { get; set; } + private object _lock = new object(); + public VncFramebuffer _framebuffer = new VncFramebuffer("Avalonia", 1, 1, VncPixelFormat.RGB32); + + private VncButton _previousButtons; + public HeadlessVncFramebufferSource(VncServerSession session, Window window) + { + Window = (IHeadlessWindow)window.PlatformImpl; + session.PointerChanged += (_, args) => + { + var pt = new Point(args.X, args.Y); + + var buttons = (VncButton)args.PressedButtons; + + int TranslateButton(VncButton vncButton) => + vncButton == VncButton.Left ? 0 : vncButton == VncButton.Right ? 1 : 2; + + var modifiers = (InputModifiers)(((int)buttons & 7) << 4); + + Dispatcher.UIThread.Post(() => + { + Window?.MouseMove(pt); + foreach (var btn in CheckedButtons) + if (_previousButtons.HasFlag(btn) && !buttons.HasFlag(btn)) + Window?.MouseUp(pt, TranslateButton(btn), modifiers); + + foreach (var btn in CheckedButtons) + if (!_previousButtons.HasFlag(btn) && buttons.HasFlag(btn)) + Window?.MouseDown(pt, TranslateButton(btn), modifiers); + _previousButtons = buttons; + }, DispatcherPriority.Input); + }; + + } + + [Flags] + enum VncButton + { + Left = 1, + Middle = 2, + Right = 4, + ScrollUp = 8, + ScrollDown = 16 + } + + + private static VncButton[] CheckedButtons = new[] {VncButton.Left, VncButton.Middle, VncButton.Right}; + + public VncFramebuffer Capture() + { + lock (_lock) + { + using (var bmpRef = Window.GetLastRenderedFrame()) + { + if (bmpRef?.Item == null) + return _framebuffer; + var bmp = bmpRef.Item; + if (bmp.PixelSize.Width != _framebuffer.Width || bmp.PixelSize.Height != _framebuffer.Height) + { + _framebuffer = new VncFramebuffer("Avalonia", bmp.PixelSize.Width, bmp.PixelSize.Height, + VncPixelFormat.RGB32); + } + + using (var fb = bmp.Lock()) + { + var buf = _framebuffer.GetBuffer(); + if (_framebuffer.Stride == fb.RowBytes) + Marshal.Copy(fb.Address, buf, 0, buf.Length); + else + for (var y = 0; y < fb.Size.Height; y++) + { + var sourceStart = fb.RowBytes * y; + var dstStart = _framebuffer.Stride * y; + var row = fb.Size.Width * 4; + Marshal.Copy(new IntPtr(sourceStart + fb.Address.ToInt64()), buf, dstStart, row); + } + } + } + } + + return _framebuffer; + } + } +} diff --git a/src/Avalonia.Headless.Vnc/HeadlessVncPlatformExtensions.cs b/src/Avalonia.Headless.Vnc/HeadlessVncPlatformExtensions.cs new file mode 100644 index 0000000000..bd215296c8 --- /dev/null +++ b/src/Avalonia.Headless.Vnc/HeadlessVncPlatformExtensions.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Sockets; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Headless; +using Avalonia.Headless.Vnc; +using RemoteViewing.Vnc; +using RemoteViewing.Vnc.Server; + +namespace Avalonia +{ + public static class HeadlessVncPlatformExtensions + { + public static int StartWithHeadlessVncPlatform( + this T builder, + string host, int port, + string[] args, ShutdownMode shutdownMode = ShutdownMode.OnLastWindowClose) + where T : AppBuilderBase, new() + { + var tcpServer = new TcpListener(host == null ? IPAddress.Loopback : IPAddress.Parse(host), port); + tcpServer.Start(); + return builder + .UseHeadless(false) + .AfterSetup(_ => + { + var lt = ((IClassicDesktopStyleApplicationLifetime)builder.Instance.ApplicationLifetime); + lt.Startup += async delegate + { + while (true) + { + var client = await tcpServer.AcceptTcpClientAsync(); + var options = new VncServerSessionOptions + { + AuthenticationMethod = AuthenticationMethod.None + }; + var session = new VncServerSession(); + + session.SetFramebufferSource(new HeadlessVncFramebufferSource( + session, lt.MainWindow)); + session.Connect(client.GetStream(), options); + } + + }; + }) + .StartWithClassicDesktopLifetime(args, shutdownMode); + } + } +} diff --git a/src/Avalonia.Headless/Avalonia.Headless.csproj b/src/Avalonia.Headless/Avalonia.Headless.csproj new file mode 100644 index 0000000000..b871316a2c --- /dev/null +++ b/src/Avalonia.Headless/Avalonia.Headless.csproj @@ -0,0 +1,8 @@ + + + netstandard2.0 + + + + + diff --git a/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs b/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs new file mode 100644 index 0000000000..5857d94182 --- /dev/null +++ b/src/Avalonia.Headless/AvaloniaHeadlessPlatform.cs @@ -0,0 +1,93 @@ +using System; +using System.Diagnostics; +using System.Reactive.Disposables; +using Avalonia.Controls; +using Avalonia.Controls.Platform; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Threading; + +namespace Avalonia.Headless +{ + public static class AvaloniaHeadlessPlatform + { + class RenderTimer : DefaultRenderTimer + { + private readonly int _framesPerSecond; + private Action _forceTick; + protected override IDisposable StartCore(Action tick) + { + bool cancelled = false; + var st = Stopwatch.StartNew(); + _forceTick = () => tick(st.Elapsed); + DispatcherTimer.Run(() => + { + if (cancelled) + return false; + tick(st.Elapsed); + return !cancelled; + }, TimeSpan.FromSeconds(1.0 / _framesPerSecond), DispatcherPriority.Render); + return Disposable.Create(() => + { + _forceTick = null; + cancelled = true; + }); + } + + public RenderTimer(int framesPerSecond) : base(framesPerSecond) + { + _framesPerSecond = framesPerSecond; + } + + public void ForceTick() => _forceTick?.Invoke(); + } + + class HeadlessWindowingPlatform : IWindowingPlatform + { + public IWindowImpl CreateWindow() => new HeadlessWindowImpl(false); + + public IEmbeddableWindowImpl CreateEmbeddableWindow() => throw new PlatformNotSupportedException(); + + public IPopupImpl CreatePopup() => new HeadlessWindowImpl(true); + } + + internal static void Initialize() + { + AvaloniaLocator.CurrentMutable + .Bind().ToConstant(new HeadlessPlatformThreadingInterface()) + .Bind().ToSingleton() + .Bind().ToSingleton() + .Bind().ToConstant(new HeadlessPlatformSettingsStub()) + .Bind().ToSingleton() + .Bind().ToSingleton() + .Bind().ToConstant(new KeyboardDevice()) + .Bind().ToConstant(new RenderLoop()) + .Bind().ToConstant(new RenderTimer(60)) + + .Bind().ToConstant(new HeadlessWindowingPlatform()) + .Bind().ToSingleton(); + } + + + public static void ForceRenderTimerTick(int count = 1) + { + var timer = AvaloniaLocator.Current.GetService() as RenderTimer; + for (var c = 0; c < count; c++) + timer?.ForceTick(); + + } + } + + public static class AvaloniaHeadlessPlatformExtensions + { + public static T UseHeadless(this T builder, bool headlessDrawing = true) + where T : AppBuilderBase, new() + { + if (headlessDrawing) + builder.UseRenderingSubsystem(HeadlessPlatformRenderInterface.Initialize, "Headless"); + return builder.UseWindowingSubsystem(AvaloniaHeadlessPlatform.Initialize, "Headless"); + } + } +} diff --git a/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs new file mode 100644 index 0000000000..381eccef08 --- /dev/null +++ b/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Utilities; +using Avalonia.Visuals.Media.Imaging; + +namespace Avalonia.Headless +{ + internal class HeadlessPlatformRenderInterface : IPlatformRenderInterface + { + public static void Initialize() + { + AvaloniaLocator.CurrentMutable + .Bind().ToConstant(new HeadlessPlatformRenderInterface()); + } + + public IEnumerable InstalledFontNames { get; } = new[] {"Tahoma"}; + + public IFormattedTextImpl CreateFormattedText(string text, Typeface typeface, TextAlignment textAlignment, + TextWrapping wrapping, Size constraint, IReadOnlyList spans) + { + return new HeadlessFormattedTextStub(text, constraint); + } + + public IGeometryImpl CreateEllipseGeometry(Rect rect) => new HeadlessGeometryStub(rect); + + public IGeometryImpl CreateLineGeometry(Point p1, Point p2) + { + var tl = new Point(Math.Min(p1.X, p2.X), Math.Min(p1.Y, p2.Y)); + var br = new Point(Math.Max(p1.X, p2.X), Math.Max(p1.Y, p2.Y)); + return new HeadlessGeometryStub(new Rect(tl, br)); + } + + public IGeometryImpl CreateRectangleGeometry(Rect rect) + { + return new HeadlessGeometryStub(rect); + } + + public IStreamGeometryImpl CreateStreamGeometry() => new HeadlessStreamingGeometryStub(); + + public IRenderTarget CreateRenderTarget(IEnumerable surfaces) => new HeadlessRenderTarget(); + + public IRenderTargetBitmapImpl CreateRenderTargetBitmap(PixelSize size, Vector dpi) + { + return new HeadlessBitmapStub(size, dpi); + } + + public IWriteableBitmapImpl CreateWriteableBitmap(PixelSize size, Vector dpi, PixelFormat? format = null) + { + return new HeadlessBitmapStub(size, dpi); + } + + public IBitmapImpl LoadBitmap(string fileName) + { + return new HeadlessBitmapStub(new Size(1, 1), new Vector(96, 96)); + } + + public IBitmapImpl LoadBitmap(Stream stream) + { + return new HeadlessBitmapStub(new Size(1, 1), new Vector(96, 96)); + } + + public IBitmapImpl LoadBitmap(PixelFormat format, IntPtr data, PixelSize size, Vector dpi, int stride) + { + return new HeadlessBitmapStub(new Size(1, 1), new Vector(96, 96)); + } + + class HeadlessGeometryStub : IGeometryImpl + { + public HeadlessGeometryStub(Rect bounds) + { + Bounds = bounds; + } + public Rect Bounds { get; set; } + public virtual Rect GetRenderBounds(Pen pen) => Bounds.Inflate(pen.Thickness / 2); + public virtual bool FillContains(Point point) => Bounds.Contains(point); + + public IGeometryImpl Intersect(IGeometryImpl geometry) + => new HeadlessGeometryStub(geometry.Bounds.Intersect(Bounds)); + + public bool StrokeContains(Pen pen, Point point) => false; + + public ITransformedGeometryImpl WithTransform(Matrix transform) => + new HeadlessTransformedGeometryStub(this, transform); + } + + class HeadlessTransformedGeometryStub : HeadlessGeometryStub, ITransformedGeometryImpl + { + public HeadlessTransformedGeometryStub(IGeometryImpl b, Matrix transform) : this(Fix(b, transform)) + { + + } + + static (IGeometryImpl, Matrix, Rect) Fix(IGeometryImpl b, Matrix transform) + { + if (b is HeadlessTransformedGeometryStub transformed) + { + b = transformed.SourceGeometry; + transform = transformed.Transform * transform; + } + + return (b, transform, b.Bounds.TransformToAABB(transform)); + } + + private HeadlessTransformedGeometryStub((IGeometryImpl b, Matrix transform, Rect bounds) fix) : base(fix.bounds) + { + SourceGeometry = fix.b; + Transform = fix.transform; + } + + + public IGeometryImpl SourceGeometry { get; } + public Matrix Transform { get; } + } + + class HeadlessStreamingGeometryStub : HeadlessGeometryStub, IStreamGeometryImpl + { + public HeadlessStreamingGeometryStub() : base(Rect.Empty) + { + } + + public IStreamGeometryImpl Clone() + { + return this; + } + + public IStreamGeometryContextImpl Open() + { + return new HeadlessStreamingGeometryContextStub(this); + } + + class HeadlessStreamingGeometryContextStub : IStreamGeometryContextImpl + { + private readonly HeadlessStreamingGeometryStub _parent; + private double _x1, _y1, _x2, _y2; + public HeadlessStreamingGeometryContextStub(HeadlessStreamingGeometryStub parent) + { + _parent = parent; + } + + void Track(Point pt) + { + if (_x1 > pt.X) + _x1 = pt.X; + if (_x2 < pt.X) + _x2 = pt.X; + if (_y1 > pt.Y) + _y1 = pt.Y; + if (_y2 < pt.Y) + _y2 = pt.Y; + } + + public void Dispose() + { + _parent.Bounds = new Rect(_x1, _y1, _x2 - _x1, _y2 - _y1); + } + + public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, SweepDirection sweepDirection) + => Track(point); + + public void BeginFigure(Point startPoint, bool isFilled = true) => Track(startPoint); + + public void CubicBezierTo(Point point1, Point point2, Point point3) + { + Track(point1); + Track(point2); + Track(point3); + } + + public void QuadraticBezierTo(Point control, Point endPoint) + { + Track(control); + Track(endPoint); + } + + public void LineTo(Point point) => Track(point); + + public void EndFigure(bool isClosed) + { + Dispose(); + } + + public void SetFillRule(FillRule fillRule) + { + + } + } + } + + class HeadlessBitmapStub : IBitmapImpl, IRenderTargetBitmapImpl, IWriteableBitmapImpl + { + public Size Size { get; } + + public HeadlessBitmapStub(Size size, Vector dpi) + { + Size = size; + Dpi = dpi; + var pixel = Size * (Dpi / 96); + PixelSize = new PixelSize(Math.Max(1, (int)pixel.Width), Math.Max(1, (int)pixel.Height)); + } + + public HeadlessBitmapStub(PixelSize size, Vector dpi) + { + PixelSize = size; + Dpi = dpi; + Size = PixelSize.ToSizeWithDpi(dpi); + } + + public void Dispose() + { + + } + + public IDrawingContextImpl CreateDrawingContext(IVisualBrushRenderer visualBrushRenderer) + { + return new HeadlessDrawingContextStub(); + } + + public Vector Dpi { get; } + public PixelSize PixelSize { get; } + public int Version { get; set; } + public void Save(string fileName) + { + + } + + public void Save(Stream stream) + { + + } + + public ILockedFramebuffer Lock() + { + Version++; + var mem = Marshal.AllocHGlobal(PixelSize.Width * PixelSize.Height * 4); + return new LockedFramebuffer(mem, PixelSize, PixelSize.Width * 4, Dpi, PixelFormat.Rgba8888, + () => Marshal.FreeHGlobal(mem)); + } + } + + class HeadlessDrawingContextStub : IDrawingContextImpl + { + public void Dispose() + { + + } + + public Matrix Transform { get; set; } + public void Clear(Color color) + { + + } + + public void DrawImage(IRef source, double opacity, Rect sourceRect, Rect destRect, + BitmapInterpolationMode bitmapInterpolationMode = BitmapInterpolationMode.Default) + { + + } + + public void DrawImage(IRef source, IBrush opacityMask, Rect opacityMaskRect, Rect destRect) + { + + } + + public void DrawLine(Pen pen, Point p1, Point p2) + { + + } + + public void DrawGeometry(IBrush brush, Pen pen, IGeometryImpl geometry) + { + + } + + public void DrawRectangle(Pen pen, Rect rect, float cornerRadius = 0) + { + + } + + public void DrawText(IBrush foreground, Point origin, IFormattedTextImpl text) + { + + } + + public void FillRectangle(IBrush brush, Rect rect, float cornerRadius = 0) + { + + } + + public IRenderTargetBitmapImpl CreateLayer(Size size) + { + return new HeadlessBitmapStub(size, new Vector(96, 96)); + } + + public void PushClip(Rect clip) + { + + } + + public void PopClip() + { + + } + + public void PushOpacity(double opacity) + { + + } + + public void PopOpacity() + { + + } + + public void PushOpacityMask(IBrush mask, Rect bounds) + { + + } + + public void PopOpacityMask() + { + + } + + public void PushGeometryClip(IGeometryImpl clip) + { + + } + + public void PopGeometryClip() + { + + } + + public void Custom(ICustomDrawOperation custom) + { + + } + } + + class HeadlessRenderTarget : IRenderTarget + { + public void Dispose() + { + + } + + public IDrawingContextImpl CreateDrawingContext(IVisualBrushRenderer visualBrushRenderer) + { + return new HeadlessDrawingContextStub(); + } + } + + class HeadlessFormattedTextStub : IFormattedTextImpl + { + public HeadlessFormattedTextStub(string text, Size constraint) + { + Text = text; + Constraint = constraint; + Bounds = new Rect(Constraint.Constrain(new Size(50, 50))); + } + + public Size Constraint { get; } + public Rect Bounds { get; } + public string Text { get; } + + + public IEnumerable GetLines() + { + return new[] {new FormattedTextLine(Text.Length, 10)}; + } + + public TextHitTestResult HitTestPoint(Point point) => new TextHitTestResult(); + + public Rect HitTestTextPosition(int index) => new Rect(); + + public IEnumerable HitTestTextRange(int index, int length) => new Rect[length]; + } + } +} diff --git a/src/Avalonia.Headless/HeadlessPlatformStubs.cs b/src/Avalonia.Headless/HeadlessPlatformStubs.cs new file mode 100644 index 0000000000..debe77c1b6 --- /dev/null +++ b/src/Avalonia.Headless/HeadlessPlatformStubs.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Platform; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Platform; + +namespace Avalonia.Headless +{ + class HeadlessClipboardStub : IClipboard + { + private string _text; + public Task GetTextAsync() + { + return Task.Run(() => _text); + } + + public Task SetTextAsync(string text) + { + return Task.Run(() => _text = text); + } + + public Task ClearAsync() + { + return Task.Run(() => _text = null); + } + } + + class HeadlessCursorFactoryStub : IStandardCursorFactory + { + + public IPlatformHandle GetCursor(StandardCursorType cursorType) + { + return new PlatformHandle(new IntPtr((int)cursorType), "STUB"); + } + } + + class HeadlessPlatformSettingsStub : IPlatformSettings + { + public Size DoubleClickSize { get; } = new Size(2, 2); + public TimeSpan DoubleClickTime { get; } = TimeSpan.FromMilliseconds(500); + } + + class HeadlessSystemDialogsStub : ISystemDialogImpl + { + public Task ShowFileDialogAsync(FileDialog dialog, IWindowImpl parent) + { + return Task.Run(() => (string[])null); + } + + public Task ShowFolderDialogAsync(OpenFolderDialog dialog, IWindowImpl parent) + { + return Task.Run(() => (string)null); + } + } + + class HeadlessIconLoaderStub : IPlatformIconLoader + { + + class IconStub : IWindowIconImpl + { + public void Save(Stream outputStream) + { + + } + } + public IWindowIconImpl LoadIcon(string fileName) + { + return new IconStub(); + } + + public IWindowIconImpl LoadIcon(Stream stream) + { + return new IconStub(); + } + + public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) + { + return new IconStub(); + } + } + + class HeadlessScreensStub : IScreenImpl + { + public int ScreenCount { get; } = 1; + + public IReadOnlyList AllScreens { get; } = new[] + { + new Screen(new PixelRect(0, 0, 1920, 1280), + new PixelRect(0, 0, 1920, 1280), true), + }; + } +} diff --git a/src/Avalonia.Headless/HeadlessPlatformThreadingInterface.cs b/src/Avalonia.Headless/HeadlessPlatformThreadingInterface.cs new file mode 100644 index 0000000000..e42a7b1a71 --- /dev/null +++ b/src/Avalonia.Headless/HeadlessPlatformThreadingInterface.cs @@ -0,0 +1,86 @@ +using System; +using System.Reactive.Disposables; +using System.Threading; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace Avalonia.Headless +{ + class HeadlessPlatformThreadingInterface : IPlatformThreadingInterface + { + public HeadlessPlatformThreadingInterface() + { + _thread = Thread.CurrentThread; + } + + private AutoResetEvent _event = new AutoResetEvent(false); + private Thread _thread; + private object _lock = new object(); + private DispatcherPriority? _signaledPriority; + + public void RunLoop(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + DispatcherPriority? signaled = null; + lock (_lock) + { + signaled = _signaledPriority; + _signaledPriority = null; + } + if(signaled.HasValue) + Signaled?.Invoke(signaled); + WaitHandle.WaitAny(new[] {cancellationToken.WaitHandle, _event}, TimeSpan.FromMilliseconds(20)); + } + } + + public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick) + { + var cancelled = false; + var enqueued = false; + var l = new object(); + var timer = new Timer(_ => + { + lock (l) + { + if (cancelled || enqueued) + return; + enqueued = true; + Dispatcher.UIThread.Post(() => + { + lock (l) + { + enqueued = false; + if (cancelled) + return; + tick(); + } + }, priority); + } + }, null, interval, interval); + return Disposable.Create(() => + { + lock (l) + { + timer.Dispose(); + cancelled = true; + } + }); + } + + public void Signal(DispatcherPriority priority) + { + lock (_lock) + { + if (_signaledPriority == null || _signaledPriority.Value > priority) + { + _signaledPriority = priority; + } + _event.Set(); + } + } + + public bool CurrentThreadIsLoopThread => _thread == Thread.CurrentThread; + public event Action Signaled; + } +} diff --git a/src/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Avalonia.Headless/HeadlessWindowImpl.cs new file mode 100644 index 0000000000..550defa815 --- /dev/null +++ b/src/Avalonia.Headless/HeadlessWindowImpl.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Controls.Platform.Surfaces; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Headless +{ + class HeadlessWindowImpl : IWindowImpl, IPopupImpl, IFramebufferPlatformSurface, IHeadlessWindow + { + private IKeyboardDevice _keyboard; + private Stopwatch _st = Stopwatch.StartNew(); + private Pointer _mousePointer; + private WriteableBitmap _lastRenderedFrame; + private object _sync = new object(); + public bool IsPopup { get; } + + public HeadlessWindowImpl(bool isPopup) + { + IsPopup = isPopup; + Surfaces = new object[] {this}; + _keyboard = AvaloniaLocator.Current.GetService(); + _mousePointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true); + MouseDevice = new MouseDevice(_mousePointer); + ClientSize = new Size(1024, 768); + } + + public void Dispose() + { + Closed?.Invoke(); + _lastRenderedFrame?.Dispose(); + _lastRenderedFrame = null; + } + + public Size ClientSize { get; set; } + public double Scaling { get; } = 1; + public IEnumerable Surfaces { get; } + public Action Input { get; set; } + public Action Paint { get; set; } + public Action Resized { get; set; } + public Action ScalingChanged { get; set; } + + public IRenderer CreateRenderer(IRenderRoot root) + => new DeferredRenderer(root, AvaloniaLocator.Current.GetService()); + + public void Invalidate(Rect rect) + { + } + + public void SetInputRoot(IInputRoot inputRoot) + { + InputRoot = inputRoot; + } + + public IInputRoot InputRoot { get; set; } + + public Point PointToClient(PixelPoint point) => point.ToPoint(Scaling); + + public PixelPoint PointToScreen(Point point) => PixelPoint.FromPoint(point, Scaling); + + public void SetCursor(IPlatformHandle cursor) + { + + } + + public Action Closed { get; set; } + public IMouseDevice MouseDevice { get; } + + public void Show() + { + Dispatcher.UIThread.Post(() => Activated?.Invoke(), DispatcherPriority.Input); + } + + public void Hide() + { + Dispatcher.UIThread.Post(() => Deactivated?.Invoke(), DispatcherPriority.Input); + } + + public void BeginMoveDrag() + { + + } + + public void BeginResizeDrag(WindowEdge edge) + { + + } + + public PixelPoint Position { get; set; } + public Action PositionChanged { get; set; } + public void Activate() + { + Dispatcher.UIThread.Post(() => Activated?.Invoke(), DispatcherPriority.Input); + } + + public Action Deactivated { get; set; } + public Action Activated { get; set; } + public IPlatformHandle Handle { get; } = new PlatformHandle(IntPtr.Zero, "STUB"); + public Size MaxClientSize { get; } = new Size(1920, 1280); + public void Resize(Size clientSize) + { + // Emulate X11 behavior here + if (IsPopup) + DoResize(clientSize); + else + Dispatcher.UIThread.Post(() => + { + DoResize(clientSize); + }); + } + + void DoResize(Size clientSize) + { + ClientSize = clientSize; + Resized?.Invoke(clientSize); + } + + public void SetMinMaxSize(Size minSize, Size maxSize) + { + + } + + public void SetTopmost(bool value) + { + + } + + public IScreenImpl Screen { get; } = new HeadlessScreensStub(); + public WindowState WindowState { get; set; } + public Action WindowStateChanged { get; set; } + public void SetTitle(string title) + { + + } + + public void ShowDialog(IWindowImpl parent) + { + Show(); + } + + public void SetSystemDecorations(bool enabled) + { + + } + + public void SetIcon(IWindowIconImpl icon) + { + + } + + public void ShowTaskbarIcon(bool value) + { + + } + + public void CanResize(bool value) + { + + } + + public Func Closing { get; set; } + + class FramebufferProxy : ILockedFramebuffer + { + private readonly ILockedFramebuffer _fb; + private readonly Action _onDispose; + private bool _disposed; + + public FramebufferProxy(ILockedFramebuffer fb, Action onDispose) + { + _fb = fb; + _onDispose = onDispose; + } + public void Dispose() + { + if(_disposed) + return; + _disposed = true; + _fb.Dispose(); + _onDispose(); + } + + public IntPtr Address => _fb.Address; + public PixelSize Size => _fb.Size; + public int RowBytes => _fb.RowBytes; + public Vector Dpi => _fb.Dpi; + public PixelFormat Format => _fb.Format; + } + + public ILockedFramebuffer Lock() + { + var bmp = new WriteableBitmap(PixelSize.FromSize(ClientSize, Scaling), new Vector(96, 96) * Scaling); + var fb = bmp.Lock(); + return new FramebufferProxy(fb, () => + { + lock (_sync) + { + _lastRenderedFrame?.Dispose(); + _lastRenderedFrame = bmp; + } + }); + } + + public IRef GetLastRenderedFrame() + { + lock (_sync) + return _lastRenderedFrame?.PlatformImpl?.CloneAs(); + } + + private ulong Timestamp => (ulong)_st.ElapsedMilliseconds; + + void IHeadlessWindow.KeyPress(Key key, InputModifiers modifiers) + { + Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, RawKeyEventType.KeyDown, key, modifiers)); + } + + void IHeadlessWindow.KeyRelease(Key key, InputModifiers modifiers) + { + Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, RawKeyEventType.KeyUp, key, modifiers)); + } + + void IHeadlessWindow.MouseDown(Point point, int button, InputModifiers modifiers) + { + Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot, + button == 0 ? RawPointerEventType.LeftButtonDown : + button == 1 ? RawPointerEventType.MiddleButtonDown : RawPointerEventType.RightButtonDown, + point, modifiers)); + } + + void IHeadlessWindow.MouseMove(Point point, InputModifiers modifiers) + { + Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot, + RawPointerEventType.Move, point, modifiers)); + } + + void IHeadlessWindow.MouseUp(Point point, int button, InputModifiers modifiers) + { + Input?.Invoke(new RawPointerEventArgs(MouseDevice, Timestamp, InputRoot, + button == 0 ? RawPointerEventType.LeftButtonUp : + button == 1 ? RawPointerEventType.MiddleButtonUp : RawPointerEventType.RightButtonUp, + point, modifiers)); + } + } +} diff --git a/src/Avalonia.Headless/IHeadlessWindow.cs b/src/Avalonia.Headless/IHeadlessWindow.cs new file mode 100644 index 0000000000..aafad2e500 --- /dev/null +++ b/src/Avalonia.Headless/IHeadlessWindow.cs @@ -0,0 +1,17 @@ +using Avalonia.Input; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Utilities; + +namespace Avalonia.Headless +{ + public interface IHeadlessWindow + { + IRef GetLastRenderedFrame(); + void KeyPress(Key key, InputModifiers modifiers); + void KeyRelease(Key key, InputModifiers modifiers); + void MouseDown(Point point, int button, InputModifiers modifiers = InputModifiers.None); + void MouseMove(Point point, InputModifiers modifiers = InputModifiers.None); + void MouseUp(Point point, int button, InputModifiers modifiers = InputModifiers.None); + } +}