From 4cefdbde81aa2fa7fba2da60cf09cb8f0fa442ae Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Wed, 23 Sep 2026 08:39:12 -0700 Subject: [PATCH 1/5] Add out of process preview to support .NET Core and custom types --- src/Eto.Designer/AppDomainDesignHost.cs | 394 ------------------ src/Eto.Designer/AssemblyResolver.cs | 55 --- .../Builders/BaseCompiledInterfaceBuilder.cs | 5 +- .../Builders/JsonInterfaceBuilder.cs | 2 + .../Builders/RoslynInterfaceBuilder.cs | 10 + .../Builders/XamlInterfaceBuilder.cs | 2 + src/Eto.Designer/DesignSurface.cs | 8 + src/Eto.Designer/EtoAdapter.cs | 46 -- src/Eto.Designer/IDesignHost.cs | 3 - src/Eto.Designer/InProcessDesignPanel.cs | 21 - src/Eto.Designer/PreviewEditorView.cs | 23 +- src/Eto.Designer/PreviewEditorViewSplitter.cs | 5 +- src/Eto.Designer/RemoteDesignPanel.cs | 158 +++++++ .../Eto.DevExtension.PreviewHost.csproj | 63 +++ src/Eto.DevExtension.PreviewHost/HostTheme.cs | 28 ++ .../PreviewRenderer.cs | 130 ++++++ .../PreviewServer.cs | 112 +++++ src/Eto.DevExtension.PreviewHost/Program.cs | 39 ++ .../ProjectAssemblies.cs | 311 ++++++++++++++ src/Eto.DevExtension.VSCode.sln | 6 + src/Eto.DevExtension.VSCode/.gitignore | 1 + src/Eto.DevExtension.VSCode/.vscodeignore | 1 + src/Eto.DevExtension.VSCode/README.md | 14 +- src/Eto.DevExtension.VSCode/package-lock.json | 1 + src/Eto.DevExtension.VSCode/package.json | 32 +- src/Eto.DevExtension.VSCode/src/extension.ts | 43 +- src/Eto.DevExtension.VSCode/src/preview.ts | 250 +++++++++++ .../src/previewHost.ts | 154 +++++++ .../Editor/EditorFactory.cs | 35 +- .../Editor/EtoAdapterHandler.cs | 65 --- .../Editor/EtoPreviewPane.cs | 22 +- .../Editor/PreviewHostClient.cs | 228 ++++++++++ ...o.DevExtension.VisualStudio.Windows.csproj | 16 +- .../Wizards/EtoInitializer.cs | 2 - 34 files changed, 1625 insertions(+), 660 deletions(-) delete mode 100755 src/Eto.Designer/AppDomainDesignHost.cs delete mode 100644 src/Eto.Designer/AssemblyResolver.cs delete mode 100755 src/Eto.Designer/EtoAdapter.cs delete mode 100644 src/Eto.Designer/InProcessDesignPanel.cs create mode 100644 src/Eto.Designer/RemoteDesignPanel.cs create mode 100644 src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj create mode 100644 src/Eto.DevExtension.PreviewHost/HostTheme.cs create mode 100644 src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs create mode 100644 src/Eto.DevExtension.PreviewHost/PreviewServer.cs create mode 100644 src/Eto.DevExtension.PreviewHost/Program.cs create mode 100644 src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs create mode 100644 src/Eto.DevExtension.VSCode/src/preview.ts create mode 100644 src/Eto.DevExtension.VSCode/src/previewHost.ts delete mode 100755 src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs create mode 100644 src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs diff --git a/src/Eto.Designer/AppDomainDesignHost.cs b/src/Eto.Designer/AppDomainDesignHost.cs deleted file mode 100755 index ac7928d..0000000 --- a/src/Eto.Designer/AppDomainDesignHost.cs +++ /dev/null @@ -1,394 +0,0 @@ -#if NETFRAMEWORK -using Eto.Drawing; -using Eto.Forms; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.Serialization; -using System.Security.Permissions; -using System.Text; -using System.Threading.Tasks; - -namespace Eto.Designer -{ - - class DomainPlatformThemeHandler : IPlatformTheme - { - AppDomainProxy proxy; - Dictionary colors = new Dictionary(); - public DomainPlatformThemeHandler(AppDomainProxy proxy) - { - this.proxy = proxy; - } - Color GetColor(string name) - { - if (colors.TryGetValue(name, out var value)) - return value; - - return colors[name] = Color.FromArgb(proxy.GetThemeColor(name)); - } - - public Color ProjectBackground => GetColor("ProjectBackground"); - public Color ProjectForeground => GetColor("ProjectForeground"); - public Color ProjectDialogBackground => GetColor("ProjectDialogBackground"); - public Color ErrorForeground => GetColor("ErrorForeground"); - public Color SummaryBackground => GetColor("SummaryBackground"); - public Color SummaryForeground => GetColor("SummaryForeground"); - public Color DesignerBackground => GetColor("DesignerBackground"); - public Color DesignerPanel => GetColor("DesignerPanel"); - public IEnumerable AllColors { get; } = Enumerable.Empty(); - } - - class AppDomainProxy : MarshalByRefObject - { - string mainAssembly; - List references; - IDesignHost designPanel; - #pragma warning disable 414 - IDisposable resolver; - #pragma warning restore 414 - - public Func GetThemeColor { get; set; } - - public void Init(string platformType, string initializeAssembly, string mainAssembly, IEnumerable references) - { - this.mainAssembly = mainAssembly; - this.references = references.ToList(); - if (Platform.Instance == null) - { - var refs = new[] { Path.GetDirectoryName(typeof(AppDomainProxy).Assembly.Location), mainAssembly }; - resolver = AssemblyResolver.Register(refs.Union(this.references)); - - var plat = Activator.CreateInstance(Type.GetType(platformType)) as Platform; - Platform.Initialize(plat); - if (!string.IsNullOrEmpty(initializeAssembly)) - { - plat.LoadAssembly(initializeAssembly); - } - plat.Add(typeof(IPlatformTheme), () => new DomainPlatformThemeHandler(this)); - var app = new Application(); - app.Attach(); - app.Terminating += App_Terminating; - - app.UnhandledException += App_UnhandledException; - - AppDomain.CurrentDomain.DomainUnload += CurrentDomain_DomainUnload; - } - } - public void HookupEvents(AppDomainEventSink eventSink) - { - GetThemeColor = eventSink.GetThemeColor; - - designPanel = new DesignPanel(); - designPanel.MainAssembly = mainAssembly; - designPanel.References = references; - - designPanel.Error = eventSink.Error; - designPanel.ControlCreating = eventSink.ControlCreating; - designPanel.ControlCreated = eventSink.ControlCreated; - } - - void App_UnhandledException(object sender, UnhandledExceptionEventArgs e) - { - // error! - } - - void App_Terminating(object sender, CancelEventArgs e) - { - e.Cancel = true; - } - - void CurrentDomain_DomainUnload(object sender, EventArgs e) - { - //EtoAdapter.Unload(); - } - - Control container; - public object GetContainer() - { - // keep ref otherwise it gets gc'd in the app domain - container = designPanel.GetContainer(); - return container.ToContract(); - } - - public void Invalidate() - { - designPanel.Invalidate(); - } - - public bool SetBuilder(string fileName) - { - return designPanel.SetBuilder(fileName); - } - - public void Update(string code) - { - designPanel.Update(code); - } - - public string GetCodeFile(string fileName) - { - return designPanel.GetCodeFile(fileName); - } - - public void Dispose() - { - EtoAdapter.Unload(); - designPanel.Dispose(); - } - - public override object InitializeLifetimeService() - { - return null; - } - } - - class AppDomainEventSink : MarshalByRefObject - { - public AppDomainDesignHost Host { get; set; } - - public void ControlCreated() => Host.ControlCreated?.Invoke(); - public void ControlCreating() => Host.ControlCreating?.Invoke(); - public void Error(DesignError ex) => Host.Error?.Invoke(ex); - public override object InitializeLifetimeService() => null; - - public int GetThemeColor(string name) - { - switch (name) - { - case "DesignerPanel": return Global.Theme.DesignerPanel.ToArgb(); - case "DesignerBackground": return Global.Theme.DesignerBackground.ToArgb(); - case "ErrorForeground": return Global.Theme.ErrorForeground.ToArgb(); - case "ProjectBackground": return Global.Theme.ProjectBackground.ToArgb(); - case "ProjectDialogBackground": return Global.Theme.ProjectDialogBackground.ToArgb(); - case "ProjectForeground": return Global.Theme.ProjectForeground.ToArgb(); - case "SummaryBackground": return Global.Theme.SummaryBackground.ToArgb(); - case "SummaryForeground": return Global.Theme.SummaryForeground.ToArgb(); - default: - return Colors.White.ToArgb(); - } - } - } - - public class AppDomainDesignHost : IDesignHost, IDisposable - { - List references; - - public IEnumerable References - { - get { return references; } - set { references = value.ToList(); } - } - - public string MainAssembly { get; set; } - - public AppDomainDesignHost() - { - eventSink = new AppDomainEventSink { Host = this }; - timer = new UITimer { Interval = 1 }; - timer.Elapsed += Timer_Elapsed; - } - - ~AppDomainDesignHost() - { - Dispose(false); - } - - void Timer_Elapsed(object sender, EventArgs e) - { - timer.Stop(); - - if (domain != null) - { - var oldDomain = domain; - requiresNewDomain = true; - var newDomain = SetupAppDomain(true); - ContainerChanged?.Invoke(); - if (newDomain && !ReferenceEquals(oldDomain, domain)) - Task.Run(() => UnloadDomain(oldDomain)); - } - } - - bool requiresNewDomain; - AppDomain domain; - FileSystemWatcher watcher; - AppDomainProxy proxy; - AppDomainEventSink eventSink; - static int domainCount = 0; - UITimer timer; - - IEnumerable GetShadowCopyDirs() - { - if (!string.IsNullOrEmpty(MainAssembly)) - yield return Path.GetDirectoryName(MainAssembly); - if (References != null) - { - foreach (var r in References) - { - if (!string.IsNullOrEmpty(r)) - yield return Path.GetDirectoryName(r); - } - } - } - - bool SetupAppDomain(bool setBuilder) - { - if (!requiresNewDomain && domain != null) - { - EnsureWatcher(); - return false; - } - - - requiresNewDomain = false; -#pragma warning disable 618 - // doesn't work without for some reason, and there's no non-obsolete alternative. - if (!AppDomain.CurrentDomain.ShadowCopyFiles) - AppDomain.CurrentDomain.SetShadowCopyFiles(); -#pragma warning restore 618 - - var baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - var initializeAssembly = Builders.BaseCompiledInterfaceBuilder.InitializeAssembly; - - var shadowCopyDirs = string.Join(";", GetShadowCopyDirs().Distinct()); - var setup = new AppDomainSetup - { - ApplicationBase = AppDomain.CurrentDomain.BaseDirectory, - PrivateBinPath = $"{baseDir};{shadowCopyDirs}", - - ShadowCopyFiles = "true", - ShadowCopyDirectories = shadowCopyDirs, - - LoaderOptimization = LoaderOptimization.MultiDomain, - //LoaderOptimization = LoaderOptimization.NotSpecified - }; - if (!string.IsNullOrEmpty(MainAssembly)) - setup.CachePath = Path.Combine(Path.GetDirectoryName(MainAssembly), "Eto.Designer"); - - - proxy = null; - domain = AppDomain.CreateDomain("eto.designer." + domainCount++, null, setup); - try - { - using (AssemblyResolver.Register(baseDir)) - { - var proxyObject = domain.CreateInstanceFromAndUnwrap(typeof(AppDomainProxy).Assembly.Location, typeof(AppDomainProxy).FullName) as AppDomainProxy; - proxy = proxyObject as AppDomainProxy; - if (proxy == null) - throw new InvalidOperationException($"Could not create proxy for domain\nApplicationBase: {AppDomain.CurrentDomain.BaseDirectory}\nBaseDir: {baseDir}\nShadowCopyDirs: {shadowCopyDirs}"); - } - proxy.Init(Platform.Instance.GetType().AssemblyQualifiedName, initializeAssembly, MainAssembly, references); - - proxy.HookupEvents(eventSink); - - if (setBuilder) - proxy.SetBuilder(fileName); - if (!string.IsNullOrEmpty(code)) - proxy.Update(code); - } - catch (Exception ex) - { - UnloadDomain(domain); - domain = null; - proxy = null; - throw new InvalidOperationException($"Could not set up proxy for domain: {ex.GetBaseException().Message}", ex); - } - - EnsureWatcher(); - - return true; - } - - private void EnsureWatcher() - { - if (watcher == null && !string.IsNullOrEmpty(MainAssembly)) - { - var mainAssemblyPath = Path.GetDirectoryName(MainAssembly); - if (!Directory.Exists(mainAssemblyPath)) - return; - watcher = new FileSystemWatcher(mainAssemblyPath, "*.dll"); - watcher.Changed += (sender, e) => Application.Instance.AsyncInvoke(() => timer.Start()); - watcher.Created += (sender, e) => Application.Instance.AsyncInvoke(() => timer.Start()); - watcher.EnableRaisingEvents = true; - } - } - - public Action ContainerChanged { get; set; } - - public Action ControlCreated { get; set; } - public Action ControlCreating { get; set; } - - public Action Error { get; set; } - - public Control GetContainer() - { - SetupAppDomain(true); - return EtoAdapter.ToControl(proxy?.GetContainer()); - } - - public void Invalidate() - { - proxy?.Invalidate(); - } - - string fileName; - public bool SetBuilder(string fileName) - { - this.fileName = fileName; - SetupAppDomain(false); - return proxy?.SetBuilder(fileName) == true; - } - - string code; - public void Update(string code) - { - SetupAppDomain(true); - - this.code = code; - proxy.Update(code); - } - - public string GetCodeFile(string fileName) - { - return proxy.GetCodeFile(fileName); - } - - void UnloadDomain(AppDomain domain) - { - try - { - if (domain != null && !domain.IsFinalizingForUnload()) - { - AppDomain.Unload(domain); - } - } - catch - { - Debug.WriteLine("Could not unload domain"); - // ignore - } - } - - public void Dispose() - { - Dispose(true); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - proxy?.Dispose(); - - UnloadDomain(domain); - domain = null; - proxy = null; - } - } - } -} -#endif \ No newline at end of file diff --git a/src/Eto.Designer/AssemblyResolver.cs b/src/Eto.Designer/AssemblyResolver.cs deleted file mode 100644 index f4ad385..0000000 --- a/src/Eto.Designer/AssemblyResolver.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace Eto.Designer -{ - class AssemblyResolver : IDisposable - { - public List Files { get; set; } - - public AppDomain AppDomain { get; set; } - - public static IDisposable Register(string file, AppDomain domain = null) - { - return Register(new string[] { file }, domain); - } - - public static IDisposable Register(IEnumerable files, AppDomain domain = null) - { - if (files == null) - return null; - if (domain == null) - domain = AppDomain.CurrentDomain; - var resolver = new AssemblyResolver { AppDomain = domain, Files = files.ToList() }; - domain.AssemblyResolve += resolver.Resolve; - return resolver; - } - - public void Dispose() - { - AppDomain.AssemblyResolve -= Resolve; - } - - Assembly Resolve(object sender, ResolveEventArgs args) - { - var assemblyName = new AssemblyName(args.Name).Name; - foreach (var path in Files) - { - var filePath = Path.GetFileNameWithoutExtension(path); - if (string.Equals(filePath, assemblyName, StringComparison.OrdinalIgnoreCase) && File.Exists(path)) - return Assembly.LoadFrom(path); - if (!Directory.Exists(path)) - continue; - filePath = Path.Combine(path, assemblyName + ".dll"); - if (File.Exists(filePath)) - return Assembly.LoadFrom(filePath); - } - return null; - } - } -} diff --git a/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs b/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs index ad4f9f8..90042ba 100755 --- a/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/BaseCompiledInterfaceBuilder.cs @@ -15,7 +15,8 @@ public abstract class BaseCompiledInterfaceBuilder : IInterfaceBuilder, IDisposa { string output; - public static string InitializeAssembly { get; set; } + /// Eto.dll to compile against, for when it was loaded from memory and has no location. + public static string EtoAssemblyPath { get; set; } ~BaseCompiledInterfaceBuilder() { @@ -72,7 +73,7 @@ public IBuildToken Create(string text, string mainAssembly, IEnumerable if (!string.IsNullOrEmpty(mainAssembly)) refs.Insert(0, mainAssembly); refs.RemoveAll(r => Path.GetFileName(r).ToLowerInvariant() == "eto.dll"); - refs.Add(typeof(Control).Assembly.Location); + refs.Add(EtoAssemblyPath ?? typeof(Control).Assembly.Location); ThreadPool.QueueUserWorkItem(state => { diff --git a/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs b/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs index 5896848..812a97e 100644 --- a/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/JsonInterfaceBuilder.cs @@ -21,6 +21,8 @@ public IBuildToken Create(string text, string mainAssembly, IEnumerable var control = JsonReader.Load(stream); if (control != null) controlCreated(control); + else + error(new FormatException("The document did not create a control.")); } } catch (Exception ex) diff --git a/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs b/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs index 793f8ee..ce1a5d6 100755 --- a/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/RoslynInterfaceBuilder.cs @@ -13,6 +13,16 @@ public abstract class RoslynInterfaceBuilder : BaseCompiledInterfaceBuilder { protected virtual IEnumerable GetReferences() { +#if NETCOREAPP + // no reference assemblies on disk to rely on, so compile against the runtime itself + var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (!string.IsNullOrEmpty(trusted)) + { + foreach (var file in trusted.Split(Path.PathSeparator)) + yield return file; + yield break; + } +#endif string referenceDir = GetReferenceAssembliesFolder(); if (string.IsNullOrEmpty(referenceDir)) diff --git a/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs b/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs index 7529262..f766754 100755 --- a/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs +++ b/src/Eto.Designer/Builders/XamlInterfaceBuilder.cs @@ -21,6 +21,8 @@ public IBuildToken Create(string text, string mainAssembly, IEnumerable var control = XamlReader.Load(new StringReader(text), null); if (control != null) controlCreated(control); + else + error(new FormatException("The document did not create a control.")); } catch (Exception ex) { diff --git a/src/Eto.Designer/DesignSurface.cs b/src/Eto.Designer/DesignSurface.cs index 8d5d2c7..03c8a76 100644 --- a/src/Eto.Designer/DesignSurface.cs +++ b/src/Eto.Designer/DesignSurface.cs @@ -24,6 +24,12 @@ public class DesignSurface : Drawable public event EventHandler InvalidateContent; + /// Raised when the user drags the content to a size, or resets it to auto size. + public event EventHandler RequestedSizeChanged; + + /// Size the user dragged the content to, or null for its own size. + public Size? RequestedSize => _sizeBounds != null ? Size.Round(_sizeBounds.Value) : (Size?)null; + public DesignSurface() { if (!Platform.Instance.IsGtk) // doesn't work correctly on Gtk2 due to lack of control transparency @@ -82,6 +88,7 @@ RectangleF SizeBounds _sizeBounds = value.Size; _content.Size = Size.Round(value.Size); Invalidate(); + RequestedSizeChanged?.Invoke(this, EventArgs.Empty); } } @@ -191,6 +198,7 @@ IEnumerable CreateGrips() _sizeBounds = null; _content.Size = _originalContentSize ?? new Size(-1, -1); InvalidateContent?.Invoke(this, EventArgs.Empty); + RequestedSizeChanged?.Invoke(this, EventArgs.Empty); }, ShouldDraw = location => _sizeBounds != null, ToolTip = "Click to reset to auto size", diff --git a/src/Eto.Designer/EtoAdapter.cs b/src/Eto.Designer/EtoAdapter.cs deleted file mode 100755 index da3972d..0000000 --- a/src/Eto.Designer/EtoAdapter.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using Eto.Forms; -using Eto.Drawing; -using Microsoft.CSharp; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.IO; -using System.Threading; -using System.Diagnostics; -using System.Runtime.Serialization.Formatters.Binary; -using Eto.Designer; - -namespace Eto.Designer -{ - public interface IEtoAdapterHandler - { - object ToContract(Control control); - - Control FromContract(object contract); - - void Unload(); - } - - public static class EtoAdapter - { - public static object ToContract(this Control control) - { - if (control == null) - return null; - return Platform.Instance.CreateShared().ToContract(control); - } - - public static Control ToControl(object contract) - { - if (contract == null) - return null; - return Platform.Instance.CreateShared().FromContract(contract); - } - - public static void Unload() - { - Platform.Instance.CreateShared().Unload(); - } - } -} diff --git a/src/Eto.Designer/IDesignHost.cs b/src/Eto.Designer/IDesignHost.cs index 8f93981..3ddf019 100644 --- a/src/Eto.Designer/IDesignHost.cs +++ b/src/Eto.Designer/IDesignHost.cs @@ -12,9 +12,6 @@ public interface IDesignHost : IDisposable Action ControlCreating { get; set; } Action ControlCreated { get; set; } Action Error { get; set; } - Action ContainerChanged { get; set; } - string MainAssembly { get; set; } - IEnumerable References { get; set; } Control GetContainer(); void Update(string code); void Invalidate(); diff --git a/src/Eto.Designer/InProcessDesignPanel.cs b/src/Eto.Designer/InProcessDesignPanel.cs deleted file mode 100644 index 7db11ed..0000000 --- a/src/Eto.Designer/InProcessDesignPanel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace Eto.Designer -{ - public class InProcessDesignPanel : DesignPanel - { - public override void Update(string code) - { - var baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - using (AssemblyResolver.Register(baseDir)) - base.Update(code); - } - } - -} diff --git a/src/Eto.Designer/PreviewEditorView.cs b/src/Eto.Designer/PreviewEditorView.cs index db467f7..84e5b32 100755 --- a/src/Eto.Designer/PreviewEditorView.cs +++ b/src/Eto.Designer/PreviewEditorView.cs @@ -23,36 +23,17 @@ public class PreviewEditorView : Panel public double ErrorDisplayTime { get; set; } = 6.0; - public string MainAssemblyPath { get; set; } - - public static bool EnableAppDomains { get; set; } = Platform.Instance.Supports(); - - public PreviewEditorView(string mainAssembly, IEnumerable references, Func getCode) + public PreviewEditorView(IDesignHost designHost, Func getCode) { - //Size = new Size (200, 200); - MainAssemblyPath = mainAssembly; this.getCode = getCode; -#if NETFRAMEWORK - if (EnableAppDomains) - designPanel = new AppDomainDesignHost(); - else - designPanel = new InProcessDesignPanel(); -#else - designPanel = new InProcessDesignPanel(); -#endif - - designPanel.MainAssembly = mainAssembly; - designPanel.References = references; - + designPanel = designHost; designPanel.ControlCreating = () => FinishProcessing(null); designPanel.Error = FinishProcessing; designPanelHolder = new Panel(); designPanelHolder.Content = designPanel.GetContainer(); - designPanel.ContainerChanged = () => designPanelHolder.Content = designPanel.GetContainer(); - errorPanel = new Panel { Padding = new Padding(5), Visible = false, BackgroundColor = new Color(Colors.Red, .4f) }; Content = new StackLayout diff --git a/src/Eto.Designer/PreviewEditorViewSplitter.cs b/src/Eto.Designer/PreviewEditorViewSplitter.cs index 36b465e..ea4f7aa 100755 --- a/src/Eto.Designer/PreviewEditorViewSplitter.cs +++ b/src/Eto.Designer/PreviewEditorViewSplitter.cs @@ -13,10 +13,9 @@ public class PreviewEditorViewSplitter : Splitter public PreviewEditorView Preview { get; } - public PreviewEditorViewSplitter(Control editor, string mainAssembly, IEnumerable references, Func getCode) + public PreviewEditorViewSplitter(Control editor, IDesignHost designHost, Func getCode) { - //Size = new Size (200, 200); - Preview = new PreviewEditorView(mainAssembly, references, getCode); + Preview = new PreviewEditorView(designHost, getCode); Editor = editor; Orientation = Orientation.Vertical; diff --git a/src/Eto.Designer/RemoteDesignPanel.cs b/src/Eto.Designer/RemoteDesignPanel.cs new file mode 100644 index 0000000..ad0fc70 --- /dev/null +++ b/src/Eto.Designer/RemoteDesignPanel.cs @@ -0,0 +1,158 @@ +using Eto.Drawing; +using Eto.Forms; +using System; +using System.Threading.Tasks; + +namespace Eto.Designer +{ + public class PreviewRenderRequest + { + public string FileName { get; set; } + public string Text { get; set; } + /// Size the user dragged the preview to, or null for its own size. + public Size? Size { get; set; } + public float Scale { get; set; } = 1; + } + + public class PreviewRenderResult + { + /// PNG data, or null when there's nothing to show. + public byte[] Image { get; set; } + /// Size of the image in logical pixels. + public Size Size { get; set; } + public DesignError Error { get; set; } + } + + /// + /// Shows a preview drawn somewhere else, such as a separate process, so the project's code never runs here. + /// + public class RemoteDesignPanel : Scrollable, IDesignHost + { + readonly Func> render; + readonly DesignSurface designSurface; + readonly Drawable imageView; + BuilderInfo builder; + Bitmap image; + string fileName; + string code; + bool rendering; + bool renderPending; + + public Action ControlCreating { get; set; } + public Action ControlCreated { get; set; } + public Action Error { get; set; } + + /// Draws the preview, called with one request at a time. + public RemoteDesignPanel(Func> render) + { + this.render = render; + imageView = new Drawable { Size = new Size(1, 1) }; + // stretched while dragging to a new size, until the redraw at that size comes back + imageView.Paint += (sender, e) => + { + if (image != null) + e.Graphics.DrawImage(image, new RectangleF(imageView.Size)); + }; + designSurface = new DesignSurface(); + designSurface.RequestedSizeChanged += (sender, e) => Render(); + Border = BorderType.None; + BackgroundColor = Global.Theme.DesignerBackground; + Content = designSurface; + } + + public Control GetContainer() => this; + + public bool SetBuilder(string fileName) + { + this.fileName = fileName; + builder = BuilderInfo.Find(fileName); + return builder != null; + } + + public string GetCodeFile(string fileName) => builder?.GetCodeFile(fileName); + + public void Update(string code) + { + this.code = code; + Render(); + } + + async void Render() + { + if (code == null) + return; + if (rendering) + { + renderPending = true; + return; + } + + rendering = true; + try + { + do + { + renderPending = false; + var request = new PreviewRenderRequest + { + FileName = fileName, + Text = code, + Size = designSurface.RequestedSize, + Scale = (ParentWindow?.Screen ?? Screen.PrimaryScreen)?.LogicalPixelSize ?? 1 + }; + PreviewRenderResult result; + try + { + result = await render(request); + } + catch (Exception ex) + { + result = new PreviewRenderResult { Error = new DesignError { Message = ex.GetBaseException().Message, Details = ex.ToString() } }; + } + if (IsDisposed) + return; + Show(result); + } + while (renderPending); + } + finally + { + rendering = false; + } + } + + void Show(PreviewRenderResult result) + { + if (result == null) + return; + if (result.Error != null) + { + // keep the last good preview up + Error?.Invoke(result.Error); + return; + } + + ControlCreating?.Invoke(); + var old = image; + image = result.Image != null ? new Bitmap(result.Image) : null; + old?.Dispose(); + // while the user has picked a size the surface owns it, and an older result mustn't undo a drag + if (designSurface.RequestedSize == null) + imageView.Size = new Size(Math.Max(1, result.Size.Width), Math.Max(1, result.Size.Height)); + if (designSurface.Content == null) + designSurface.Content = imageView; + imageView.Invalidate(); + ControlCreated?.Invoke(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + image?.Dispose(); + image = null; + } + base.Dispose(disposing); + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj new file mode 100644 index 0000000..4fcb8e5 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj @@ -0,0 +1,63 @@ + + + Exe + net8.0-windows + true + disable + disable + LatestMajor + + false + + true + Eto.DevExtension.PreviewHost + Eto.DevExtension.PreviewHost + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Eto.DevExtension.PreviewHost/HostTheme.cs b/src/Eto.DevExtension.PreviewHost/HostTheme.cs new file mode 100644 index 0000000..27fa74f --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/HostTheme.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Linq; +using Eto.Designer; +using Eto.Drawing; + +namespace Eto.DevExtension.PreviewHost +{ + /// Theme colors sent by the editor, so previews match it. + class HostTheme : IPlatformTheme + { + readonly IReadOnlyDictionary colors; + + public HostTheme(IReadOnlyDictionary colors) => this.colors = colors; + + Color Get(string name, Color fallback) => + colors.TryGetValue(name, out var value) && Color.TryParse(value, out var color) ? color : fallback; + + public Color ProjectBackground => Get(nameof(ProjectBackground), SystemColors.Control); + public Color ProjectForeground => Get(nameof(ProjectForeground), SystemColors.ControlText); + public Color ProjectDialogBackground => Get(nameof(ProjectDialogBackground), SystemColors.Control); + public Color ErrorForeground => Get(nameof(ErrorForeground), Colors.Red); + public Color SummaryBackground => Get(nameof(SummaryBackground), SystemColors.Control); + public Color SummaryForeground => Get(nameof(SummaryForeground), SystemColors.ControlText); + public Color DesignerBackground => Get(nameof(DesignerBackground), SystemColors.Control); + public Color DesignerPanel => Get(nameof(DesignerPanel), Color.FromRgb(0xF0F0F0)); + public IEnumerable AllColors => Enumerable.Empty(); + } +} diff --git a/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs new file mode 100644 index 0000000..b8756f0 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Eto.Designer; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class RenderRequest + { + public string FileName; + public string Text; + public int? Width; + public int? Height; + public double Scale = 1; + } + + class RenderResult + { + public string Image { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public RenderError Error { get; set; } + public bool? RestartRequired { get; set; } + } + + class RenderError + { + public string Message { get; set; } + public string Details { get; set; } + } + + /// Builds a designer file into a control and draws it to a PNG. Use from the UI thread only. + class PreviewRenderer + { + string builderFile; + IInterfaceBuilder builder; + IBuildToken token; + + public Task RenderAsync(RenderRequest request) + { + var completion = new TaskCompletionSource(); + try + { + if (!string.Equals(builderFile, request.FileName, StringComparison.OrdinalIgnoreCase)) + { + builder = BuilderInfo.Find(request.FileName)?.CreateBuilder(); + builderFile = request.FileName; + } + if (builder == null) + return Task.FromResult(Error(new NotSupportedException($"No designer for {Path.GetFileName(request.FileName)}"))); + + token?.Cancel(); + token = builder.Create( + request.Text, + ProjectAssemblies.MainAssembly, + ProjectAssemblies.Paths, + control => CaptureAsync(control, request).ContinueWith(r => completion.TrySetResult(r.IsFaulted ? Error(r.Exception) : r.Result)), + ex => completion.TrySetResult(Error(ex))); + } + catch (Exception ex) + { + completion.TrySetResult(Error(ex)); + } + return completion.Task; + } + + static async Task CaptureAsync(Control control, RenderRequest request) + { + var content = DesignPanel.GetContent(control); + if (request.Width != null || request.Height != null) + content.Size = new Eto.Drawing.Size(request.Width ?? -1, request.Height ?? -1); + + // shown for real, but out of sight, so load events fire and templates apply like in the app + var form = new Form + { + Content = content, + WindowStyle = Eto.Forms.WindowStyle.None, + ShowInTaskbar = false, + ShowActivated = false, + Resizable = false, + Location = new Eto.Drawing.Point(-32000, -32000) + }; + try + { + form.Show(); + await Dispatcher.Yield(DispatcherPriority.ContextIdle); + + var element = content.ToNative(); + element.UpdateLayout(); + var width = (int)Math.Ceiling(element.ActualWidth); + var height = (int)Math.Ceiling(element.ActualHeight); + if (width <= 0 || height <= 0) + return new RenderResult { Width = Math.Max(width, 0), Height = Math.Max(height, 0) }; + + var scale = request.Scale > 0 ? request.Scale : 1; + var bitmap = new RenderTargetBitmap((int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32); + // through a brush, since rendering the element directly includes its offset in the window + var visual = new DrawingVisual(); + using (var context = visual.RenderOpen()) + context.DrawRectangle(new VisualBrush(element), null, new Rect(0, 0, width, height)); + bitmap.Render(visual); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using (var stream = new MemoryStream()) + { + encoder.Save(stream); + return new RenderResult { Image = Convert.ToBase64String(stream.ToArray()), Width = width, Height = height }; + } + } + finally + { + form.Close(); + control.Dispose(); + } + } + + static RenderResult Error(Exception ex) + { + ex = (ex as AggregateException)?.Flatten().InnerException ?? ex; + var root = ex.GetBaseException(); + return new RenderResult { Error = new RenderError { Message = root.Message, Details = ex.ToString() } }; + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/PreviewServer.cs b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs new file mode 100644 index 0000000..45d5669 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Eto.DevExtension.LanguageServer; +using Eto.DevExtension.LanguageServer.Lsp; + +namespace Eto.DevExtension.PreviewHost +{ + /// + /// Handles the preview protocol. Eto only starts on the first render, once it's known which project + /// (and so which Eto) this process is for. + /// + class PreviewServer + { + readonly LspConnection connection; + readonly ManualResetEventSlim startUI = new ManualResetEventSlim(); + readonly TaskCompletionSource uiReady = new TaskCompletionSource(); + Dictionary theme = new Dictionary(); + bool configured; + object renderer; + + public PreviewServer(LspConnection connection) + { + this.connection = connection; + connection.OnRequest("initialize", Initialize); + connection.OnRequest("shutdown", _ => null); + connection.OnNotification("initialized", _ => { }); + connection.OnNotification("exit", _ => Environment.Exit(0)); + connection.OnRequest("preview/render", Render); + } + + object Initialize(JsonNode parameters) + { + if (parameters?["theme"] is JsonObject colors) + theme = colors.Where(r => r.Value != null).ToDictionary(r => r.Key, r => r.Value.GetValue(), StringComparer.OrdinalIgnoreCase); + return new { capabilities = new { }, serverInfo = new { name = "Eto Preview Host" } }; + } + + object Render(JsonNode parameters) + { + var request = new RenderRequest + { + FileName = parameters?["fileName"]?.GetValue(), + Text = parameters?["text"]?.GetValue() ?? string.Empty, + Width = parameters?["width"]?.GetValue(), + Height = parameters?["height"]?.GetValue(), + Scale = parameters?["scale"]?.GetValue() ?? 1 + }; + var assemblies = (parameters?["assemblies"] as JsonArray)?.Select(r => r?.GetValue()).Where(r => !string.IsNullOrEmpty(r)).ToList() + ?? ProjectAssemblyLocator.Find(request.FileName, connection.Log); + + if (!configured) + { + ProjectAssemblies.Configure(assemblies, request.FileName, connection.Log); + configured = true; + startUI.Set(); + uiReady.Task.Wait(); + } + else if (ProjectAssemblies.GetKey(assemblies) != ProjectAssemblies.Key) + return new RenderResult { RestartRequired = true }; + + return RenderOnUI(request); + } + + /// Runs the UI on the calling thread, which must be the process's main STA thread. + public void RunUI() + { + startUI.Wait(); + StartEto(); + } + + // kept apart so no Eto type is touched before the project's Eto is chosen + [MethodImpl(MethodImplOptions.NoInlining)] + void StartEto() + { + var platform = new Eto.Wpf.Platform(); + platform.Add(() => new HostTheme(theme)); + Eto.Designer.Builders.BaseCompiledInterfaceBuilder.EtoAssemblyPath = ProjectAssemblies.EtoFile; + + var app = new Eto.Forms.Application(platform); + app.Initialized += (sender, e) => + { + // the offscreen forms come and go, and must not end the process + System.Windows.Application.Current.ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown; + ProjectAssemblies.LoadProject(); + ProjectAssemblies.WatchForChanges(Restart); + renderer = new PreviewRenderer(); + uiReady.TrySetResult(true); + }; + app.UnhandledException += (sender, e) => connection.Log($"Unhandled exception: {e.ExceptionObject}"); + app.Run(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + RenderResult RenderOnUI(RenderRequest request) + { + var result = Eto.Forms.Application.Instance.Invoke(() => ((PreviewRenderer)renderer).RenderAsync(request)); + return result.GetAwaiter().GetResult(); + } + + void Restart() + { + connection.Log("Project assemblies changed, restarting the preview host."); + connection.Notify("preview/restart", new { }); + Environment.Exit(0); + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/Program.cs b/src/Eto.DevExtension.PreviewHost/Program.cs new file mode 100644 index 0000000..a3531f7 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Program.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using Eto.DevExtension.LanguageServer.Lsp; + +namespace Eto.DevExtension.PreviewHost +{ + static class Program + { + [STAThread] + static int Main(string[] args) + { + var connection = new LspConnection(Console.OpenStandardInput(), Console.OpenStandardOutput()); + + // stdout is the protocol channel + Console.SetOut(TextWriter.Null); + + var server = new PreviewServer(connection); + var reader = new Thread(() => + { + try + { + connection.Run(); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + // the editor went away + Environment.Exit(0); + }) + { IsBackground = true, Name = "Preview protocol" }; + reader.Start(); + + server.RunUI(); + return 0; + } + } +} diff --git a/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs b/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs new file mode 100644 index 0000000..341b80f --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using Eto.DevExtension.LanguageServer; + +namespace Eto.DevExtension.PreviewHost +{ + /// + /// Resolves the project's assemblies and a matching Eto + Eto.Wpf for this process. + /// Call once, before anything touches an Eto type. + /// + /// + /// A process only ever serves one project, since assemblies can't be swapped out once loaded. + /// + static class ProjectAssemblies + { + // Eto and the libraries it shares with the project, which must all come from the same place + static readonly string[] EtoNames = { "Eto", "Eto.Wpf", "Eto.Serialization.Xaml", "Eto.Serialization.Json" }; + static readonly string[] SharedNames = { "Portable.Xaml", "Newtonsoft.Json" }; + + static readonly Dictionary namedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + static readonly ConcurrentDictionary loaded = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + static readonly List probeDirectories = new List(); + static readonly List watchedFiles = new List(); + static readonly List watchers = new List(); + static System.Threading.Timer changeTimer; + static readonly object loadLock = new object(); + static AssemblyDependencyResolver dependencyResolver; + static Action log; + + static string FallbackDirectory => Path.Combine(AppContext.BaseDirectory, "eto"); + + /// Project assembly files, its own first. + public static IReadOnlyList Paths { get; private set; } = Array.Empty(); + + /// Identifies the assembly set, so a render for a different project can be refused. + public static string Key { get; private set; } + + public static string MainAssembly => Paths.Count > 0 ? Paths[0] : null; + + /// File Eto.dll was loaded from, since a copy loaded from memory has no location. + public static string EtoFile => namedFiles.TryGetValue("Eto", out var file) ? file : null; + + public static string GetKey(IEnumerable paths) => string.Join("|", paths ?? Enumerable.Empty()); + + public static void Configure(IList paths, string documentPath, Action logger) + { + log = logger; + Paths = (paths ?? new List()).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + Key = GetKey(paths); + + foreach (var directory in Paths.Select(Path.GetDirectoryName).Distinct(StringComparer.OrdinalIgnoreCase)) + probeDirectories.Add(directory); + + if (MainAssembly != null) + { + try + { + dependencyResolver = new AssemblyDependencyResolver(MainAssembly); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + // no deps.json, as with .NET Framework projects + } + } + + ChooseEto(documentPath); + + AssemblyLoadContext.Default.Resolving += Resolve; + AssemblyLoadContext.Default.ResolvingUnmanagedDll += ResolveUnmanaged; + } + + /// Loads the project's own assemblies, so the designer can find their types. + public static void LoadProject() + { + foreach (var path in Paths) + { + try + { + Load(path); + } + catch (Exception ex) when (ex is IOException || ex is BadImageFormatException || ex is UnauthorizedAccessException) + { + log?.Invoke($"Could not load {path}: {ex.Message}"); + } + } + } + + /// Calls once any project file loaded so far is rebuilt. + public static void WatchForChanges(Action changed) + { + List files; + lock (loadLock) + files = watchedFiles.ToList(); + + var timer = changeTimer = new System.Threading.Timer(_ => changed()); + foreach (var group in files.GroupBy(Path.GetDirectoryName, StringComparer.OrdinalIgnoreCase)) + { + var names = new HashSet(group.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase); + var watcher = new FileSystemWatcher(group.Key) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size }; + FileSystemEventHandler handler = (sender, e) => + { + // a build writes several files, so wait for it to settle + if (names.Contains(e.Name)) + timer.Change(1000, System.Threading.Timeout.Infinite); + }; + watcher.Changed += handler; + watcher.Created += handler; + watcher.EnableRaisingEvents = true; + watchers.Add(watcher); + } + } + + static void ChooseEto(string documentPath) + { + // the project's own copy, so its custom controls bind to the Eto they were built against + var etoDirectory = probeDirectories.FirstOrDefault(r => File.Exists(Path.Combine(r, "Eto.dll"))) + ?? EtoAssemblyLocator.Find(documentPath, log); + var eto = etoDirectory != null ? Path.Combine(etoDirectory, "Eto.dll") : null; + var version = eto != null && File.Exists(eto) ? GetPackageVersion(eto) : null; + var wpf = version != null ? FindEtoWpf(version, etoDirectory) : null; + + if (wpf == null) + { + if (eto != null) + log?.Invoke($"No Eto.Wpf {version} found for {eto}, so using Eto bundled with the extension."); + foreach (var name in EtoNames.Concat(SharedNames)) + SetFallback(name); + return; + } + + namedFiles["Eto"] = eto; + namedFiles["Eto.Wpf"] = wpf; + foreach (var name in EtoNames.Skip(2)) + { + var file = FindBeside(name, etoDirectory) ?? FindPackage(name, version, new[] { "netstandard2.0" }); + if (file != null) + namedFiles[name] = file; + else + SetFallback(name); + } + foreach (var name in SharedNames) + { + var file = probeDirectories.Select(r => FindBeside(name, r)).FirstOrDefault(r => r != null); + if (file != null) + namedFiles[name] = file; + else + SetFallback(name); + } + log?.Invoke($"Using Eto {version} from {eto} with {wpf}"); + } + + static void SetFallback(string name) + { + var file = Path.Combine(FallbackDirectory, name + ".dll"); + if (File.Exists(file)) + namedFiles[name] = file; + } + + static string FindBeside(string name, string directory) + { + if (directory == null) + return null; + var file = Path.Combine(directory, name + ".dll"); + return File.Exists(file) ? file : null; + } + + /// Eto.Wpf of the same version as Eto, preferring a .NET Core build from the package over one next to the project. + static string FindEtoWpf(string version, string etoDirectory) + { + var fromPackage = FindPackage("Eto.Wpf", version, GetWindowsFrameworks(), "eto.platform.wpf"); + if (fromPackage != null) + return fromPackage; + + var beside = FindBeside("Eto.Wpf", etoDirectory) ?? probeDirectories.Select(r => FindBeside("Eto.Wpf", r)).FirstOrDefault(r => r != null); + return beside != null && GetPackageVersion(beside) == version ? beside : null; + } + + // newest first, but never newer than the runtime we're on + static IEnumerable GetWindowsFrameworks() + { + for (var major = Environment.Version.Major; major >= 5; major--) + { + yield return $"net{major}.0-windows7.0"; + yield return $"net{major}.0-windows"; + } + yield return "netcoreapp3.1"; + } + + static string FindPackage(string name, string version, IEnumerable frameworks, string packageId = null) + { + packageId = (packageId ?? name).ToLowerInvariant(); + foreach (var folder in GetPackageFolders()) + { + var lib = Path.Combine(folder, packageId, version.ToLowerInvariant(), "lib"); + if (!Directory.Exists(lib)) + continue; + foreach (var framework in frameworks) + { + var file = Path.Combine(lib, framework, name + ".dll"); + if (File.Exists(file)) + return file; + } + } + return null; + } + + static IEnumerable GetPackageFolders() + { + var configured = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (!string.IsNullOrEmpty(configured)) + yield return configured; + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + } + + static string GetPackageVersion(string file) + { + var version = FileVersionInfo.GetVersionInfo(file).ProductVersion; + if (string.IsNullOrEmpty(version)) + return null; + var plus = version.IndexOf('+'); + return plus >= 0 ? version.Substring(0, plus) : version; + } + + static Assembly Resolve(AssemblyLoadContext context, AssemblyName name) + { + if (loaded.TryGetValue(name.Name, out var assembly)) + return assembly; + + var file = FindFile(name); + if (file == null) + return null; + try + { + return Load(file); + } + catch (Exception ex) when (ex is IOException || ex is BadImageFormatException || ex is UnauthorizedAccessException) + { + log?.Invoke($"Could not load {file}: {ex.Message}"); + return null; + } + } + + static string FindFile(AssemblyName name) + { + if (namedFiles.TryGetValue(name.Name, out var named)) + return named; + + var own = Paths.FirstOrDefault(r => string.Equals(Path.GetFileNameWithoutExtension(r), name.Name, StringComparison.OrdinalIgnoreCase)); + if (own != null) + return own; + + var resolved = dependencyResolver?.ResolveAssemblyToPath(name); + if (resolved != null && File.Exists(resolved)) + return resolved; + + foreach (var directory in probeDirectories) + { + foreach (var extension in new[] { ".dll", ".exe" }) + { + var candidate = Path.Combine(directory, name.Name + extension); + if (File.Exists(candidate)) + return candidate; + } + } + + var fallback = Path.Combine(FallbackDirectory, name.Name + ".dll"); + return File.Exists(fallback) ? fallback : null; + } + + static Assembly Load(string file) + { + lock (loadLock) + { + var name = AssemblyName.GetAssemblyName(file).Name; + if (loaded.TryGetValue(name, out var existing)) + return existing; + + Assembly assembly; + if (IsImmutable(file)) + assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(file); + else + { + // read into memory so the project can still be rebuilt while the preview is open + using (var stream = new MemoryStream(File.ReadAllBytes(file))) + assembly = AssemblyLoadContext.Default.LoadFromStream(stream); + watchedFiles.Add(file); + } + loaded[name] = assembly; + return assembly; + } + } + + // package and bundled copies never change, so they can be loaded in place + static bool IsImmutable(string file) => + file.StartsWith(FallbackDirectory, StringComparison.OrdinalIgnoreCase) + || GetPackageFolders().Any(r => file.StartsWith(r, StringComparison.OrdinalIgnoreCase)); + + static IntPtr ResolveUnmanaged(Assembly assembly, string name) + { + var path = dependencyResolver?.ResolveUnmanagedDllToPath(name); + return path != null ? NativeLibrary.Load(path) : IntPtr.Zero; + } + } +} diff --git a/src/Eto.DevExtension.VSCode.sln b/src/Eto.DevExtension.VSCode.sln index fd6b9b5..ae8d9ce 100644 --- a/src/Eto.DevExtension.VSCode.sln +++ b/src/Eto.DevExtension.VSCode.sln @@ -5,6 +5,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Eto.DevExtension.LanguageSe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Eto.Designer", "Eto.Designer\Eto.Designer.csproj", "{7FAA1841-0869-474C-9D89-87E1B4BAB190}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Eto.DevExtension.PreviewHost", "Eto.DevExtension.PreviewHost\Eto.DevExtension.PreviewHost.csproj", "{5AF057D2-FE4A-428D-A704-BCC02BFF24C3}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{F132CF00-BA1F-41E0-BFD9-0F691C47C5FE}" ProjectSection(SolutionItems) = preProject ..\Directory.Build.props = ..\Directory.Build.props @@ -24,6 +26,10 @@ Global {7FAA1841-0869-474C-9D89-87E1B4BAB190}.Debug|Any CPU.Build.0 = Debug|Any CPU {7FAA1841-0869-474C-9D89-87E1B4BAB190}.Release|Any CPU.ActiveCfg = Release|Any CPU {7FAA1841-0869-474C-9D89-87E1B4BAB190}.Release|Any CPU.Build.0 = Release|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AF057D2-FE4A-428D-A704-BCC02BFF24C3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/src/Eto.DevExtension.VSCode/.gitignore b/src/Eto.DevExtension.VSCode/.gitignore index 2115027..a101196 100644 --- a/src/Eto.DevExtension.VSCode/.gitignore +++ b/src/Eto.DevExtension.VSCode/.gitignore @@ -1,4 +1,5 @@ node_modules/ out/ server/ +preview/ *.vsix diff --git a/src/Eto.DevExtension.VSCode/.vscodeignore b/src/Eto.DevExtension.VSCode/.vscodeignore index c668793..04aef9a 100644 --- a/src/Eto.DevExtension.VSCode/.vscodeignore +++ b/src/Eto.DevExtension.VSCode/.vscodeignore @@ -5,3 +5,4 @@ src/** tsconfig.json .gitignore server/**/*.pdb +preview/**/*.pdb diff --git a/src/Eto.DevExtension.VSCode/README.md b/src/Eto.DevExtension.VSCode/README.md index 5caab6e..dc2bc2f 100644 --- a/src/Eto.DevExtension.VSCode/README.md +++ b/src/Eto.DevExtension.VSCode/README.md @@ -1,9 +1,14 @@ # Eto.Forms Designer for VS Code -Autocompletion and hover documentation for [Eto.Forms](https://github.com/picoe/Eto) designer files. +Autocompletion, hover documentation and a live preview for [Eto.Forms](https://github.com/picoe/Eto) designer files. ## Features +- A live preview of `.xeto`, `.jeto` and `.eto.cs` files beside the editor (Windows only). Run + **Eto: Open Preview to the Side**, or use the preview button in the editor title bar. Your own + controls show up once the project is built, and the preview redraws after each build. Drag the + corner handle to try the form at other sizes; click the size label to go back to its own size + - Completion of control names, properties, events and property values in `.xeto` (xaml) and `.jeto` (json) files - Completion of your own controls from the project and the projects it references, once built. In `.xeto` files picking one adds the `xmlns` it needs; in `.jeto` files they complete as @@ -19,6 +24,10 @@ the copy bundled with the extension is used. ## Requirements The [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer, used to run the language server. +The preview also needs the .NET Desktop Runtime 8 or newer. + +The preview runs your project's code in a separate process, drawing with the Eto.Forms and Eto.Wpf +versions the project uses. ## Settings @@ -26,6 +35,7 @@ The [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer, used to ru | --- | --- | | `eto.dotnetPath` | Path to the `dotnet` executable. Uses `dotnet` from `PATH` when empty. | | `eto.languageServer.path` | Path to `Eto.DevExtension.LanguageServer.dll`. Uses the bundled copy when empty. | +| `eto.previewHost.path` | Path to `Eto.DevExtension.PreviewHost.dll`. Uses the bundled copy when empty. | | `eto.etoAssemblyPath` | Folder containing the `Eto.dll` to complete against. Detected from the project when empty. | | `eto.trace.server` | Logs the traffic between VS Code and the language server. | @@ -36,7 +46,7 @@ a different project, run **Eto: Restart Language Server**. ```sh npm install -npm run build:server # publishes the .NET language server into ./server +npm run build:server # publishes the .NET language server into ./server and the preview host into ./preview npm run compile ``` diff --git a/src/Eto.DevExtension.VSCode/package-lock.json b/src/Eto.DevExtension.VSCode/package-lock.json index 6fb6a12..3a08704 100644 --- a/src/Eto.DevExtension.VSCode/package-lock.json +++ b/src/Eto.DevExtension.VSCode/package-lock.json @@ -9,6 +9,7 @@ "version": "2.7.5", "license": "MIT", "dependencies": { + "vscode-jsonrpc": "^8.2.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/src/Eto.DevExtension.VSCode/package.json b/src/Eto.DevExtension.VSCode/package.json index 80a32be..041adcf 100644 --- a/src/Eto.DevExtension.VSCode/package.json +++ b/src/Eto.DevExtension.VSCode/package.json @@ -1,7 +1,7 @@ { "name": "eto-forms-designer", "displayName": "Eto.Forms Designer", - "description": "Autocompletion and hover documentation for Eto.Forms .xeto and .jeto designer files.", + "description": "Autocompletion, hover documentation and a live preview for Eto.Forms .xeto and .jeto designer files.", "version": "2.7.5", "publisher": "picoe", "license": "MIT", @@ -76,6 +76,11 @@ "default": "", "description": "Path to Eto.DevExtension.LanguageServer.dll. Uses the copy bundled with the extension when empty." }, + "eto.previewHost.path": { + "type": "string", + "default": "", + "description": "Path to Eto.DevExtension.PreviewHost.dll. Uses the copy bundled with the extension when empty." + }, "eto.etoAssemblyPath": { "type": "string", "default": "", @@ -97,17 +102,38 @@ { "command": "eto.restartLanguageServer", "title": "Eto: Restart Language Server" + }, + { + "command": "eto.openPreview", + "title": "Eto: Open Preview to the Side", + "icon": "$(open-preview)" } - ] + ], + "menus": { + "editor/title": [ + { + "command": "eto.openPreview", + "group": "navigation", + "when": "isWindows && resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" + } + ], + "commandPalette": [ + { + "command": "eto.openPreview", + "when": "isWindows && resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" + } + ] + } }, "scripts": { "vscode:prepublish": "npm run build:server && npm run compile", - "build:server": "dotnet publish ../Eto.DevExtension.LanguageServer/Eto.DevExtension.LanguageServer.csproj -c Release -o server", + "build:server": "dotnet publish ../Eto.DevExtension.LanguageServer/Eto.DevExtension.LanguageServer.csproj -c Release -o server && dotnet publish ../Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj -c Release -o preview", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "package": "node -e \"require('fs').mkdirSync('../../artifacts/vscode',{recursive:true})\" && vsce package --out ../../artifacts/vscode/" }, "dependencies": { + "vscode-jsonrpc": "^8.2.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/src/Eto.DevExtension.VSCode/src/extension.ts b/src/Eto.DevExtension.VSCode/src/extension.ts index a0ae5c3..998a3c2 100644 --- a/src/Eto.DevExtension.VSCode/src/extension.ts +++ b/src/Eto.DevExtension.VSCode/src/extension.ts @@ -7,18 +7,24 @@ import { ServerOptions, TransportKind } from 'vscode-languageclient/node'; +import { isPreviewable, PreviewPanel } from './preview'; +import { PreviewHost } from './previewHost'; const SERVER_DLL = 'Eto.DevExtension.LanguageServer.dll'; +const PREVIEW_HOST_DLL = 'Eto.DevExtension.PreviewHost.dll'; let client: LanguageClient | undefined; let output: vscode.OutputChannel; +let previewHost: PreviewHost | undefined; export async function activate(context: vscode.ExtensionContext): Promise { output = vscode.window.createOutputChannel('Eto.Forms Designer'); context.subscriptions.push(output); context.subscriptions.push( - vscode.commands.registerCommand('eto.restartLanguageServer', () => restart(context)) + vscode.commands.registerCommand('eto.restartLanguageServer', () => restart(context)), + vscode.commands.registerCommand('eto.openPreview', (uri?: vscode.Uri) => openPreview(context, uri)), + { dispose: () => previewHost?.dispose() } ); // the server pins one Eto version per session, so these need a fresh process @@ -93,12 +99,41 @@ async function start(context: vscode.ExtensionContext): Promise { } } +async function openPreview(context: vscode.ExtensionContext, uri?: vscode.Uri): Promise { + const document = uri + ? await vscode.workspace.openTextDocument(uri) + : vscode.window.activeTextEditor?.document; + if (!document || !isPreviewable(document)) { + vscode.window.showInformationMessage('Open a .xeto, .jeto or .eto.cs file to preview it.'); + return; + } + + if (!previewHost) { + const config = vscode.workspace.getConfiguration('eto'); + const hostPath = resolvePath(context, config.get('previewHost.path'), 'preview', PREVIEW_HOST_DLL, + path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0-windows')); + if (!hostPath) { + vscode.window.showErrorMessage(`Could not find ${PREVIEW_HOST_DLL}. Set "eto.previewHost.path" to point at it.`); + return; + } + previewHost = new PreviewHost(config.get('dotnetPath')?.trim() || 'dotnet', hostPath, output); + } + PreviewPanel.show(previewHost, document); +} + function resolveServerPath(context: vscode.ExtensionContext, configured?: string): string | undefined { + return resolvePath(context, configured, 'server', SERVER_DLL, path.join('Eto.DevExtension.LanguageServer', 'Debug', 'net8.0')); +} + +/** + * @param folder Where the dll is bundled in the extension. + * @param buildFolder Where a debug build puts it under artifacts, for running out of the source tree. + */ +function resolvePath(context: vscode.ExtensionContext, configured: string | undefined, folder: string, dll: string, buildFolder: string): string | undefined { const candidates = [ configured?.trim(), - path.join(context.extensionPath, 'server', SERVER_DLL), - // running out of the source tree during development - path.join(context.extensionPath, '..', '..', 'artifacts', 'Eto.DevExtension.LanguageServer', 'Debug', 'net8.0', SERVER_DLL) + path.join(context.extensionPath, folder, dll), + path.join(context.extensionPath, '..', '..', 'artifacts', buildFolder, dll) ]; for (const candidate of candidates) { diff --git a/src/Eto.DevExtension.VSCode/src/preview.ts b/src/Eto.DevExtension.VSCode/src/preview.ts new file mode 100644 index 0000000..083c5c0 --- /dev/null +++ b/src/Eto.DevExtension.VSCode/src/preview.ts @@ -0,0 +1,250 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { PreviewHost, RenderResult } from './previewHost'; + +const REFRESH_DELAY_MS = 500; + +/** Designer files the preview can draw. */ +export function isPreviewable(document: vscode.TextDocument): boolean { + return document.uri.scheme === 'file' && /\.(xeto|jeto|eto\.cs|eto\.vb)$/i.test(document.fileName); +} + +/** + * A single preview beside the editor that follows the active designer file and redraws as it is edited. + */ +export class PreviewPanel implements vscode.Disposable { + private static current: PreviewPanel | undefined; + + private readonly panel: vscode.WebviewPanel; + private readonly disposables: vscode.Disposable[] = []; + private document: vscode.TextDocument | undefined; + private size: { width: number; height: number } | undefined; + private scale = 1; + private ready = false; + private rendering = false; + private renderPending = false; + private timer: NodeJS.Timeout | undefined; + + static show(host: PreviewHost, document: vscode.TextDocument): void { + if (PreviewPanel.current) { + PreviewPanel.current.panel.reveal(vscode.ViewColumn.Beside, true); + } else { + PreviewPanel.current = new PreviewPanel(host); + } + PreviewPanel.current.setDocument(document); + } + + private constructor(private readonly host: PreviewHost) { + this.panel = vscode.window.createWebviewPanel('eto.preview', 'Eto Preview', { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { + enableScripts: true, + localResourceRoots: [] + }); + this.panel.webview.html = getHtml(); + + this.disposables.push( + this.panel.onDidDispose(() => this.dispose()), + this.panel.webview.onDidReceiveMessage(message => this.onMessage(message)), + vscode.workspace.onDidChangeTextDocument(e => { + if (e.document === this.document) { + this.schedule(); + } + }), + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor && isPreviewable(editor.document) && editor.document !== this.document) { + this.setDocument(editor.document); + } + }), + host.onDidRequestRedraw(() => this.render()) + ); + } + + dispose(): void { + PreviewPanel.current = undefined; + clearTimeout(this.timer); + this.panel.dispose(); + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private setDocument(document: vscode.TextDocument): void { + this.document = document; + // a size picked for one file rarely suits the next + this.size = undefined; + this.panel.title = `Preview ${path.basename(document.fileName)}`; + this.render(); + } + + private onMessage(message: { type: string; scale?: number; width?: number; height?: number }): void { + switch (message.type) { + case 'ready': + this.ready = true; + this.scale = message.scale || 1; + this.render(); + break; + case 'scale': + this.scale = message.scale || 1; + this.render(); + break; + case 'resize': + this.size = { width: Math.round(message.width!), height: Math.round(message.height!) }; + this.render(); + break; + case 'reset': + this.size = undefined; + this.render(); + break; + } + } + + private schedule(): void { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.render(), REFRESH_DELAY_MS); + } + + private async render(): Promise { + if (!this.ready || !this.document) { + return; + } + if (this.rendering) { + this.renderPending = true; + return; + } + + this.rendering = true; + try { + do { + this.renderPending = false; + const document: vscode.TextDocument = this.document; + const result: RenderResult = await this.host.render({ + fileName: document.fileName, + text: document.getText(), + width: this.size?.width, + height: this.size?.height, + scale: this.scale + }); + if (PreviewPanel.current !== this) { + return; + } + // results for a file the user already moved away from would only flicker + if (document === this.document) { + this.panel.webview.postMessage(result.error + ? { type: 'error', message: result.error.message, details: result.error.details } + : { type: 'image', image: result.image, width: result.width, height: result.height, sized: !!this.size }); + } + } while (this.renderPending); + } finally { + this.rendering = false; + } + } +} + +function getHtml(): string { + const nonce = [...Array(32)].map(() => Math.floor(Math.random() * 36).toString(36)).join(''); + return /* html */ ` + + + + + + + + +
+
Drawing preview…
+
+
+
+ + +`; +} diff --git a/src/Eto.DevExtension.VSCode/src/previewHost.ts b/src/Eto.DevExtension.VSCode/src/previewHost.ts new file mode 100644 index 0000000..2f53789 --- /dev/null +++ b/src/Eto.DevExtension.VSCode/src/previewHost.ts @@ -0,0 +1,154 @@ +import { ChildProcess, spawn } from 'child_process'; +import * as vscode from 'vscode'; +import { + createMessageConnection, + MessageConnection, + StreamMessageReader, + StreamMessageWriter +} from 'vscode-jsonrpc/node'; + +// long enough for a first render that compiles code, short enough to recover from a hung control +const RENDER_TIMEOUT_MS = 30000; + +export interface RenderRequest { + fileName: string; + text: string; + width?: number; + height?: number; + scale: number; +} + +export interface RenderResult { + image?: string; + width?: number; + height?: number; + error?: { message: string; details?: string }; +} + +/** + * The .NET process that draws designer files to images, so project code runs outside VS Code. + * Renders one request at a time, and restarts the process whenever it exits or stops responding. + */ +export class PreviewHost implements vscode.Disposable { + private process: ChildProcess | undefined; + private connection: MessageConnection | undefined; + private starting: Promise | undefined; + private queue: Promise = Promise.resolve(); + private unavailable = false; + private readonly restarted = new vscode.EventEmitter(); + + /** Fires after the project is rebuilt, so previews can be redrawn. */ + readonly onDidRequestRedraw = this.restarted.event; + + constructor( + private readonly dotnet: string, + private readonly hostPath: string, + private readonly output: vscode.OutputChannel + ) { } + + render(request: RenderRequest): Promise { + const next = this.queue.then(() => this.renderNow(request)); + this.queue = next.catch(() => undefined); + return next; + } + + dispose(): void { + this.stop(); + this.restarted.dispose(); + } + + private async renderNow(request: RenderRequest): Promise { + // a second try covers a host that exited after a rebuild, or that serves another project + for (let attempt = 0; attempt < 2; attempt++) { + const connection = await this.getConnection(); + if (!connection) { + return error('The preview needs the .NET 8 Desktop Runtime (or newer). See the Eto.Forms Designer output for details.'); + } + + let timer: NodeJS.Timeout | undefined; + try { + const timeout = new Promise<'timeout'>(resolve => timer = setTimeout(() => resolve('timeout'), RENDER_TIMEOUT_MS)); + const result = await Promise.race([ + connection.sendRequest('preview/render', { ...request, assemblies: null }), + timeout + ]); + if (result === 'timeout') { + this.stop(); + return error('The preview took too long to draw, so it was stopped.'); + } + if (result?.restartRequired) { + this.stop(); + continue; + } + return result ?? {}; + } catch (e) { + this.stop(); + if (attempt > 0) { + return error('The preview host stopped unexpectedly.', `${e}`); + } + } finally { + clearTimeout(timer); + } + } + return error('The preview host could not load the project.'); + } + + private getConnection(): Promise { + if (this.connection && this.process && isRunning(this.process)) { + return Promise.resolve(this.connection); + } + if (this.unavailable) { + return Promise.resolve(undefined); + } + this.stop(); + this.starting ??= this.start().finally(() => this.starting = undefined); + return this.starting; + } + + private async start(): Promise { + try { + const child = spawn(this.dotnet, [this.hostPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const failed = new Promise((_, reject) => child.once('error', reject)); + child.stderr?.on('data', data => this.output.append(`${data}`)); + + const connection = createMessageConnection(new StreamMessageReader(child.stdout!), new StreamMessageWriter(child.stdin!)); + connection.onNotification('preview/restart', () => { + this.output.appendLine('Project rebuilt, restarting the preview host.'); + this.restarted.fire(); + }); + connection.onNotification('window/logMessage', (params: { message: string }) => this.output.appendLine(params.message)); + connection.listen(); + + this.process = child; + this.connection = connection; + await Promise.race([connection.sendRequest('initialize', { processId: process.pid }), failed]); + connection.sendNotification('initialized', {}); + return connection; + } catch (e) { + // usually no desktop runtime to run it with, so don't keep trying + this.unavailable = true; + this.output.appendLine(`Could not start the Eto preview host using "${this.dotnet}": ${e}`); + this.stop(); + return undefined; + } + } + + private stop(): void { + const connection = this.connection; + const child = this.process; + this.connection = undefined; + this.process = undefined; + connection?.dispose(); + if (child && isRunning(child)) { + child.kill(); + } + } +} + +function isRunning(child: ChildProcess): boolean { + return child.exitCode === null && child.signalCode === null; +} + +function error(message: string, details?: string): RenderResult { + return { error: { message, details: details ?? message } }; +} diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs index 3c0f488..a031d54 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EditorFactory.cs @@ -212,12 +212,10 @@ public int CreateEditorInstance( try { - var outputFile = GetAssemblyPath(proj); - var references = GetReferences(proj).ToList(); - //var outputDir = Path.GetDirectoryName(outputFile); + var projectKey = proj?.FullName ?? pszMkDocument; // Create the Document (editor) - var editor = new EtoPreviewPane(editorPackage, pszMkDocument, textBuffer, outputFile, references, codeEditor); + var editor = new EtoPreviewPane(editorPackage, pszMkDocument, textBuffer, projectKey, codeEditor); ppunkDocView = Marshal.GetIUnknownForObject(editor); } catch (Exception ex) @@ -259,35 +257,6 @@ public IVsProject ToVsProject(EnvDTE.Project project) return vsProject; } - public static IEnumerable GetReferences(EnvDTE.Project project) - { - Microsoft.VisualStudio.Shell.ThreadHelper.ThrowIfNotOnUIThread(); - var vsproject = project.Object as VSLangProj.VSProject; - if (vsproject == null) - yield break; - // note: you could also try casting to VsWebSite.VSWebSite - - foreach (VSLangProj.Reference reference in vsproject.References) - { - if (reference.SourceProject == null) - { - //skip framework assemblies - //if (((dynamic)reference).AutoReferenced) - // continue; - - // This is an assembly reference - if (reference.Path != null) - yield return reference.Path; - } - else - { - // This is a project reference - var path = GetAssemblyPath(reference.SourceProject); - if (path != null) - yield return path; - } - } - } internal static string GetAssemblyPath(EnvDTE.Project vsProject) { Microsoft.VisualStudio.Shell.ThreadHelper.ThrowIfNotOnUIThread(); diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs deleted file mode 100755 index c641e9a..0000000 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoAdapterHandler.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using Eto.Forms; -using Eto.Designer; -using Eto.Wpf.Forms; -using System.Windows; -using System.AddIn.Pipeline; -using System.AddIn.Contract; -using System.Collections.Generic; -using Eto.Wpf.Forms.Controls; -using sw = System.Windows; -using swc = System.Windows.Controls; -using System.Windows.Threading; -using Eto; -using Eto.DevExtension.VisualStudio.Windows.Editor; - -[assembly: ExportHandler(typeof(IEtoAdapterHandler), typeof(EtoAdapterHandler))] - -namespace Eto.DevExtension.VisualStudio.Windows.Editor -{ - class ControlContract : MarshalByRefObject - { - public IntPtr Handle { get; set; } - } - - class NativeControl : WpfFrameworkElement - { - public NativeControl(FrameworkElement control) - { - Control = control; - } - - public override Drawing.Color BackgroundColor - { - get { return Drawing.Colors.Transparent; } - set - { - } - } - } - - public class EtoAdapterHandler : IEtoAdapterHandler - { - public object ToContract(Control control) - { - var content = DesignPanel.GetContent(control); - var view = content.ToNative(true); - return FrameworkElementAdapters.ViewToContractAdapter(view); - } - - public Control FromContract(object contract) - { - var view = FrameworkElementAdapters.ContractToViewAdapter((INativeHandleContract)contract); - view.Focusable = false; // otherwise editor loses focus when switching back to its tab.. may be a better way around this. - return new Control(new NativeControl(view)); - } - - public void Unload() - { - Dispatcher.CurrentDispatcher?.InvokeShutdown(); - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } -} - diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs index 8fa8cf9..4c2bd39 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs @@ -31,6 +31,7 @@ using System.Windows.Media; using IOleServiceProvider = Microsoft.VisualStudio.OLE.Interop.IServiceProvider; using System.Collections.Generic; +using Eto.DevExtension.VisualStudio.Intellisense; namespace Eto.DevExtension.VisualStudio.Windows.Editor { @@ -48,6 +49,7 @@ public sealed class EtoPreviewPane : Microsoft.VisualStudio.Shell.WindowPane, EtoAddinPackage package; PreviewEditorView preview; PreviewEditorViewSplitter previewSplitter; + PreviewHostClient previewHost; Panel editorControl; uint dataEventsCookie; uint linesEventsCookie; @@ -100,7 +102,8 @@ void RegisterIndependentView(bool subscribe) /// our initialization functions. /// /// Our Package instance. - public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines textBuffer, string mainAssembly, IEnumerable references, CodeEditorHost codeEditor) + /// Identifies the project, so its designers share one preview host. + public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines textBuffer, string projectKey, CodeEditorHost codeEditor) : base(package) { this.editor = codeEditor; @@ -111,7 +114,11 @@ public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines tex editorControl = new Panel(); editorControl.Content = editor.wpfElement.ToEto(); - previewSplitter = new PreviewEditorViewSplitter(editorControl, mainAssembly, references, () => textBuffer?.GetText()); + var host = previewHost = PreviewHostClient.Acquire(projectKey); + host.ProjectChanged += PreviewHost_ProjectChanged; + var designHost = new RemoteDesignPanel(async request => await host.RenderAsync(request, await ProjectAssemblyPaths.GetAsync(fileName))); + + previewSplitter = new PreviewEditorViewSplitter(editorControl, designHost, () => textBuffer?.GetText()); previewSplitter.GotFocus += (sender, e) => { WpfTextView?.VisualElement?.Focus(); @@ -257,7 +264,14 @@ protected override void Dispose(bool disposing) if (disposing) { - RegisterIndependentView(false); + RegisterIndependentView(false); + + if (previewHost != null) + { + previewHost.ProjectChanged -= PreviewHost_ProjectChanged; + previewHost.Release(); + previewHost = null; + } //previewSplitter?.Dispose(); //previewSplitter = null; @@ -306,6 +320,8 @@ void ViewCode() #endregion + void PreviewHost_ProjectChanged(object sender, EventArgs e) => preview?.Update(); + void IVsTextBufferDataEvents.OnFileChanged(uint grfChange, uint dwFileAttrs) { preview.Update(); diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs new file mode 100644 index 0000000..1c0b93f --- /dev/null +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs @@ -0,0 +1,228 @@ +using Eto.Designer; +using Eto.Drawing; +using Newtonsoft.Json.Linq; +using StreamJsonRpc; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Eto.DevExtension.VisualStudio.Windows.Editor +{ + /// + /// The .NET preview host that draws designer files, shared by the open designers of one project. + /// + /// + /// Previews are drawn in a separate process so the project's own code never loads into Visual Studio. + /// + sealed class PreviewHostClient + { + const string HostDll = "Eto.DevExtension.PreviewHost.dll"; + // long enough for a first render that compiles code, short enough to recover from a hung control + static readonly TimeSpan RenderTimeout = TimeSpan.FromSeconds(30); + + static readonly Dictionary hosts = new Dictionary(StringComparer.OrdinalIgnoreCase); + static bool unavailable; + + readonly string key; + readonly SemaphoreSlim requestLock = new SemaphoreSlim(1, 1); + int references; + Process process; + JsonRpc rpc; + + /// Raised on the UI thread after the project is rebuilt, so previews can be redrawn. + public event EventHandler ProjectChanged; + + PreviewHostClient(string key) => this.key = key; + + bool IsRunning => rpc != null && process != null && !process.HasExited; + + /// Identifies the project, so its designers share one host. + public static PreviewHostClient Acquire(string key) + { + key = key ?? string.Empty; + lock (hosts) + { + if (!hosts.TryGetValue(key, out var host)) + hosts[key] = host = new PreviewHostClient(key); + host.references++; + return host; + } + } + + public void Release() + { + lock (hosts) + { + if (--references > 0) + return; + hosts.Remove(key); + } + Stop(); + } + + /// Project assembly files, the project's own first, or null to let the host find them. + public async Task RenderAsync(PreviewRenderRequest request, IList assemblies) + { + await requestLock.WaitAsync(); + try + { + // a second try covers a host that exited after a rebuild, or that serves an older build + for (var attempt = 0; attempt < 2; attempt++) + { + var server = await GetServerAsync(); + if (server == null) + return Error("The preview needs the .NET 8 Desktop Runtime (or newer) to be installed.", null); + + try + { + using (var timeout = new CancellationTokenSource(RenderTimeout)) + { + var result = await server.InvokeWithParameterObjectAsync( + "preview/render", + new + { + fileName = request.FileName, + text = request.Text, + assemblies, + width = request.Size?.Width, + height = request.Size?.Height, + scale = request.Scale + }, + timeout.Token); + + if ((bool?)result?["restartRequired"] == true) + { + Stop(); + continue; + } + return Read(result); + } + } + catch (OperationCanceledException) + { + Stop(); + return Error("The preview took too long to draw, so it was stopped.", null); + } + catch (Exception ex) when (ex is ConnectionLostException || ex is ObjectDisposedException || ex is IOException) + { + Stop(); + if (attempt > 0) + return Error("The preview host stopped unexpectedly.", ex.ToString()); + } + } + return Error("The preview host could not load the project.", null); + } + finally + { + requestLock.Release(); + } + } + + static PreviewRenderResult Read(JToken result) + { + var error = result?["error"]; + if (error != null && error.Type == JTokenType.Object) + return Error((string)error["message"], (string)error["details"]); + + var image = (string)result?["image"]; + return new PreviewRenderResult + { + Image = string.IsNullOrEmpty(image) ? null : Convert.FromBase64String(image), + Size = new Size((int?)result?["width"] ?? 0, (int?)result?["height"] ?? 0) + }; + } + + static PreviewRenderResult Error(string message, string details) => + new PreviewRenderResult { Error = new DesignError { Message = message, Details = details ?? message } }; + + async Task GetServerAsync() + { + if (IsRunning) + return rpc; + if (unavailable) + return null; + + // clean up after a host that exited, such as after a rebuild + Stop(); + + var hostPath = Path.Combine(Path.GetDirectoryName(typeof(PreviewHostClient).Assembly.Location), "preview", HostDll); + if (!File.Exists(hostPath)) + { + unavailable = true; + Debug.WriteLine($"Eto preview host not found at {hostPath}"); + return null; + } + + try + { + var start = new ProcessStartInfo("dotnet", "\"" + hostPath + "\"") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + process = Process.Start(start); + process.ErrorDataReceived += (sender, e) => Debug.WriteLine(e.Data); + process.BeginErrorReadLine(); + + rpc = new JsonRpc(new HeaderDelimitedMessageHandler(process.StandardInput.BaseStream, process.StandardOutput.BaseStream)); + rpc.AddLocalRpcMethod("preview/restart", new Action(OnRestart)); + rpc.AddLocalRpcMethod("window/logMessage", new Action((type, message) => Debug.WriteLine($"Eto preview: {message}"))); + rpc.StartListening(); + await rpc.InvokeWithParameterObjectAsync("initialize", new { processId = Process.GetCurrentProcess().Id, theme = GetTheme() }); + await rpc.NotifyWithParameterObjectAsync("initialized", new { }); + return rpc; + } + catch (Exception ex) + { + // usually no desktop runtime to run it with, so don't keep trying + unavailable = true; + Debug.WriteLine($"Could not start the Eto preview host: {ex}"); + Stop(); + return null; + } + } + + void OnRestart() + { + // the host exits right after this, and the next render starts a fresh one + Eto.Forms.Application.Instance.AsyncInvoke(() => ProjectChanged?.Invoke(this, EventArgs.Empty)); + } + + static object GetTheme() + { + var theme = Global.Theme; + return new + { + designerPanel = theme.DesignerPanel.ToHex(), + designerBackground = theme.DesignerBackground.ToHex(), + projectBackground = theme.ProjectBackground.ToHex(), + projectForeground = theme.ProjectForeground.ToHex() + }; + } + + void Stop() + { + var oldRpc = rpc; + var oldProcess = process; + rpc = null; + process = null; + try + { + oldRpc?.Dispose(); + if (oldProcess != null && !oldProcess.HasExited) + oldProcess.Kill(); + } + catch (Exception ex) + { + Debug.WriteLine($"Could not stop the Eto preview host: {ex.Message}"); + } + oldProcess?.Dispose(); + } + } +} diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj index ae16b09..ab613cd 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj +++ b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj @@ -84,8 +84,8 @@ - + @@ -184,8 +184,6 @@ - - @@ -282,6 +280,18 @@ + + + $(MSBuildProjectDirectory)\..\Eto.DevExtension.PreviewHost\Eto.DevExtension.PreviewHost.csproj + $(ArtifactsDir)Eto.DevExtension.PreviewHost\publish\$(Configuration)\ + + + + + + + + diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs b/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs index 4c7401a..01c7961 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Wizards/EtoInitializer.cs @@ -34,8 +34,6 @@ public static void Initialize() if (Application.Instance == null) new Eto.Forms.Application().Attach(); - - Eto.Designer.Builders.BaseCompiledInterfaceBuilder.InitializeAssembly = typeof(EtoInitializer).Assembly.FullName; } private static void ThemeWindow(System.Windows.Window w) From 5b2c6694196bc26fef92859b66b9a01c87464e50 Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Wed, 23 Sep 2026 10:03:47 -0700 Subject: [PATCH 2/5] Add Mac/Gtk support to the previewer --- .../Eto.DevExtension.PreviewHost.macOS.csproj | 57 +++++++ .../Info.plist | 9 + .../Eto.DevExtension.PreviewHost.csproj | 50 +++++- .../Platforms/GtkPreviewPlatform.cs | 86 ++++++++++ .../Platforms/MacPreviewPlatform.cs | 90 ++++++++++ .../Platforms/PreviewPlatform.cs | 81 +++++++++ .../Platforms/WpfPreviewPlatform.cs | 74 ++++++++ .../PreviewRenderer.cs | 84 ++++----- .../PreviewServer.cs | 19 ++- src/Eto.DevExtension.PreviewHost/Program.cs | 6 +- .../ProjectAssemblies.cs | 71 +++++--- src/Eto.DevExtension.VSCode/.vscodeignore | 1 + src/Eto.DevExtension.VSCode/README.md | 18 +- src/Eto.DevExtension.VSCode/package.json | 23 ++- .../scripts/build-server.js | 36 ++++ src/Eto.DevExtension.VSCode/src/extension.ts | 12 +- src/Eto.DevExtension.VSCode/src/hostLaunch.ts | 159 ++++++++++++++++++ .../src/previewHost.ts | 36 ++-- ...o.DevExtension.VisualStudio.Windows.csproj | 2 +- 19 files changed, 789 insertions(+), 125 deletions(-) create mode 100644 src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj create mode 100644 src/Eto.DevExtension.PreviewHost.macOS/Info.plist create mode 100644 src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs create mode 100644 src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs create mode 100644 src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs create mode 100644 src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs create mode 100644 src/Eto.DevExtension.VSCode/scripts/build-server.js create mode 100644 src/Eto.DevExtension.VSCode/src/hostLaunch.ts diff --git a/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj b/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj new file mode 100644 index 0000000..543c36e --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj @@ -0,0 +1,57 @@ + + + + Exe + net10.0-macos + 12.0 + osx-x64;osx-arm64 + disable + disable + Eto.DevExtension.PreviewHost + Eto.DevExtension.PreviewHost + ca.picoe.eto.previewhost + Eto Preview Host + en + + dynamic + None + false + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Eto.DevExtension.PreviewHost.macOS/Info.plist b/src/Eto.DevExtension.PreviewHost.macOS/Info.plist new file mode 100644 index 0000000..fc7c47c --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost.macOS/Info.plist @@ -0,0 +1,9 @@ + + + + + + LSUIElement + + + diff --git a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj index 4fcb8e5..ea5af21 100644 --- a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj +++ b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj @@ -1,8 +1,8 @@ Exe - net8.0-windows - true + + net8.0-windows;net8.0 disable disable LatestMajor @@ -15,6 +15,10 @@ en + + true + + @@ -28,31 +32,63 @@ - + + + + + + + + + - - - + + + + + + + + + + + - + + + + + + + + + + + + + + + + + diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs new file mode 100644 index 0000000..6114e86 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/GtkPreviewPlatform.cs @@ -0,0 +1,86 @@ +#if !WINDOWS && !MACOS +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class GtkPreviewPlatform : PreviewPlatform + { + public override string Name => "Gtk"; + public override string AssemblyName => "Eto.Gtk"; + public override string PackageId => "eto.platform.gtk"; + + public override IEnumerable SharedNames => new[] { "GtkSharp", "GdkSharp", "GLibSharp", "GioSharp", "AtkSharp", "PangoSharp", "CairoSharp" }; + + public override IEnumerable GetFrameworks(IEnumerable available) => + ProjectAssemblies.RuntimeFrameworks(); + + public override object CreatePlatform() => new Eto.GtkSharp.Platform(); + + // otherwise the first text box shows with its text selected, as the offscreen window focuses it + public override void Initialized() => Gtk.Settings.Default.SetProperty("gtk-entry-select-on-focus", new GLib.Value(false)); + + public override Task CaptureAsync(object control, RenderRequest request) + { + var etoControl = (Control)control; + var content = Eto.Designer.DesignPanel.GetContent(etoControl); + if (request.Width != null || request.Height != null) + content.Size = new Eto.Drawing.Size(request.Width ?? -1, request.Height ?? -1); + + // an offscreen window, since Wayland won't let a real one be moved out of sight + var window = new Gtk.OffscreenWindow(); + Gtk.Widget widget = null; + try + { + widget = Gtk3Helpers.ToNative(content, true); + window.Add(widget); + // GTK would give wrapping labels their narrowest width, unlike an Eto form + if (request.Width == null) + content.Width = (int)Math.Ceiling(content.GetPreferredSize().Width); + window.ShowAll(); + window.Focus = null; + for (var i = 0; i < 10 && Gtk.Application.EventsPending(); i++) + Gtk.Application.RunIteration(false); + + var width = widget.AllocatedWidth; + var height = widget.AllocatedHeight; + if (width <= 0 || height <= 0) + return Task.FromResult(RenderResult.Empty(width, height)); + + var scale = request.Scale > 0 ? request.Scale : 1; + using (var surface = new Cairo.ImageSurface(Cairo.Format.Argb32, (int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale))) + { + using (var context = new Cairo.Context(surface)) + { + context.Scale(scale, scale); + widget.Draw(context); + } + surface.Flush(); + // CairoSharp can only write PNGs to a file + var file = Path.GetTempFileName(); + try + { + surface.WriteToPng(file); + return Task.FromResult(RenderResult.Png(File.ReadAllBytes(file), width, height)); + } + finally + { + File.Delete(file); + } + } + } + finally + { + if (widget?.Parent == window) + window.Remove(widget); + window.Destroy(); + etoControl.Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs new file mode 100644 index 0000000..fbe554e --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/MacPreviewPlatform.cs @@ -0,0 +1,90 @@ +#if !WINDOWS +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Eto.Forms; +#if MACOS +using AppKit; +using Foundation; +using CoreGraphics; +#else +using MonoMac.AppKit; +using MonoMac.Foundation; +using MonoMac.CoreGraphics; +#endif + +namespace Eto.DevExtension.PreviewHost +{ + /// Eto.macOS in the app bundle host, or Eto.Mac64 when run with dotnet. + class MacPreviewPlatform : PreviewPlatform + { +#if MACOS + public override string Name => "macOS"; + public override string AssemblyName => "Eto.macOS"; + public override string PackageId => "eto.platform.macos"; + + // folders are versioned, e.g. net10.0-macos26.0 + public override IEnumerable GetFrameworks(IEnumerable available) => + Majors().SelectMany(major => available + .Where(r => r.StartsWith($"net{major}.0-macos", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r, StringComparer.OrdinalIgnoreCase)); +#else + public override string Name => "Mac64"; + public override string AssemblyName => "Eto.Mac64"; + public override string PackageId => "eto.platform.mac64"; + public override IEnumerable CompanionNames => new[] { "MonoMac" }; + + public override IEnumerable GetFrameworks(IEnumerable available) => + ProjectAssemblies.RuntimeFrameworks(); +#endif + + public override object CreatePlatform() => new Eto.Mac.Platform(); + + // no dock icon or menu bar + public override void Initialized() => NSApplication.SharedApplication.ActivationPolicy = NSApplicationActivationPolicy.Accessory; + + public override Task CaptureAsync(object control, RenderRequest request) + { + var form = OffscreenForm.Create((Control)control, request, out var content); + try + { + form.Show(); + Application.Instance.RunIteration(); + + #if MACOS + var view = MacOSHelpers.ToNative(content); +#else + var view = MonoMac64Helpers.ToNative(content); +#endif + var width = (int)Math.Ceiling(view.Frame.Width); + var height = (int)Math.Ceiling(view.Frame.Height); + if (width <= 0 || height <= 0) + return Task.FromResult(RenderResult.Empty(width, height)); + + // sized by hand, since the offscreen window has no screen to take a backing scale from + var scale = request.Scale > 0 ? request.Scale : 1; + var bounds = view.Bounds; + var rep = view.BitmapImageRepForCachingDisplayInRect(bounds); + if (Math.Abs(rep.PixelsWide - width * scale) > 1) + rep = new NSBitmapImageRep(IntPtr.Zero, (int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale), 8, 4, true, false, NSColorSpace.DeviceRGB, 0, 32) { Size = bounds.Size }; + view.CacheDisplay(bounds, rep); + + var data = rep.RepresentationUsingTypeProperties(NSBitmapImageFileType.Png, new NSDictionary()); + using (var input = data.AsStream()) + using (var stream = new MemoryStream()) + { + input.CopyTo(stream); + return Task.FromResult(RenderResult.Png(stream.ToArray(), width, height)); + } + } + finally + { + form.Close(); + ((Control)control).Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs new file mode 100644 index 0000000..386d561 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Eto.DevExtension.PreviewHost +{ + /// + /// The Eto platform a preview is drawn with. Pick one with . + /// + /// + /// The name members are read before Eto is loaded, so only the methods taking or returning objects may touch Eto types. + /// + abstract class PreviewPlatform + { + /// Name passed with --platform. + public abstract string Name { get; } + + /// Eto platform assembly, e.g. Eto.Wpf. + public abstract string AssemblyName { get; } + + /// NuGet package id holding . + public abstract string PackageId { get; } + + /// Assemblies that ship beside in its package and must match it. + public virtual IEnumerable CompanionNames => Enumerable.Empty(); + + /// Other libraries the platform needs, taken from the project when it has them. + public virtual IEnumerable SharedNames => Enumerable.Empty(); + + /// Package lib folders to look in, best first. + public abstract IEnumerable GetFrameworks(IEnumerable available); + + /// Creates the Eto.Platform. + public abstract object CreatePlatform(); + + /// Called once the application is running. + public virtual void Initialized() { } + + /// Draws the control to a PNG. is an Eto.Forms.Control, and is disposed after. + public abstract Task CaptureAsync(object control, RenderRequest request); + + // newest first, but never newer than the runtime we're on + protected static IEnumerable Majors() + { + for (var major = Environment.Version.Major; major >= 5; major--) + yield return major; + } + + public static PreviewPlatform Get(string name) + { + if (string.IsNullOrEmpty(name)) + name = Default; + switch (name.ToLowerInvariant()) + { +#if WINDOWS + case "wpf": + return new WpfPreviewPlatform(); +#elif MACOS + case "macos": + return new MacPreviewPlatform(); +#else + case "gtk": + return new GtkPreviewPlatform(); + case "mac64": + return new MacPreviewPlatform(); +#endif + default: + throw new NotSupportedException($"The {name} platform isn't supported by this preview host"); + } + } + +#if WINDOWS + static string Default => "Wpf"; +#elif MACOS + static string Default => "macOS"; +#else + static string Default => OperatingSystem.IsMacOS() ? "Mac64" : "Gtk"; +#endif + } +} diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs new file mode 100644 index 0000000..e45a4b8 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs @@ -0,0 +1,74 @@ +#if WINDOWS +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class WpfPreviewPlatform : PreviewPlatform + { + public override string Name => "Wpf"; + public override string AssemblyName => "Eto.Wpf"; + public override string PackageId => "eto.platform.wpf"; + + public override IEnumerable GetFrameworks(IEnumerable available) + { + foreach (var major in Majors()) + { + yield return $"net{major}.0-windows7.0"; + yield return $"net{major}.0-windows"; + } + yield return "netcoreapp3.1"; + } + + public override object CreatePlatform() => new Eto.Wpf.Platform(); + + // the offscreen forms come and go, and must not end the process + public override void Initialized() => System.Windows.Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + public override async Task CaptureAsync(object control, RenderRequest request) + { + var form = OffscreenForm.Create((Control)control, request, out var content); + try + { + form.Show(); + await Dispatcher.Yield(DispatcherPriority.ContextIdle); + + var element = content.ToNative(); + element.UpdateLayout(); + var width = (int)Math.Ceiling(element.ActualWidth); + var height = (int)Math.Ceiling(element.ActualHeight); + if (width <= 0 || height <= 0) + return RenderResult.Empty(width, height); + + var scale = request.Scale > 0 ? request.Scale : 1; + var bitmap = new RenderTargetBitmap((int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32); + // through a brush, since rendering the element directly includes its offset in the window + var visual = new DrawingVisual(); + using (var context = visual.RenderOpen()) + context.DrawRectangle(new VisualBrush(element), null, new Rect(0, 0, width, height)); + bitmap.Render(visual); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using (var stream = new MemoryStream()) + { + encoder.Save(stream); + return RenderResult.Png(stream.ToArray(), width, height); + } + } + finally + { + form.Close(); + ((Control)control).Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs index b8756f0..526579a 100644 --- a/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs +++ b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs @@ -1,10 +1,6 @@ using System; using System.IO; using System.Threading.Tasks; -using System.Windows; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Threading; using Eto.Designer; using Eto.Forms; @@ -26,6 +22,13 @@ class RenderResult public int Height { get; set; } public RenderError Error { get; set; } public bool? RestartRequired { get; set; } + + public static RenderResult Png(byte[] png, int width, int height) => + new RenderResult { Image = Convert.ToBase64String(png), Width = width, Height = height }; + + /// For a control with nothing to draw. + public static RenderResult Empty(int width, int height) => + new RenderResult { Width = Math.Max(width, 0), Height = Math.Max(height, 0) }; } class RenderError @@ -37,10 +40,13 @@ class RenderError /// Builds a designer file into a control and draws it to a PNG. Use from the UI thread only. class PreviewRenderer { + readonly PreviewPlatform platform; string builderFile; IInterfaceBuilder builder; IBuildToken token; + public PreviewRenderer(PreviewPlatform platform) => this.platform = platform; + public Task RenderAsync(RenderRequest request) { var completion = new TaskCompletionSource(); @@ -59,7 +65,7 @@ public Task RenderAsync(RenderRequest request) request.Text, ProjectAssemblies.MainAssembly, ProjectAssemblies.Paths, - control => CaptureAsync(control, request).ContinueWith(r => completion.TrySetResult(r.IsFaulted ? Error(r.Exception) : r.Result)), + control => Capture(control, request).ContinueWith(r => completion.TrySetResult(r.IsFaulted ? Error(r.Exception) : r.Result)), ex => completion.TrySetResult(Error(ex))); } catch (Exception ex) @@ -69,54 +75,15 @@ public Task RenderAsync(RenderRequest request) return completion.Task; } - static async Task CaptureAsync(Control control, RenderRequest request) + Task Capture(Control control, RenderRequest request) { - var content = DesignPanel.GetContent(control); - if (request.Width != null || request.Height != null) - content.Size = new Eto.Drawing.Size(request.Width ?? -1, request.Height ?? -1); - - // shown for real, but out of sight, so load events fire and templates apply like in the app - var form = new Form - { - Content = content, - WindowStyle = Eto.Forms.WindowStyle.None, - ShowInTaskbar = false, - ShowActivated = false, - Resizable = false, - Location = new Eto.Drawing.Point(-32000, -32000) - }; try { - form.Show(); - await Dispatcher.Yield(DispatcherPriority.ContextIdle); - - var element = content.ToNative(); - element.UpdateLayout(); - var width = (int)Math.Ceiling(element.ActualWidth); - var height = (int)Math.Ceiling(element.ActualHeight); - if (width <= 0 || height <= 0) - return new RenderResult { Width = Math.Max(width, 0), Height = Math.Max(height, 0) }; - - var scale = request.Scale > 0 ? request.Scale : 1; - var bitmap = new RenderTargetBitmap((int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32); - // through a brush, since rendering the element directly includes its offset in the window - var visual = new DrawingVisual(); - using (var context = visual.RenderOpen()) - context.DrawRectangle(new VisualBrush(element), null, new Rect(0, 0, width, height)); - bitmap.Render(visual); - - var encoder = new PngBitmapEncoder(); - encoder.Frames.Add(BitmapFrame.Create(bitmap)); - using (var stream = new MemoryStream()) - { - encoder.Save(stream); - return new RenderResult { Image = Convert.ToBase64String(stream.ToArray()), Width = width, Height = height }; - } + return platform.CaptureAsync(control, request); } - finally + catch (Exception ex) { - form.Close(); - control.Dispose(); + return Task.FromException(ex); } } @@ -127,4 +94,25 @@ static RenderResult Error(Exception ex) return new RenderResult { Error = new RenderError { Message = root.Message, Details = ex.ToString() } }; } } + + static class OffscreenForm + { + /// A form out of sight holding the control, shown for real so load events fire and templates apply like in the app. + public static Form Create(Control control, RenderRequest request, out Control content) + { + content = DesignPanel.GetContent(control); + if (request.Width != null || request.Height != null) + content.Size = new Eto.Drawing.Size(request.Width ?? -1, request.Height ?? -1); + + return new Form + { + Content = content, + WindowStyle = WindowStyle.None, + ShowInTaskbar = false, + ShowActivated = false, + Resizable = false, + Location = new Eto.Drawing.Point(-32000, -32000) + }; + } + } } diff --git a/src/Eto.DevExtension.PreviewHost/PreviewServer.cs b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs index 45d5669..8ea4ed3 100644 --- a/src/Eto.DevExtension.PreviewHost/PreviewServer.cs +++ b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs @@ -17,15 +17,17 @@ namespace Eto.DevExtension.PreviewHost class PreviewServer { readonly LspConnection connection; + readonly PreviewPlatform platform; readonly ManualResetEventSlim startUI = new ManualResetEventSlim(); readonly TaskCompletionSource uiReady = new TaskCompletionSource(); Dictionary theme = new Dictionary(); bool configured; object renderer; - public PreviewServer(LspConnection connection) + public PreviewServer(LspConnection connection, PreviewPlatform platform) { this.connection = connection; + this.platform = platform; connection.OnRequest("initialize", Initialize); connection.OnRequest("shutdown", _ => null); connection.OnNotification("initialized", _ => { }); @@ -55,7 +57,7 @@ object Render(JsonNode parameters) if (!configured) { - ProjectAssemblies.Configure(assemblies, request.FileName, connection.Log); + ProjectAssemblies.Configure(platform, assemblies, request.FileName, connection.Log); configured = true; startUI.Set(); uiReady.Task.Wait(); @@ -66,7 +68,7 @@ object Render(JsonNode parameters) return RenderOnUI(request); } - /// Runs the UI on the calling thread, which must be the process's main STA thread. + /// Runs the UI on the calling thread, which must be the process's main thread. public void RunUI() { startUI.Wait(); @@ -77,18 +79,17 @@ public void RunUI() [MethodImpl(MethodImplOptions.NoInlining)] void StartEto() { - var platform = new Eto.Wpf.Platform(); - platform.Add(() => new HostTheme(theme)); + var etoPlatform = (Eto.Platform)platform.CreatePlatform(); + etoPlatform.Add(() => new HostTheme(theme)); Eto.Designer.Builders.BaseCompiledInterfaceBuilder.EtoAssemblyPath = ProjectAssemblies.EtoFile; - var app = new Eto.Forms.Application(platform); + var app = new Eto.Forms.Application(etoPlatform); app.Initialized += (sender, e) => { - // the offscreen forms come and go, and must not end the process - System.Windows.Application.Current.ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown; + platform.Initialized(); ProjectAssemblies.LoadProject(); ProjectAssemblies.WatchForChanges(Restart); - renderer = new PreviewRenderer(); + renderer = new PreviewRenderer(platform); uiReady.TrySetResult(true); }; app.UnhandledException += (sender, e) => connection.Log($"Unhandled exception: {e.ExceptionObject}"); diff --git a/src/Eto.DevExtension.PreviewHost/Program.cs b/src/Eto.DevExtension.PreviewHost/Program.cs index a3531f7..9caae27 100644 --- a/src/Eto.DevExtension.PreviewHost/Program.cs +++ b/src/Eto.DevExtension.PreviewHost/Program.cs @@ -7,15 +7,19 @@ namespace Eto.DevExtension.PreviewHost { static class Program { + /// Pass --platform Wpf|Gtk|Mac64|macOS to pick the Eto platform, otherwise the OS's default is used. [STAThread] static int Main(string[] args) { + var index = Array.FindIndex(args, r => r == "--platform"); + var platform = PreviewPlatform.Get(index >= 0 && index + 1 < args.Length ? args[index + 1] : null); + var connection = new LspConnection(Console.OpenStandardInput(), Console.OpenStandardOutput()); // stdout is the protocol channel Console.SetOut(TextWriter.Null); - var server = new PreviewServer(connection); + var server = new PreviewServer(connection, platform); var reader = new Thread(() => { try diff --git a/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs b/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs index 341b80f..459343a 100644 --- a/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs +++ b/src/Eto.DevExtension.PreviewHost/ProjectAssemblies.cs @@ -12,7 +12,7 @@ namespace Eto.DevExtension.PreviewHost { /// - /// Resolves the project's assemblies and a matching Eto + Eto.Wpf for this process. + /// Resolves the project's assemblies and a matching Eto + Eto platform for this process. /// Call once, before anything touches an Eto type. /// /// @@ -21,7 +21,7 @@ namespace Eto.DevExtension.PreviewHost static class ProjectAssemblies { // Eto and the libraries it shares with the project, which must all come from the same place - static readonly string[] EtoNames = { "Eto", "Eto.Wpf", "Eto.Serialization.Xaml", "Eto.Serialization.Json" }; + static readonly string[] SerializationNames = { "Eto.Serialization.Xaml", "Eto.Serialization.Json" }; static readonly string[] SharedNames = { "Portable.Xaml", "Newtonsoft.Json" }; static readonly Dictionary namedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -33,8 +33,14 @@ static class ProjectAssemblies static readonly object loadLock = new object(); static AssemblyDependencyResolver dependencyResolver; static Action log; + static PreviewPlatform platform; - static string FallbackDirectory => Path.Combine(AppContext.BaseDirectory, "eto"); +#if MACOS + // Contents/Resources/eto in the app bundle, beside Contents/MonoBundle + static readonly string FallbackDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Resources", "eto")); +#else + static readonly string FallbackDirectory = Path.Combine(AppContext.BaseDirectory, "eto"); +#endif /// Project assembly files, its own first. public static IReadOnlyList Paths { get; private set; } = Array.Empty(); @@ -49,8 +55,9 @@ static class ProjectAssemblies public static string GetKey(IEnumerable paths) => string.Join("|", paths ?? Enumerable.Empty()); - public static void Configure(IList paths, string documentPath, Action logger) + public static void Configure(PreviewPlatform previewPlatform, IList paths, string documentPath, Action logger) { + platform = previewPlatform; log = logger; Paths = (paths ?? new List()).Where(File.Exists).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); Key = GetKey(paths); @@ -124,28 +131,37 @@ static void ChooseEto(string documentPath) ?? EtoAssemblyLocator.Find(documentPath, log); var eto = etoDirectory != null ? Path.Combine(etoDirectory, "Eto.dll") : null; var version = eto != null && File.Exists(eto) ? GetPackageVersion(eto) : null; - var wpf = version != null ? FindEtoWpf(version, etoDirectory) : null; + var platformFile = version != null ? FindPlatform(version, etoDirectory) : null; - if (wpf == null) + if (platformFile == null) { if (eto != null) - log?.Invoke($"No Eto.Wpf {version} found for {eto}, so using Eto bundled with the extension."); - foreach (var name in EtoNames.Concat(SharedNames)) + log?.Invoke($"No {platform.AssemblyName} {version} found for {eto}, so using Eto bundled with the extension."); + foreach (var name in new[] { "Eto", platform.AssemblyName }.Concat(platform.CompanionNames).Concat(SerializationNames).Concat(SharedNames).Concat(platform.SharedNames)) SetFallback(name); return; } - namedFiles["Eto"] = eto; - namedFiles["Eto.Wpf"] = wpf; - foreach (var name in EtoNames.Skip(2)) + // the located copy may be built for a newer runtime than this one + namedFiles["Eto"] = FindPackage("Eto", version, RuntimeFrameworks(), "eto.forms") ?? eto; + namedFiles[platform.AssemblyName] = platformFile; + foreach (var name in platform.CompanionNames) { - var file = FindBeside(name, etoDirectory) ?? FindPackage(name, version, new[] { "netstandard2.0" }); + var file = FindBeside(name, Path.GetDirectoryName(platformFile)); if (file != null) namedFiles[name] = file; else SetFallback(name); } - foreach (var name in SharedNames) + foreach (var name in SerializationNames) + { + var file = FindBeside(name, etoDirectory) ?? FindPackage(name, version, RuntimeFrameworks()); + if (file != null) + namedFiles[name] = file; + else + SetFallback(name); + } + foreach (var name in SharedNames.Concat(platform.SharedNames)) { var file = probeDirectories.Select(r => FindBeside(name, r)).FirstOrDefault(r => r != null); if (file != null) @@ -153,7 +169,7 @@ static void ChooseEto(string documentPath) else SetFallback(name); } - log?.Invoke($"Using Eto {version} from {eto} with {wpf}"); + log?.Invoke($"Using Eto {version} from {namedFiles["Eto"]} with {platformFile}"); } static void SetFallback(string name) @@ -171,29 +187,31 @@ static string FindBeside(string name, string directory) return File.Exists(file) ? file : null; } - /// Eto.Wpf of the same version as Eto, preferring a .NET Core build from the package over one next to the project. - static string FindEtoWpf(string version, string etoDirectory) + /// Eto platform of the same version as Eto, preferring a build for this runtime from the package over one next to the project. + static string FindPlatform(string version, string etoDirectory) { - var fromPackage = FindPackage("Eto.Wpf", version, GetWindowsFrameworks(), "eto.platform.wpf"); + var name = platform.AssemblyName; + var fromPackage = FindPackage(name, version, platform.GetFrameworks, platform.PackageId); if (fromPackage != null) return fromPackage; - var beside = FindBeside("Eto.Wpf", etoDirectory) ?? probeDirectories.Select(r => FindBeside("Eto.Wpf", r)).FirstOrDefault(r => r != null); + var beside = FindBeside(name, etoDirectory) ?? probeDirectories.Select(r => FindBeside(name, r)).FirstOrDefault(r => r != null); return beside != null && GetPackageVersion(beside) == version ? beside : null; } // newest first, but never newer than the runtime we're on - static IEnumerable GetWindowsFrameworks() + public static IEnumerable RuntimeFrameworks() { for (var major = Environment.Version.Major; major >= 5; major--) - { - yield return $"net{major}.0-windows7.0"; - yield return $"net{major}.0-windows"; - } - yield return "netcoreapp3.1"; + yield return $"net{major}.0"; + yield return "netstandard2.0"; } - static string FindPackage(string name, string version, IEnumerable frameworks, string packageId = null) + static string FindPackage(string name, string version, IEnumerable frameworks, string packageId = null) => + FindPackage(name, version, _ => frameworks, packageId); + + /// Given the lib folders in the package, returns the ones to use, best first. + static string FindPackage(string name, string version, Func, IEnumerable> getFrameworks, string packageId = null) { packageId = (packageId ?? name).ToLowerInvariant(); foreach (var folder in GetPackageFolders()) @@ -201,7 +219,8 @@ static string FindPackage(string name, string version, IEnumerable frame var lib = Path.Combine(folder, packageId, version.ToLowerInvariant(), "lib"); if (!Directory.Exists(lib)) continue; - foreach (var framework in frameworks) + var available = Directory.EnumerateDirectories(lib).Select(Path.GetFileName).ToList(); + foreach (var framework in getFrameworks(available)) { var file = Path.Combine(lib, framework, name + ".dll"); if (File.Exists(file)) diff --git a/src/Eto.DevExtension.VSCode/.vscodeignore b/src/Eto.DevExtension.VSCode/.vscodeignore index 04aef9a..1333960 100644 --- a/src/Eto.DevExtension.VSCode/.vscodeignore +++ b/src/Eto.DevExtension.VSCode/.vscodeignore @@ -6,3 +6,4 @@ tsconfig.json .gitignore server/**/*.pdb preview/**/*.pdb +scripts/** diff --git a/src/Eto.DevExtension.VSCode/README.md b/src/Eto.DevExtension.VSCode/README.md index dc2bc2f..a8debed 100644 --- a/src/Eto.DevExtension.VSCode/README.md +++ b/src/Eto.DevExtension.VSCode/README.md @@ -4,7 +4,7 @@ Autocompletion, hover documentation and a live preview for [Eto.Forms](https://g ## Features -- A live preview of `.xeto`, `.jeto` and `.eto.cs` files beside the editor (Windows only). Run +- A live preview of `.xeto`, `.jeto` and `.eto.cs` files beside the editor. Run **Eto: Open Preview to the Side**, or use the preview button in the editor title bar. Your own controls show up once the project is built, and the preview redraws after each build. Drag the corner handle to try the form at other sizes; click the size label to go back to its own size @@ -24,10 +24,11 @@ the copy bundled with the extension is used. ## Requirements The [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer, used to run the language server. -The preview also needs the .NET Desktop Runtime 8 or newer. +On Windows the preview also needs the .NET Desktop Runtime 8 or newer, and on Linux it needs GTK 3. -The preview runs your project's code in a separate process, drawing with the Eto.Forms and Eto.Wpf -versions the project uses. +The preview runs your project's code in a separate process, drawing with the Eto.Forms version the +project uses and the platform for your OS: Eto.Wpf on Windows, Eto.Gtk on Linux, and on macOS +Eto.macOS when a project in the solution references `Eto.Platform.macOS`, otherwise Eto.Mac64. ## Settings @@ -35,7 +36,8 @@ versions the project uses. | --- | --- | | `eto.dotnetPath` | Path to the `dotnet` executable. Uses `dotnet` from `PATH` when empty. | | `eto.languageServer.path` | Path to `Eto.DevExtension.LanguageServer.dll`. Uses the bundled copy when empty. | -| `eto.previewHost.path` | Path to `Eto.DevExtension.PreviewHost.dll`. Uses the bundled copy when empty. | +| `eto.previewHost.path` | Path to `Eto.DevExtension.PreviewHost.dll`. Uses the bundled copy when empty. Not used for Eto.macOS previews. | +| `eto.preview.macPlatform` | `auto`, `macOS` or `Mac64`: the platform used for previews on macOS. | | `eto.etoAssemblyPath` | Folder containing the `Eto.dll` to complete against. Detected from the project when empty. | | `eto.trace.server` | Logs the traffic between VS Code and the language server. | @@ -46,9 +48,13 @@ a different project, run **Eto: Restart Language Server**. ```sh npm install -npm run build:server # publishes the .NET language server into ./server and the preview host into ./preview +npm run build:server # publishes the .NET language server into ./server and the preview hosts into ./preview npm run compile ``` +The Eto.macOS preview host is an app bundle, so it's only built when packaging on a Mac with the .NET +macOS workload. Without it, Mac previews use Eto.Mac64. If the workload rejects your Xcode version, pass +`npm run build:server -- -p:ValidateXcodeVersion=false`. + Press F5 to launch an extension development host. `npm run package` produces a `.vsix` in `artifacts/vscode`. diff --git a/src/Eto.DevExtension.VSCode/package.json b/src/Eto.DevExtension.VSCode/package.json index 041adcf..e85a5a6 100644 --- a/src/Eto.DevExtension.VSCode/package.json +++ b/src/Eto.DevExtension.VSCode/package.json @@ -79,7 +79,22 @@ "eto.previewHost.path": { "type": "string", "default": "", - "description": "Path to Eto.DevExtension.PreviewHost.dll. Uses the copy bundled with the extension when empty." + "description": "Path to Eto.DevExtension.PreviewHost.dll. Uses the copy bundled with the extension when empty. Not used for Eto.macOS previews." + }, + "eto.preview.macPlatform": { + "type": "string", + "enum": [ + "auto", + "macOS", + "Mac64" + ], + "enumDescriptions": [ + "Eto.macOS when a project in the solution references Eto.Platform.macOS, otherwise Eto.Mac64.", + "Always preview with Eto.macOS.", + "Always preview with Eto.Mac64." + ], + "default": "auto", + "description": "The Eto platform used for previews on macOS." }, "eto.etoAssemblyPath": { "type": "string", @@ -114,20 +129,20 @@ { "command": "eto.openPreview", "group": "navigation", - "when": "isWindows && resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" + "when": "resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" } ], "commandPalette": [ { "command": "eto.openPreview", - "when": "isWindows && resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" + "when": "resourceFilename =~ /\\.(xeto|jeto|eto\\.cs|eto\\.vb)$/i" } ] } }, "scripts": { "vscode:prepublish": "npm run build:server && npm run compile", - "build:server": "dotnet publish ../Eto.DevExtension.LanguageServer/Eto.DevExtension.LanguageServer.csproj -c Release -o server && dotnet publish ../Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj -c Release -o preview", + "build:server": "node scripts/build-server.js", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "package": "node -e \"require('fs').mkdirSync('../../artifacts/vscode',{recursive:true})\" && vsce package --out ../../artifacts/vscode/" diff --git a/src/Eto.DevExtension.VSCode/scripts/build-server.js b/src/Eto.DevExtension.VSCode/scripts/build-server.js new file mode 100644 index 0000000..b22973c --- /dev/null +++ b/src/Eto.DevExtension.VSCode/scripts/build-server.js @@ -0,0 +1,36 @@ +// Publishes the language server to server/ and the preview hosts to preview/ for packaging. +// Extra arguments go to the Eto.macOS host build, e.g. `npm run build:server -- -p:ValidateXcodeVersion=false`. +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const src = path.resolve(root, '..'); +const artifacts = path.resolve(src, '..', 'artifacts'); +const host = path.join(src, 'Eto.DevExtension.PreviewHost', 'Eto.DevExtension.PreviewHost.csproj'); +const preview = path.join(root, 'preview'); + +function dotnet(...args) { + execFileSync('dotnet', args, { stdio: 'inherit', cwd: root }); +} + +fs.rmSync(preview, { recursive: true, force: true }); + +dotnet('publish', path.join(src, 'Eto.DevExtension.LanguageServer', 'Eto.DevExtension.LanguageServer.csproj'), '-c', 'Release', '-o', path.join(root, 'server')); +dotnet('publish', host, '-c', 'Release', '-f', 'net8.0-windows', '-o', path.join(preview, 'win')); +dotnet('publish', host, '-c', 'Release', '-f', 'net8.0', '-o', path.join(preview, 'net')); + +if (process.platform === 'darwin') { + try { + dotnet('build', path.join(src, 'Eto.DevExtension.PreviewHost.macOS', 'Eto.DevExtension.PreviewHost.macOS.csproj'), '-c', 'Release', ...process.argv.slice(2)); + const app = 'Eto Preview Host.app'; + fs.mkdirSync(path.join(preview, 'macos'), { recursive: true }); + // ditto keeps the bundle's signature intact + execFileSync('ditto', [path.join(artifacts, 'Eto.DevExtension.PreviewHost.macOS', 'Release', 'net10.0-macos', app), path.join(preview, 'macos', app)], { stdio: 'inherit' }); + } catch { + // usually a missing macOS workload or mismatched Xcode, which shouldn't stop the rest from packaging + console.warn('Could not build the Eto.macOS preview host, so Mac previews will use Eto.Mac64.'); + } +} else { + console.log('Skipping the Eto.macOS preview host, which only builds on macOS. Mac previews will use Eto.Mac64.'); +} diff --git a/src/Eto.DevExtension.VSCode/src/extension.ts b/src/Eto.DevExtension.VSCode/src/extension.ts index 998a3c2..f7d8077 100644 --- a/src/Eto.DevExtension.VSCode/src/extension.ts +++ b/src/Eto.DevExtension.VSCode/src/extension.ts @@ -7,11 +7,11 @@ import { ServerOptions, TransportKind } from 'vscode-languageclient/node'; +import { HostLauncher } from './hostLaunch'; import { isPreviewable, PreviewPanel } from './preview'; import { PreviewHost } from './previewHost'; const SERVER_DLL = 'Eto.DevExtension.LanguageServer.dll'; -const PREVIEW_HOST_DLL = 'Eto.DevExtension.PreviewHost.dll'; let client: LanguageClient | undefined; let output: vscode.OutputChannel; @@ -109,14 +109,8 @@ async function openPreview(context: vscode.ExtensionContext, uri?: vscode.Uri): } if (!previewHost) { - const config = vscode.workspace.getConfiguration('eto'); - const hostPath = resolvePath(context, config.get('previewHost.path'), 'preview', PREVIEW_HOST_DLL, - path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0-windows')); - if (!hostPath) { - vscode.window.showErrorMessage(`Could not find ${PREVIEW_HOST_DLL}. Set "eto.previewHost.path" to point at it.`); - return; - } - previewHost = new PreviewHost(config.get('dotnetPath')?.trim() || 'dotnet', hostPath, output); + const launcher = new HostLauncher(context.extensionPath, output); + previewHost = new PreviewHost(fileName => launcher.resolve(fileName), output); } PreviewPanel.show(previewHost, document); } diff --git a/src/Eto.DevExtension.VSCode/src/hostLaunch.ts b/src/Eto.DevExtension.VSCode/src/hostLaunch.ts new file mode 100644 index 0000000..08c260b --- /dev/null +++ b/src/Eto.DevExtension.VSCode/src/hostLaunch.ts @@ -0,0 +1,159 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const HOST_DLL = 'Eto.DevExtension.PreviewHost.dll'; +const MAC_APP = 'Eto Preview Host.app'; +const MAC_EXECUTABLE = path.join(MAC_APP, 'Contents', 'MacOS', 'Eto.DevExtension.PreviewHost'); + +// scanning projects on every keystroke is too slow, but an edited project should still be noticed +const CACHE_MS = 10000; + +type MacPlatform = 'macOS' | 'Mac64'; + +/** How to start a preview host process. */ +export interface HostLaunch { + command: string; + args: string[]; + /** Shown when the process can't be started. */ + requirement: string; +} + +/** + * Picks the preview host for a document: Wpf on Windows, Gtk on Linux, and on macOS either the + * Eto.macOS app bundle or Mac64, depending on what the solution uses. + */ +export class HostLauncher { + private readonly macCache = new Map(); + private lastMessage: string | undefined; + + constructor(private readonly extensionPath: string, private readonly output: vscode.OutputChannel) { } + + async resolve(fileName: string): Promise { + const config = vscode.workspace.getConfiguration('eto'); + const dotnet = config.get('dotnetPath')?.trim() || 'dotnet'; + const configured = config.get('previewHost.path')?.trim(); + + if (process.platform === 'win32') { + const dll = this.find(configured, path.join('preview', 'win', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0-windows', HOST_DLL)); + return withDotnet(dotnet, dll, [], 'The preview needs the .NET 8 Desktop Runtime (or newer).'); + } + + const dll = this.find(configured, path.join('preview', 'net', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0', HOST_DLL)); + if (process.platform !== 'darwin') { + return withDotnet(dotnet, dll, ['--platform', 'Gtk'], 'The preview needs the .NET 8 runtime (or newer) and GTK 3.'); + } + + if (await this.getMacPlatform(fileName) === 'macOS') { + const app = this.find(undefined, path.join('preview', 'macos', MAC_EXECUTABLE), path.join('Eto.DevExtension.PreviewHost.macOS', 'Debug', 'net10.0-macos', MAC_EXECUTABLE)); + if (app) { + return { command: app, args: [], requirement: 'The Eto.macOS preview host could not be started.' }; + } + this.log('This copy of the extension has no Eto.macOS preview host, so previewing with Mac64 instead.'); + } + return withDotnet(dotnet, dll, ['--platform', 'Mac64'], 'The preview needs the .NET 8 runtime (or newer).'); + } + + /** + * @param bundled Where the host is in the packaged extension. + * @param build Where a debug build puts it under artifacts, for running out of the source tree. + */ + private find(configured: string | undefined, bundled: string, build: string): string | undefined { + const candidates = [ + configured, + path.join(this.extensionPath, bundled), + path.join(this.extensionPath, '..', '..', 'artifacts', build) + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const resolved = path.isAbsolute(candidate) ? candidate : path.join(this.extensionPath, candidate); + if (fs.existsSync(resolved)) { + return resolved; + } + } + return undefined; + } + + private async getMacPlatform(fileName: string): Promise { + const configured = vscode.workspace.getConfiguration('eto').get('preview.macPlatform'); + if (configured === 'macOS' || configured === 'Mac64') { + return configured; + } + + const key = path.dirname(fileName); + const cached = this.macCache.get(key); + if (cached && Date.now() - cached.time < CACHE_MS) { + return cached.platform; + } + + const projects = findSolutionProjects(fileName) ?? await findWorkspaceProjects(); + let platform: MacPlatform = 'Mac64'; + let reason = 'no project in the solution references Eto.Platform.macOS'; + for (const project of projects) { + const text = readText(project); + if (/Eto\.Platform\.macOS\b|Eto\.macOS\.csproj/i.test(text)) { + platform = 'macOS'; + reason = `${path.basename(project)} references it`; + break; + } + } + this.macCache.set(key, { time: Date.now(), platform }); + this.log(`Previewing with ${platform === 'macOS' ? 'Eto.macOS' : 'Eto.Mac64'}, as ${reason}. Set "eto.preview.macPlatform" to choose.`); + return platform; + } + + private log(message: string): void { + if (message !== this.lastMessage) { + this.lastMessage = message; + this.output.appendLine(message); + } + } +} + +function withDotnet(dotnet: string, dll: string | undefined, args: string[], requirement: string): HostLaunch | undefined { + return dll ? { command: dotnet, args: [dll, ...args], requirement } : undefined; +} + +/** Projects in the nearest solution above the file, or undefined when there's none. */ +function findSolutionProjects(fileName: string): string[] | undefined { + let dir = path.dirname(fileName); + for (; ;) { + const solution = readDir(dir).find(r => /\.slnx?$/i.test(r)); + if (solution) { + const file = path.join(dir, solution); + const pattern = solution.toLowerCase().endsWith('.slnx') + ? / path.resolve(dir, r[1].replace(/\\/g, path.sep))); + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +async function findWorkspaceProjects(): Promise { + const files = await vscode.workspace.findFiles('**/*.{csproj,vbproj,fsproj}', '**/{bin,obj,node_modules}/**', 500); + return files.map(r => r.fsPath); +} + +function readDir(dir: string): string[] { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +function readText(file: string): string { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return ''; + } +} diff --git a/src/Eto.DevExtension.VSCode/src/previewHost.ts b/src/Eto.DevExtension.VSCode/src/previewHost.ts index 2f53789..8ecb37d 100644 --- a/src/Eto.DevExtension.VSCode/src/previewHost.ts +++ b/src/Eto.DevExtension.VSCode/src/previewHost.ts @@ -6,6 +6,7 @@ import { StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'; +import { HostLaunch } from './hostLaunch'; // long enough for a first render that compiles code, short enough to recover from a hung control const RENDER_TIMEOUT_MS = 30000; @@ -34,15 +35,16 @@ export class PreviewHost implements vscode.Disposable { private connection: MessageConnection | undefined; private starting: Promise | undefined; private queue: Promise = Promise.resolve(); - private unavailable = false; + private launchKey: string | undefined; + // hosts that couldn't start, usually for want of a runtime, so they aren't tried again + private readonly unavailable = new Set(); private readonly restarted = new vscode.EventEmitter(); /** Fires after the project is rebuilt, so previews can be redrawn. */ readonly onDidRequestRedraw = this.restarted.event; constructor( - private readonly dotnet: string, - private readonly hostPath: string, + private readonly resolveLaunch: (fileName: string) => Promise, private readonly output: vscode.OutputChannel ) { } @@ -60,9 +62,13 @@ export class PreviewHost implements vscode.Disposable { private async renderNow(request: RenderRequest): Promise { // a second try covers a host that exited after a rebuild, or that serves another project for (let attempt = 0; attempt < 2; attempt++) { - const connection = await this.getConnection(); + const launch = await this.resolveLaunch(request.fileName); + if (!launch) { + return error('Could not find the Eto preview host. Set "eto.previewHost.path" to point at it.'); + } + const connection = await this.getConnection(launch); if (!connection) { - return error('The preview needs the .NET 8 Desktop Runtime (or newer). See the Eto.Forms Designer output for details.'); + return error(`${launch.requirement} See the Eto.Forms Designer output for details.`); } let timer: NodeJS.Timeout | undefined; @@ -93,21 +99,23 @@ export class PreviewHost implements vscode.Disposable { return error('The preview host could not load the project.'); } - private getConnection(): Promise { - if (this.connection && this.process && isRunning(this.process)) { + private getConnection(launch: HostLaunch): Promise { + const key = JSON.stringify([launch.command, ...launch.args]); + if (this.launchKey === key && this.connection && this.process && isRunning(this.process)) { return Promise.resolve(this.connection); } - if (this.unavailable) { + if (this.unavailable.has(key)) { return Promise.resolve(undefined); } this.stop(); - this.starting ??= this.start().finally(() => this.starting = undefined); + this.launchKey = key; + this.starting ??= this.start(launch, key).finally(() => this.starting = undefined); return this.starting; } - private async start(): Promise { + private async start(launch: HostLaunch, key: string): Promise { try { - const child = spawn(this.dotnet, [this.hostPath], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const child = spawn(launch.command, launch.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); const failed = new Promise((_, reject) => child.once('error', reject)); child.stderr?.on('data', data => this.output.append(`${data}`)); @@ -125,9 +133,8 @@ export class PreviewHost implements vscode.Disposable { connection.sendNotification('initialized', {}); return connection; } catch (e) { - // usually no desktop runtime to run it with, so don't keep trying - this.unavailable = true; - this.output.appendLine(`Could not start the Eto preview host using "${this.dotnet}": ${e}`); + this.unavailable.add(key); + this.output.appendLine(`Could not start the Eto preview host using "${launch.command}": ${e}`); this.stop(); return undefined; } @@ -138,6 +145,7 @@ export class PreviewHost implements vscode.Disposable { const child = this.process; this.connection = undefined; this.process = undefined; + this.launchKey = undefined; connection?.dispose(); if (child && isRunning(child)) { child.kill(); diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj index ab613cd..0ed6605 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj +++ b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj @@ -286,7 +286,7 @@ $(ArtifactsDir)Eto.DevExtension.PreviewHost\publish\$(Configuration)\ - + From bc3cee3de4d11503f4f1cc335ebb6ab05ec6a65d Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Wed, 23 Sep 2026 10:15:05 -0700 Subject: [PATCH 3/5] Add platform switcher to preview --- .../Eto.DevExtension.PreviewHost.csproj | 8 +- .../Platforms/PreviewPlatform.cs | 2 + .../Platforms/WinFormsPreviewPlatform.cs | 60 +++++++ .../Platforms/WpfPreviewPlatform.cs | 2 +- src/Eto.DevExtension.VSCode/README.md | 7 +- src/Eto.DevExtension.VSCode/package.json | 15 -- src/Eto.DevExtension.VSCode/src/extension.ts | 17 +- src/Eto.DevExtension.VSCode/src/hostLaunch.ts | 151 ++++++++++++------ src/Eto.DevExtension.VSCode/src/preview.ts | 51 +++++- .../src/previewHost.ts | 6 +- 10 files changed, 237 insertions(+), 82 deletions(-) create mode 100644 src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs diff --git a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj index ea5af21..fa7a46d 100644 --- a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj +++ b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj @@ -1,7 +1,7 @@ Exe - + net8.0-windows;net8.0 disable disable @@ -17,6 +17,7 @@ true + true @@ -44,10 +45,12 @@ + - + + @@ -74,6 +77,7 @@ + diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs index 386d561..e1a29f5 100644 --- a/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs +++ b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs @@ -56,6 +56,8 @@ public static PreviewPlatform Get(string name) #if WINDOWS case "wpf": return new WpfPreviewPlatform(); + case "winforms": + return new WinFormsPreviewPlatform(); #elif MACOS case "macos": return new MacPreviewPlatform(); diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs new file mode 100644 index 0000000..0754835 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/Platforms/WinFormsPreviewPlatform.cs @@ -0,0 +1,60 @@ +#if WINDOWS +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Eto.Forms; + +namespace Eto.DevExtension.PreviewHost +{ + class WinFormsPreviewPlatform : PreviewPlatform + { + public override string Name => "WinForms"; + public override string AssemblyName => "Eto.WinForms"; + public override string PackageId => "eto.platform.windows"; + + public override IEnumerable GetFrameworks(IEnumerable available) + { + foreach (var major in Majors()) + { + yield return $"net{major}.0-windows7.0"; + yield return $"net{major}.0-windows"; + } + yield return "netcoreapp3.1"; + } + + public override object CreatePlatform() => new Eto.WinForms.Platform(); + + public override Task CaptureAsync(object control, RenderRequest request) + { + var form = OffscreenForm.Create((Control)control, request, out var content); + try + { + form.Show(); + System.Windows.Forms.Application.DoEvents(); + + var native = WinFormsHelpers.ToNative(content); + var width = native.Width; + var height = native.Height; + if (width <= 0 || height <= 0) + return Task.FromResult(RenderResult.Empty(width, height)); + + // WinForms can only draw at the screen's own scale, so report the size in logical pixels + var dpiScale = native.DeviceDpi / 96.0; + using (var bitmap = new System.Drawing.Bitmap(width, height)) + using (var stream = new MemoryStream()) + { + native.DrawToBitmap(bitmap, new System.Drawing.Rectangle(0, 0, width, height)); + bitmap.Save(stream, System.Drawing.Imaging.ImageFormat.Png); + return Task.FromResult(RenderResult.Png(stream.ToArray(), (int)Math.Round(width / dpiScale), (int)Math.Round(height / dpiScale))); + } + } + finally + { + form.Close(); + ((Control)control).Dispose(); + } + } + } +} +#endif diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs index e45a4b8..ecb6be8 100644 --- a/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs +++ b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs @@ -40,7 +40,7 @@ public override async Task CaptureAsync(object control, RenderRequ form.Show(); await Dispatcher.Yield(DispatcherPriority.ContextIdle); - var element = content.ToNative(); + var element = WpfHelpers.ToNative(content); element.UpdateLayout(); var width = (int)Math.Ceiling(element.ActualWidth); var height = (int)Math.Ceiling(element.ActualHeight); diff --git a/src/Eto.DevExtension.VSCode/README.md b/src/Eto.DevExtension.VSCode/README.md index a8debed..9bb7b91 100644 --- a/src/Eto.DevExtension.VSCode/README.md +++ b/src/Eto.DevExtension.VSCode/README.md @@ -27,8 +27,10 @@ The [.NET 8 runtime](https://dotnet.microsoft.com/download) or newer, used to ru On Windows the preview also needs the .NET Desktop Runtime 8 or newer, and on Linux it needs GTK 3. The preview runs your project's code in a separate process, drawing with the Eto.Forms version the -project uses and the platform for your OS: Eto.Wpf on Windows, Eto.Gtk on Linux, and on macOS -Eto.macOS when a project in the solution references `Eto.Platform.macOS`, otherwise Eto.Mac64. +project uses. Pick the platform to draw with from the drop down at the top of the preview: WPF or +WinForms on Windows, macOS or Mac64 on macOS, and Gtk anywhere GTK 3 is installed (on macOS, from +Homebrew or MacPorts). **Auto** uses the first of these that a project in the solution references, +otherwise WPF, macOS or Gtk. ## Settings @@ -37,7 +39,6 @@ Eto.macOS when a project in the solution references `Eto.Platform.macOS`, otherw | `eto.dotnetPath` | Path to the `dotnet` executable. Uses `dotnet` from `PATH` when empty. | | `eto.languageServer.path` | Path to `Eto.DevExtension.LanguageServer.dll`. Uses the bundled copy when empty. | | `eto.previewHost.path` | Path to `Eto.DevExtension.PreviewHost.dll`. Uses the bundled copy when empty. Not used for Eto.macOS previews. | -| `eto.preview.macPlatform` | `auto`, `macOS` or `Mac64`: the platform used for previews on macOS. | | `eto.etoAssemblyPath` | Folder containing the `Eto.dll` to complete against. Detected from the project when empty. | | `eto.trace.server` | Logs the traffic between VS Code and the language server. | diff --git a/src/Eto.DevExtension.VSCode/package.json b/src/Eto.DevExtension.VSCode/package.json index e85a5a6..5fd69a7 100644 --- a/src/Eto.DevExtension.VSCode/package.json +++ b/src/Eto.DevExtension.VSCode/package.json @@ -81,21 +81,6 @@ "default": "", "description": "Path to Eto.DevExtension.PreviewHost.dll. Uses the copy bundled with the extension when empty. Not used for Eto.macOS previews." }, - "eto.preview.macPlatform": { - "type": "string", - "enum": [ - "auto", - "macOS", - "Mac64" - ], - "enumDescriptions": [ - "Eto.macOS when a project in the solution references Eto.Platform.macOS, otherwise Eto.Mac64.", - "Always preview with Eto.macOS.", - "Always preview with Eto.Mac64." - ], - "default": "auto", - "description": "The Eto platform used for previews on macOS." - }, "eto.etoAssemblyPath": { "type": "string", "default": "", diff --git a/src/Eto.DevExtension.VSCode/src/extension.ts b/src/Eto.DevExtension.VSCode/src/extension.ts index f7d8077..d402929 100644 --- a/src/Eto.DevExtension.VSCode/src/extension.ts +++ b/src/Eto.DevExtension.VSCode/src/extension.ts @@ -7,15 +7,17 @@ import { ServerOptions, TransportKind } from 'vscode-languageclient/node'; -import { HostLauncher } from './hostLaunch'; -import { isPreviewable, PreviewPanel } from './preview'; +import { AUTO, HostLauncher } from './hostLaunch'; +import { isPreviewable, PlatformPicker, PreviewPanel } from './preview'; import { PreviewHost } from './previewHost'; const SERVER_DLL = 'Eto.DevExtension.LanguageServer.dll'; +const PLATFORM_KEY = 'eto.preview.platform'; let client: LanguageClient | undefined; let output: vscode.OutputChannel; let previewHost: PreviewHost | undefined; +let platformPicker: PlatformPicker | undefined; export async function activate(context: vscode.ExtensionContext): Promise { output = vscode.window.createOutputChannel('Eto.Forms Designer'); @@ -108,11 +110,16 @@ async function openPreview(context: vscode.ExtensionContext, uri?: vscode.Uri): return; } - if (!previewHost) { + if (!previewHost || !platformPicker) { const launcher = new HostLauncher(context.extensionPath, output); - previewHost = new PreviewHost(fileName => launcher.resolve(fileName), output); + const picker: PlatformPicker = platformPicker = { + getPlatforms: () => launcher.getPlatforms(), + get: () => context.workspaceState.get(PLATFORM_KEY, AUTO), + set: id => context.workspaceState.update(PLATFORM_KEY, id) + }; + previewHost = new PreviewHost(fileName => launcher.resolve(fileName, picker.get()), output); } - PreviewPanel.show(previewHost, document); + PreviewPanel.show(previewHost, platformPicker, document); } function resolveServerPath(context: vscode.ExtensionContext, configured?: string): string | undefined { diff --git a/src/Eto.DevExtension.VSCode/src/hostLaunch.ts b/src/Eto.DevExtension.VSCode/src/hostLaunch.ts index 08c260b..c20b20d 100644 --- a/src/Eto.DevExtension.VSCode/src/hostLaunch.ts +++ b/src/Eto.DevExtension.VSCode/src/hostLaunch.ts @@ -9,49 +9,113 @@ const MAC_EXECUTABLE = path.join(MAC_APP, 'Contents', 'MacOS', 'Eto.DevExtension // scanning projects on every keystroke is too slow, but an edited project should still be noticed const CACHE_MS = 10000; -type MacPlatform = 'macOS' | 'Mac64'; +export const AUTO = 'auto'; + +/** A platform the preview can draw with. */ +export interface PlatformOption { + id: string; + label: string; +} /** How to start a preview host process. */ export interface HostLaunch { command: string; args: string[]; + env?: NodeJS.ProcessEnv; + /** Label of the platform it draws with. */ + platform: string; /** Shown when the process can't be started. */ requirement: string; } +interface Platform extends PlatformOption { + /** Matches a project that uses this platform, from a package or project reference. */ + reference: RegExp; + launch(): HostLaunch | undefined; +} + /** - * Picks the preview host for a document: Wpf on Windows, Gtk on Linux, and on macOS either the - * Eto.macOS app bundle or Mac64, depending on what the solution uses. + * Picks the preview host for a document. The platforms offered depend on the OS and what's installed, + * and Auto picks the first one the solution references, otherwise the OS's usual one. */ export class HostLauncher { - private readonly macCache = new Map(); + private readonly autoCache = new Map(); private lastMessage: string | undefined; constructor(private readonly extensionPath: string, private readonly output: vscode.OutputChannel) { } - async resolve(fileName: string): Promise { + /** Platforms available on this machine, best first. */ + getPlatforms(): PlatformOption[] { + return this.getAvailable().map(r => ({ id: r.id, label: r.label })); + } + + /** @param choice A platform id, or {@link AUTO}. */ + async resolve(fileName: string, choice: string): Promise { + const platforms = this.getAvailable(); + const platform = platforms.find(r => r.id === choice) ?? await this.detect(fileName, platforms); + return platform?.launch(); + } + + private getAvailable(): Platform[] { const config = vscode.workspace.getConfiguration('eto'); const dotnet = config.get('dotnetPath')?.trim() || 'dotnet'; const configured = config.get('previewHost.path')?.trim(); + const netHost = () => this.find(configured, path.join('preview', 'net', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0', HOST_DLL)); + const platforms: Platform[] = []; if (process.platform === 'win32') { - const dll = this.find(configured, path.join('preview', 'win', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0-windows', HOST_DLL)); - return withDotnet(dotnet, dll, [], 'The preview needs the .NET 8 Desktop Runtime (or newer).'); - } - - const dll = this.find(configured, path.join('preview', 'net', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0', HOST_DLL)); - if (process.platform !== 'darwin') { - return withDotnet(dotnet, dll, ['--platform', 'Gtk'], 'The preview needs the .NET 8 runtime (or newer) and GTK 3.'); + const windowsHost = () => this.find(configured, path.join('preview', 'win', HOST_DLL), path.join('Eto.DevExtension.PreviewHost', 'Debug', 'net8.0-windows', HOST_DLL)); + const requirement = 'The preview needs the .NET 8 Desktop Runtime (or newer).'; + platforms.push( + { id: 'Wpf', label: 'WPF', reference: /Eto\.Platform\.Wpf\b|Eto\.Wpf\.csproj/i, launch: () => withDotnet(dotnet, windowsHost(), 'Wpf', 'WPF', requirement) }, + { id: 'WinForms', label: 'WinForms', reference: /Eto\.Platform\.Windows\b|Eto\.WinForms\.csproj/i, launch: () => withDotnet(dotnet, windowsHost(), 'WinForms', 'WinForms', requirement) } + ); } - - if (await this.getMacPlatform(fileName) === 'macOS') { + if (process.platform === 'darwin') { const app = this.find(undefined, path.join('preview', 'macos', MAC_EXECUTABLE), path.join('Eto.DevExtension.PreviewHost.macOS', 'Debug', 'net10.0-macos', MAC_EXECUTABLE)); if (app) { - return { command: app, args: [], requirement: 'The Eto.macOS preview host could not be started.' }; + platforms.push({ + id: 'macOS', label: 'macOS', reference: /Eto\.Platform\.macOS\b|Eto\.macOS\.csproj/i, + launch: () => ({ command: app, args: [], platform: 'macOS', requirement: 'The Eto.macOS preview host could not be started.' }) + }); } - this.log('This copy of the extension has no Eto.macOS preview host, so previewing with Mac64 instead.'); + platforms.push({ + id: 'Mac64', label: 'Mac64', reference: /Eto\.Platform\.Mac64\b|Eto\.Mac64\.csproj/i, + launch: () => withDotnet(dotnet, netHost(), 'Mac64', 'Mac64', 'The preview needs the .NET 8 runtime (or newer).') + }); + } + + const gtk = findGtk(); + if (gtk) { + platforms.push({ + id: 'Gtk', label: 'Gtk', reference: /Eto\.Platform\.Gtk\b|Eto\.Gtk\.csproj/i, + launch: () => { + const launch = withDotnet(dotnet, netHost(), 'Gtk', 'Gtk', 'The preview needs the .NET 8 runtime (or newer) and GTK 3.'); + return launch && { ...launch, env: gtk.env }; + } + }); } - return withDotnet(dotnet, dll, ['--platform', 'Mac64'], 'The preview needs the .NET 8 runtime (or newer).'); + return platforms; + } + + private async detect(fileName: string, platforms: Platform[]): Promise { + const key = path.dirname(fileName); + const cached = this.autoCache.get(key); + if (cached && Date.now() - cached.time < CACHE_MS && platforms.some(r => r.id === cached.platform.id)) { + return cached.platform; + } + if (platforms.length === 0) { + return undefined; + } + + const texts = (findSolutionProjects(fileName) ?? await findWorkspaceProjects()).map(readText); + const referenced = platforms.find(platform => texts.some(text => platform.reference.test(text))); + const platform = referenced ?? platforms[0]; + this.autoCache.set(key, { time: Date.now(), platform }); + this.log(referenced + ? `Previewing with ${platform.label}, as the solution uses it.` + : `Previewing with ${platform.label}, as the solution doesn't reference an Eto platform available here.`); + return platform; } /** @@ -76,34 +140,6 @@ export class HostLauncher { return undefined; } - private async getMacPlatform(fileName: string): Promise { - const configured = vscode.workspace.getConfiguration('eto').get('preview.macPlatform'); - if (configured === 'macOS' || configured === 'Mac64') { - return configured; - } - - const key = path.dirname(fileName); - const cached = this.macCache.get(key); - if (cached && Date.now() - cached.time < CACHE_MS) { - return cached.platform; - } - - const projects = findSolutionProjects(fileName) ?? await findWorkspaceProjects(); - let platform: MacPlatform = 'Mac64'; - let reason = 'no project in the solution references Eto.Platform.macOS'; - for (const project of projects) { - const text = readText(project); - if (/Eto\.Platform\.macOS\b|Eto\.macOS\.csproj/i.test(text)) { - platform = 'macOS'; - reason = `${path.basename(project)} references it`; - break; - } - } - this.macCache.set(key, { time: Date.now(), platform }); - this.log(`Previewing with ${platform === 'macOS' ? 'Eto.macOS' : 'Eto.Mac64'}, as ${reason}. Set "eto.preview.macPlatform" to choose.`); - return platform; - } - private log(message: string): void { if (message !== this.lastMessage) { this.lastMessage = message; @@ -112,8 +148,29 @@ export class HostLauncher { } } -function withDotnet(dotnet: string, dll: string | undefined, args: string[], requirement: string): HostLaunch | undefined { - return dll ? { command: dotnet, args: [dll, ...args], requirement } : undefined; +function withDotnet(dotnet: string, dll: string | undefined, id: string, label: string, requirement: string): HostLaunch | undefined { + return dll ? { command: dotnet, args: [dll, '--platform', id], platform: label, requirement } : undefined; +} + +/** GTK 3, and the environment the host needs to find it, or undefined when it isn't installed. */ +function findGtk(): { env?: NodeJS.ProcessEnv } | undefined { + if (process.platform === 'darwin') { + // Homebrew (Apple silicon, then Intel) or MacPorts, none of which are on the default library path + const dir = ['/opt/homebrew/lib', '/usr/local/lib', '/opt/local/lib'].find(r => fs.existsSync(path.join(r, 'libgtk-3.0.dylib'))); + return dir ? { env: { ...process.env, DYLD_FALLBACK_LIBRARY_PATH: joinPath(dir, process.env.DYLD_FALLBACK_LIBRARY_PATH) } } : undefined; + } + if (process.platform === 'win32') { + // where GtkSharp's build installs it, or anywhere on PATH such as MSYS2 + const installed = process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Gtk', '3.24.24') : undefined; + const dir = [installed, ...(process.env.PATH ?? '').split(path.delimiter)].find(r => r && fs.existsSync(path.join(r, 'libgtk-3-0.dll'))); + return dir ? { env: { ...process.env, PATH: joinPath(dir, process.env.PATH) } } : undefined; + } + // the desktop's own toolkit, so assume it's there + return {}; +} + +function joinPath(first: string, rest: string | undefined): string { + return rest ? `${first}${path.delimiter}${rest}` : first; } /** Projects in the nearest solution above the file, or undefined when there's none. */ diff --git a/src/Eto.DevExtension.VSCode/src/preview.ts b/src/Eto.DevExtension.VSCode/src/preview.ts index 083c5c0..f89aa94 100644 --- a/src/Eto.DevExtension.VSCode/src/preview.ts +++ b/src/Eto.DevExtension.VSCode/src/preview.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { AUTO, PlatformOption } from './hostLaunch'; import { PreviewHost, RenderResult } from './previewHost'; const REFRESH_DELAY_MS = 500; @@ -9,6 +10,14 @@ export function isPreviewable(document: vscode.TextDocument): boolean { return document.uri.scheme === 'file' && /\.(xeto|jeto|eto\.cs|eto\.vb)$/i.test(document.fileName); } +/** The platform chosen in the preview's drop down. */ +export interface PlatformPicker { + getPlatforms(): PlatformOption[]; + /** A platform id, or {@link AUTO}. */ + get(): string; + set(id: string): Thenable; +} + /** * A single preview beside the editor that follows the active designer file and redraws as it is edited. */ @@ -25,16 +34,16 @@ export class PreviewPanel implements vscode.Disposable { private renderPending = false; private timer: NodeJS.Timeout | undefined; - static show(host: PreviewHost, document: vscode.TextDocument): void { + static show(host: PreviewHost, picker: PlatformPicker, document: vscode.TextDocument): void { if (PreviewPanel.current) { PreviewPanel.current.panel.reveal(vscode.ViewColumn.Beside, true); } else { - PreviewPanel.current = new PreviewPanel(host); + PreviewPanel.current = new PreviewPanel(host, picker); } PreviewPanel.current.setDocument(document); } - private constructor(private readonly host: PreviewHost) { + private constructor(private readonly host: PreviewHost, private readonly picker: PlatformPicker) { this.panel = vscode.window.createWebviewPanel('eto.preview', 'Eto Preview', { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, localResourceRoots: [] @@ -75,13 +84,20 @@ export class PreviewPanel implements vscode.Disposable { this.render(); } - private onMessage(message: { type: string; scale?: number; width?: number; height?: number }): void { + private onMessage(message: { type: string; scale?: number; width?: number; height?: number; id?: string }): void { switch (message.type) { - case 'ready': + case 'ready': { this.ready = true; this.scale = message.scale || 1; + const options = [{ id: AUTO, label: 'Auto' }, ...this.picker.getPlatforms()]; + const selected = options.some(r => r.id === this.picker.get()) ? this.picker.get() : AUTO; + this.panel.webview.postMessage({ type: 'platforms', options, selected }); this.render(); break; + } + case 'platform': + this.picker.set(message.id || AUTO).then(() => this.render()); + break; case 'scale': this.scale = message.scale || 1; this.render(); @@ -129,8 +145,8 @@ export class PreviewPanel implements vscode.Disposable { // results for a file the user already moved away from would only flicker if (document === this.document) { this.panel.webview.postMessage(result.error - ? { type: 'error', message: result.error.message, details: result.error.details } - : { type: 'image', image: result.image, width: result.width, height: result.height, sized: !!this.size }); + ? { type: 'error', message: result.error.message, details: result.error.details, platform: result.platform } + : { type: 'image', image: result.image, width: result.width, height: result.height, sized: !!this.size, platform: result.platform }); } } while (this.renderPending); } finally { @@ -150,6 +166,9 @@ function getHtml(): string { +
Drawing preview…
@@ -175,6 +195,7 @@ function getHtml(): string { const grip = document.getElementById('grip'); const status = document.getElementById('status'); const error = document.getElementById('error'); + const platform = document.getElementById('platform'); let drag, sent = 0, sized = false; function setFrameSize(width, height) { @@ -183,8 +204,20 @@ function getHtml(): string { size.textContent = width + 'x' + height; } + // shows what Auto picked + function setPlatform(label) { + const auto = platform.querySelector('option[value="auto"]'); + if (auto && label) + auto.textContent = platform.value === 'auto' ? 'Auto (' + label + ')' : 'Auto'; + } + window.addEventListener('message', e => { const message = e.data; + if (message.type === 'platforms') { + platform.replaceChildren(...message.options.map(r => new Option(r.label, r.id, false, r.id === message.selected))); + return; + } + setPlatform(message.platform); if (message.type === 'image') { error.style.display = 'none'; status.style.display = 'none'; @@ -231,6 +264,10 @@ function getHtml(): string { frame.classList.remove('dragging'); vscode.postMessage({ type: 'resize', width: frame.offsetWidth, height: frame.offsetHeight }); }); + platform.addEventListener('change', () => { + status.textContent = 'Drawing preview…'; + vscode.postMessage({ type: 'platform', id: platform.value }); + }); size.addEventListener('click', () => { if (sized) vscode.postMessage({ type: 'reset' }); diff --git a/src/Eto.DevExtension.VSCode/src/previewHost.ts b/src/Eto.DevExtension.VSCode/src/previewHost.ts index 8ecb37d..5b0d24f 100644 --- a/src/Eto.DevExtension.VSCode/src/previewHost.ts +++ b/src/Eto.DevExtension.VSCode/src/previewHost.ts @@ -24,6 +24,8 @@ export interface RenderResult { width?: number; height?: number; error?: { message: string; details?: string }; + /** Label of the platform it was drawn with. */ + platform?: string; } /** @@ -86,7 +88,7 @@ export class PreviewHost implements vscode.Disposable { this.stop(); continue; } - return result ?? {}; + return { ...result, platform: launch.platform }; } catch (e) { this.stop(); if (attempt > 0) { @@ -115,7 +117,7 @@ export class PreviewHost implements vscode.Disposable { private async start(launch: HostLaunch, key: string): Promise { try { - const child = spawn(launch.command, launch.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + const child = spawn(launch.command, launch.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: launch.env }); const failed = new Promise((_, reject) => child.once('error', reject)); child.stderr?.on('data', data => this.output.append(`${data}`)); From 50fa2b0c1632aa2c6931b5dd740f117387f7d5dc Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Wed, 23 Sep 2026 17:44:26 -0700 Subject: [PATCH 4/5] Update VS Code build on CI to include macos preview host --- .github/workflows/build.yml | 11 ++++++++++- src/Eto.DevExtension.VSCode/scripts/build-server.js | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 516a2bc..30fb3b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,7 +53,8 @@ jobs: build-vscode: - runs-on: ubuntu-latest + # macOS so the package includes the Eto.macOS preview host + runs-on: macos-latest steps: - uses: actions/checkout@v7 @@ -67,6 +68,14 @@ jobs: ${{ env.DotNetVersion }} 8.0.x + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install macOS workload + run: dotnet workload install macos + - uses: actions/setup-node@v4 with: node-version: '20' diff --git a/src/Eto.DevExtension.VSCode/scripts/build-server.js b/src/Eto.DevExtension.VSCode/scripts/build-server.js index b22973c..18488f9 100644 --- a/src/Eto.DevExtension.VSCode/scripts/build-server.js +++ b/src/Eto.DevExtension.VSCode/scripts/build-server.js @@ -27,7 +27,10 @@ if (process.platform === 'darwin') { fs.mkdirSync(path.join(preview, 'macos'), { recursive: true }); // ditto keeps the bundle's signature intact execFileSync('ditto', [path.join(artifacts, 'Eto.DevExtension.PreviewHost.macOS', 'Release', 'net10.0-macos', app), path.join(preview, 'macos', app)], { stdio: 'inherit' }); - } catch { + } catch (e) { + // CI packages must always ship it + if (process.env.CI) + throw e; // usually a missing macOS workload or mismatched Xcode, which shouldn't stop the rest from packaging console.warn('Could not build the Eto.macOS preview host, so Mac previews will use Eto.Mac64.'); } From 1299229747d5b006ed756a764d612a6bb8cd14fb Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Sat, 26 Sep 2026 15:14:49 -0700 Subject: [PATCH 5/5] Update Eto to 2.12 --- Directory.Build.props | 7 ++-- src/Eto.Designer/Eto.Designer.csproj | 6 ++-- .../Eto.DevExtension.LanguageServer.csproj | 2 +- .../Eto.DevExtension.PreviewHost.macOS.csproj | 10 +++--- .../Eto.DevExtension.PreviewHost.csproj | 33 +++++++++---------- .../Eto.DevExtension.Shared.csproj | 4 +-- src/Eto.DevExtension.VSCode/package-lock.json | 4 +-- src/Eto.DevExtension.VSCode/package.json | 2 +- .../2022/source.extension.vsixmanifest | 2 +- ...o.DevExtension.VisualStudio.Windows.csproj | 10 +++--- .../Properties/AssemblyInfo.cs | 4 +-- .../Templates/App-CSharp/App.vstemplate | 2 +- .../Templates/App-FSharp/App.vstemplate | 2 +- .../Templates/App-VisualBasic/App.vstemplate | 2 +- .../Templates/File-CSharp/File.vstemplate | 2 +- .../Templates/File-FSharp/File.vstemplate | 2 +- .../File-VisualBasic/File.vstemplate | 2 +- 17 files changed, 49 insertions(+), 47 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index c6967b4..a39a96d 100755 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,12 +20,15 @@ $(MSBuildThisFileDirectory) $(BasePath)artifacts\ + + true + false - 2.7.5 + 2.12.0 - 2.7.5.0 + 2.12.0.0 $(SetVersion) diff --git a/src/Eto.Designer/Eto.Designer.csproj b/src/Eto.Designer/Eto.Designer.csproj index 25c923b..f7a32ff 100755 --- a/src/Eto.Designer/Eto.Designer.csproj +++ b/src/Eto.Designer/Eto.Designer.csproj @@ -4,9 +4,9 @@ Roslyn;RoslynCS;RoslynVB - - - + + + - + diff --git a/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj b/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj index 543c36e..2021264 100644 --- a/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj +++ b/src/Eto.DevExtension.PreviewHost.macOS/Eto.DevExtension.PreviewHost.macOS.csproj @@ -33,10 +33,10 @@ - - - - + + + + @@ -48,7 +48,7 @@ - + diff --git a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj index fa7a46d..f485ccf 100644 --- a/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj +++ b/src/Eto.DevExtension.PreviewHost/Eto.DevExtension.PreviewHost.csproj @@ -9,7 +9,6 @@ false - true Eto.DevExtension.PreviewHost Eto.DevExtension.PreviewHost en @@ -32,9 +31,9 @@ - - - + + + @@ -44,8 +43,8 @@ - - + + @@ -55,15 +54,15 @@ - - - - - - - - - + + + + + + + + + @@ -76,8 +75,8 @@ - - + + diff --git a/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj b/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj index e566754..4dbfdf6 100755 --- a/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj +++ b/src/Eto.DevExtension.Shared/Eto.DevExtension.Shared.csproj @@ -4,8 +4,8 @@ - - + + \ No newline at end of file diff --git a/src/Eto.DevExtension.VSCode/package-lock.json b/src/Eto.DevExtension.VSCode/package-lock.json index 3a08704..d1b2942 100644 --- a/src/Eto.DevExtension.VSCode/package-lock.json +++ b/src/Eto.DevExtension.VSCode/package-lock.json @@ -1,12 +1,12 @@ { "name": "eto-forms-designer", - "version": "2.7.5", + "version": "2.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eto-forms-designer", - "version": "2.7.5", + "version": "2.12.0", "license": "MIT", "dependencies": { "vscode-jsonrpc": "^8.2.0", diff --git a/src/Eto.DevExtension.VSCode/package.json b/src/Eto.DevExtension.VSCode/package.json index 5fd69a7..ba5ce8e 100644 --- a/src/Eto.DevExtension.VSCode/package.json +++ b/src/Eto.DevExtension.VSCode/package.json @@ -2,7 +2,7 @@ "name": "eto-forms-designer", "displayName": "Eto.Forms Designer", "description": "Autocompletion, hover documentation and a live preview for Eto.Forms .xeto and .jeto designer files.", - "version": "2.7.5", + "version": "2.12.0", "publisher": "picoe", "license": "MIT", "repository": { diff --git a/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest b/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest index c1a35d9..124f30f 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest +++ b/src/Eto.DevExtension.VisualStudio.Windows/2022/source.extension.vsixmanifest @@ -3,7 +3,7 @@ xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> - + Eto.Forms Templates and Previewer Eto.Forms Support for Visual Studio. Eto.Forms is a cross platform GUI framework for desktop and mobile applications in .NET that can target Wpf, WinForms, Direct2D, MonoMac, Xamarin.Mac, Gtk2, and Gtk3 with a single codebase. https://github.com/picoe/Eto diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj index 0ed6605..d83579a 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj +++ b/src/Eto.DevExtension.VisualStudio.Windows/Eto.DevExtension.VisualStudio.Windows.csproj @@ -209,11 +209,11 @@ --> - - - - - + + + + + diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs b/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs index e04d3d4..87b78d0 100644 --- a/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Properties/AssemblyInfo.cs @@ -10,7 +10,7 @@ [assembly: AssemblyTitle("Eto.VisualStudio")] [assembly: AssemblyDescription("")] -[assembly: AssemblyVersion("2.7.5.0")] +[assembly: AssemblyVersion("2.12.0.0")] // Setting ComVisible to false makes the types in this assembly not visible @@ -21,6 +21,6 @@ [assembly: ProvideCodeBase( AssemblyName = "Eto.DevExtension.VisualStudio.Windows", - Version = "2.7.5.0", + Version = "2.12.0.0", CodeBase = "$PackageFolder$\\Eto.DevExtension.VisualStudio.Windows.dll")] diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate index 26fc4bf..3d61f5e 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-CSharp/App.vstemplate @@ -35,7 +35,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate index 6c10728..b6ad1ac 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-FSharp/App.vstemplate @@ -35,7 +35,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate index 63c1e3c..141ea1a 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/App-VisualBasic/App.vstemplate @@ -35,7 +35,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate index 23ee50f..99d3cb7 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-CSharp/File.vstemplate @@ -24,7 +24,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate index 520fe35..ee5f3c2 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-FSharp/File.vstemplate @@ -24,7 +24,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate index 6415732..bcce098 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate +++ b/src/Eto.DevExtension.VisualStudio.Windows/Templates/File-VisualBasic/File.vstemplate @@ -24,7 +24,7 @@ - Eto.DevExtension.VisualStudio.Windows, Version=2.7.5.0, Culture=neutral, PublicKeyToken=null + Eto.DevExtension.VisualStudio.Windows, Version=2.12.0.0, Culture=neutral, PublicKeyToken=null Eto.DevExtension.VisualStudio.Windows.Wizards.ProjectWizard