From 6aeb6d87f1d38841694894381001a4827b8fbfae Mon Sep 17 00:00:00 2001 From: Curtis Wensley Date: Sun, 27 Sep 2026 20:02:08 -0700 Subject: [PATCH] Add theme selection - Designer should have border and follow VS theme colors - Unify designer style in VS to appear the same as other extensions --- src/Eto.Designer/DesignPanel.cs | 6 + src/Eto.Designer/DesignSurface.cs | 282 ++++++++---------- src/Eto.Designer/IDesignHost.cs | 2 + src/Eto.Designer/PlatformProperties.cs | 6 + src/Eto.Designer/PreviewEditorView.cs | 7 + src/Eto.Designer/PreviewEditorViewSplitter.cs | 7 + src/Eto.Designer/PreviewSplitBar.cs | 7 + src/Eto.Designer/RemoteDesignPanel.cs | 10 + src/Eto.DevExtension.PreviewHost/HostTheme.cs | 10 +- .../Platforms/PreviewPlatform.cs | 3 + .../Platforms/WpfPreviewPlatform.cs | 3 + .../PreviewRenderer.cs | 24 +- .../PreviewServer.cs | 8 +- .../PreviewThemes.cs | 95 ++++++ src/Eto.DevExtension.Rider/README.md | 2 +- .../picoe/eto/rider/preview/HostLauncher.kt | 3 + .../picoe/eto/rider/preview/PreviewHost.kt | 11 +- .../picoe/eto/rider/preview/PreviewPanel.kt | 49 ++- .../eto/rider/preview/PreviewToolWindow.kt | 10 +- src/Eto.DevExtension.VSCode/README.md | 2 +- src/Eto.DevExtension.VSCode/src/extension.ts | 7 +- src/Eto.DevExtension.VSCode/src/preview.ts | 69 ++++- .../src/previewHost.ts | 6 + .../Editor/EtoPreviewPane.cs | 75 ++++- .../Editor/PreviewHostClient.cs | 25 +- .../Editor/PreviewPlatforms.cs | 78 +++-- .../PlatformThemeHandler.cs | 8 + 27 files changed, 595 insertions(+), 220 deletions(-) create mode 100644 src/Eto.DevExtension.PreviewHost/PreviewThemes.cs diff --git a/src/Eto.Designer/DesignPanel.cs b/src/Eto.Designer/DesignPanel.cs index 61b1a8a..3daa78c 100755 --- a/src/Eto.Designer/DesignPanel.cs +++ b/src/Eto.Designer/DesignPanel.cs @@ -34,6 +34,12 @@ public Control GetContainer() return this; } + public void UpdateTheme() + { + BackgroundColor = Global.Theme.DesignerBackground; + designSurface.Invalidate(); + } + public string MainAssembly { get; set; } public IEnumerable References { get; set; } diff --git a/src/Eto.Designer/DesignSurface.cs b/src/Eto.Designer/DesignSurface.cs index 03c8a76..cd852fb 100644 --- a/src/Eto.Designer/DesignSurface.cs +++ b/src/Eto.Designer/DesignSurface.cs @@ -8,16 +8,19 @@ namespace Eto.Designer { + /// + /// Shows the content centered, with its size above it and a handle to resize it by. + /// public class DesignSurface : Drawable { - public static Size GripPadding = new Size(5, 5); - static Size GripSize = new Size(2, 2); - static Color GripColor = Colors.LightSkyBlue; - List _grips; - bool _isSizing; - Grip _currentGrip; + public static Size GripPadding = new Size(4, 4); + static Size GripSize = new Size(10, 10); + static Font LabelFont = SystemFonts.Default(8); + bool _enabled; + bool _hover; + bool _dragging; PointF _startDrag; - Grip _hoverGrip; + SizeF _startSize; Size? _originalContentSize; public bool EnableResizing { get; set; } = true; @@ -32,16 +35,14 @@ public class DesignSurface : Drawable public DesignSurface() { - if (!Platform.Instance.IsGtk) // doesn't work correctly on Gtk2 due to lack of control transparency - { - //var padding = GripPadding * 3; - Padding = 32;// new Padding(padding.Width, 32, padding.Width, padding.Height); - _grips = CreateGrips().ToList(); - } - else - _grips = new List(); + // doesn't work correctly on Gtk2 due to lack of control transparency + _enabled = !Platform.Instance.IsGtk; + if (_enabled) + Padding = new Padding(32, 40, 32, 32); } + bool CanResize => _enabled && EnableResizing && _content != null; + Control _content; public new Control Content @@ -49,20 +50,41 @@ public DesignSurface() get { return _content; } set { + if (_content != null) + _content.SizeChanged -= content_Relayout; _content = value; if (_content != null) { - base.Content = TableLayout.AutoSized(_content, centered: true); + // centered side to side only, so dragging the corner grows it both ways and down + base.Content = new TableLayout + { + Rows = + { + new TableRow(new TableCell { ScaleWidth = true }, _content, new TableCell { ScaleWidth = true }), + new TableRow { ScaleHeight = true } + } + }; _content.SizeChanged += content_SizeChanged; + _content.SizeChanged += content_Relayout; } else { base.Content = null; } + Invalidate(); } } + // the border, label and handle follow the content, which only gets its size and place after layout + void content_Relayout(object sender, EventArgs e) => Invalidate(); + + protected override void OnSizeChanged(EventArgs e) + { + base.OnSizeChanged(e); + Invalidate(); + } + private void content_SizeChanged(object sender, EventArgs e) { if (_content.Size == Size.Empty) @@ -74,7 +96,8 @@ private void content_SizeChanged(object sender, EventArgs e) } SizeF? _sizeBounds; - RectangleF SizeBounds + + RectangleF ContentBounds { get { @@ -83,175 +106,101 @@ RectangleF SizeBounds var contentRect = RectangleFromScreen(_content.RectangleToScreen(new RectangleF(_content.Size))); return new RectangleF(contentRect.Location, _sizeBounds ?? contentRect.Size); } - set - { - _sizeBounds = value.Size; - _content.Size = Size.Round(value.Size); - Invalidate(); - RequestedSizeChanged?.Invoke(this, EventArgs.Empty); - } } - bool IsSizing + RectangleF GripBounds { - get { return _isSizing && EnableResizing; } - set + get { - if (_isSizing != value) - { - _isSizing = value; - Invalidate(); - } + var bounds = ContentBounds; + return new RectangleF(bounds.Right - 1, bounds.Bottom - 1, GripSize.Width, GripSize.Height); } } - class Grip - { - public Func Location; - public Action Update; - public Action Start; - public Action Draw; - public Func ShouldDraw; - public Cursor Cursor; - public string ToolTip; - - public bool IsOver(PointF location) - { - return RectangleF.Inflate(Location(), new SizeF(2, 2)).Contains(location); - } + string LabelText => $"{_content?.Size.Width}x{_content?.Size.Height}"; - public bool GetShouldDraw(PointF location) + RectangleF LabelBounds + { + get { - return ShouldDraw?.Invoke(location) == true || IsOver(location); + var bounds = ContentBounds; + var size = LabelFont.MeasureString(LabelText) + new SizeF(12, 2); + return new RectangleF(bounds.Center.X - size.Width / 2, bounds.Top - 30, size.Width, size.Height); } } - RectangleF SizeBoundsWithPadding + void SetSize(SizeF size) { - get { return RectangleF.Inflate(SizeBounds, GripPadding); } + _sizeBounds = size; + _content.Size = Size.Round(size); + Invalidate(); + RequestedSizeChanged?.Invoke(this, EventArgs.Empty); } - void UpdateSize(SizeF? topLeft = null, SizeF? bottomRight = null, SizeF? topRight = null, SizeF? bottomLeft = null) + void ResetSize() { - var bounds = SizeBounds; - - if (topLeft != null) - bounds.TopLeft += topLeft.Value; - if (topRight != null) - bounds.TopRight += topRight.Value; - if (bottomLeft != null) - bounds.BottomLeft += bottomLeft.Value; - if (bottomRight != null) - bounds.BottomRight += bottomRight.Value; - SizeBounds = bounds; + _sizeBounds = null; + _content.Size = _originalContentSize ?? new Size(-1, -1); + Invalidate(); + InvalidateContent?.Invoke(this, EventArgs.Empty); + RequestedSizeChanged?.Invoke(this, EventArgs.Empty); } - IEnumerable CreateGrips() - { - Func gripRect = r => RectangleF.Inflate(new RectangleF(r, new SizeF(1, 1)), GripSize); + bool IsOverGrip(PointF location) => RectangleF.Inflate(GripBounds, new SizeF(2, 2)).Contains(location); - yield return new Grip - { - Location = () => gripRect(SizeBoundsWithPadding.TopLeft), - Update = diff => UpdateSize(topLeft: diff), - Cursor = Cursors.SizeTopLeft - }; - yield return new Grip - { - Location = () => gripRect(SizeBoundsWithPadding.TopRight), - Update = diff => UpdateSize(topRight: diff), - Cursor = Cursors.SizeTopRight - }; - yield return new Grip - { - Location = () => gripRect(SizeBoundsWithPadding.BottomLeft), - Update = diff => UpdateSize(bottomLeft: diff), - Cursor = Cursors.SizeBottomLeft - }; - yield return new Grip - { - Location = () => gripRect(SizeBoundsWithPadding.BottomRight), - Update = diff => UpdateSize(bottomRight: diff), - Cursor = Cursors.SizeBottomRight - }; - Font font = SystemFonts.Default(8); - SizeF gripSize = SizeF.Empty; - yield return new Grip - { - Location = () => { - var rect = SizeBoundsWithPadding; - rect = new RectangleF(rect.Center.X - gripSize.Width / 2, rect.Top - gripSize.Height, gripSize.Width, gripSize.Height); - return rect; - }, - Draw = g => - { - var rect = SizeBoundsWithPadding; - var text = $"{_content?.Size.Width}x{_content?.Size.Height}"; - gripSize = g.MeasureString(font, text) + 4; - //Padding = new Padding(15, Math.Max(15, (int)gripSize.Height + 5), 15, 15); - rect = new RectangleF(rect.Center.X - gripSize.Width / 2, rect.Top - gripSize.Height, gripSize.Width, gripSize.Height); - g.FillRectangle(GripColor, rect); - g.DrawText(font, Colors.White, rect.Location + 2, text); - }, - Start = () => - { - _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", - Cursor = Cursors.Pointer - }; - } + bool IsOverLabel(PointF location) => _sizeBounds != null && LabelBounds.Contains(location); protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); - if (_content != null) + if (!_enabled || _content == null) + return; + var g = e.Graphics; + var theme = Global.Theme; + var bounds = ContentBounds; + g.DrawRectangle(theme.DesignerBorder, bounds.X - 1, bounds.Y - 1, bounds.Width + 1, bounds.Height + 1); + if (!CanResize) + return; + var gripColor = theme.SizeGrip; + if (_hover || _dragging) { - var bounds = SizeBounds; - bounds.Inflate(GripPadding); - - var mouseLocation = PointFromScreen(Mouse.Position); - if (IsSizing) - e.Graphics.DrawRectangle(GripColor, bounds); - foreach (var grip in _grips) - { - if (!IsSizing && !grip.GetShouldDraw(mouseLocation)) - continue; - if (grip.Draw != null) - grip.Draw(e.Graphics); - else - e.Graphics.FillEllipse(GripColor, grip.Location()); - } + var outline = RectangleF.Inflate(ContentBounds, GripPadding); + using (var pen = new Pen(gripColor, 1) { DashStyle = DashStyles.Dash }) + g.DrawRectangle(pen, outline); } + + var label = LabelBounds; + g.FillPath(theme.SizeLabelBackground, GraphicsPath.GetRoundRect(label, 2)); + g.DrawText(LabelFont, theme.SizeLabelForeground, label.Location + new SizeF(6, 1), LabelText); + + g.FillEllipse(gripColor, GripBounds); } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); - if (e.Buttons == MouseButtons.Primary) + if (!CanResize || e.Buttons != MouseButtons.Primary) + return; + if (IsOverGrip(e.Location)) { - _currentGrip = GetGrip(e.Location); - if (_currentGrip != null) - { - _startDrag = e.Location; - _currentGrip?.Start?.Invoke(); - Cursor = _currentGrip?.Cursor ?? Cursors.Default; - e.Handled = true; - } + _dragging = true; + _startDrag = e.Location; + _startSize = ContentBounds.Size; + e.Handled = true; + } + else if (IsOverLabel(e.Location)) + { + ResetSize(); + e.Handled = true; } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); - if (_currentGrip != null) + if (_dragging) { - _currentGrip = null; - Cursor = Cursors.Default; + _dragging = false; Invalidate(); e.Handled = true; } @@ -260,30 +209,39 @@ protected override void OnMouseUp(MouseEventArgs e) protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); - if (_currentGrip != null) + if (!CanResize) + return; + if (_dragging) { - _currentGrip.Update?.Invoke((SizeF)(e.Location - _startDrag)); - _startDrag = _currentGrip.Location().Center; + // centered, so it grows both ways and the edge under the mouse moves twice as far + var diff = e.Location - _startDrag; + SetSize(new SizeF(Math.Max(1, _startSize.Width + diff.X * 2), Math.Max(1, _startSize.Height + diff.Y))); return; } - var bounds = SizeBounds; - var outer = RectangleF.Inflate(bounds, GripPadding * 2); - IsSizing = outer.Contains(e.Location) && !bounds.Contains(e.Location); - var grip = GetGrip(e.Location); - if (grip != _hoverGrip) + var hover = RectangleF.Inflate(ContentBounds, GripSize).Contains(e.Location); + if (hover != _hover) { - _hoverGrip = grip; - ToolTip = grip?.ToolTip; + _hover = hover; Invalidate(); } - Cursor = grip?.Cursor ?? Cursors.Default; - Invalidate(); + var overGrip = IsOverGrip(e.Location); + var overLabel = IsOverLabel(e.Location); + Cursor = overGrip ? Cursors.SizeBottomRight : overLabel ? Cursors.Pointer : Cursors.Default; + // setting it on every move would keep restarting the tooltip + var toolTip = overGrip ? "Drag to resize" : overLabel ? "Click to reset to auto size" : null; + if (toolTip != ToolTip) + ToolTip = toolTip; } - Grip GetGrip(PointF location) + protected override void OnMouseLeave(MouseEventArgs e) { - return _grips.FirstOrDefault(r => r.IsOver(location)); + base.OnMouseLeave(e); + if (_hover && !_dragging) + { + _hover = false; + Invalidate(); + } } } } diff --git a/src/Eto.Designer/IDesignHost.cs b/src/Eto.Designer/IDesignHost.cs index 3ddf019..1192f02 100644 --- a/src/Eto.Designer/IDesignHost.cs +++ b/src/Eto.Designer/IDesignHost.cs @@ -17,5 +17,7 @@ public interface IDesignHost : IDisposable void Invalidate(); bool SetBuilder(string fileName); string GetCodeFile(string fileName); + /// Call after the editor's theme changes to pick up its new colours. + void UpdateTheme(); } } diff --git a/src/Eto.Designer/PlatformProperties.cs b/src/Eto.Designer/PlatformProperties.cs index 7de0385..cbdcab1 100644 --- a/src/Eto.Designer/PlatformProperties.cs +++ b/src/Eto.Designer/PlatformProperties.cs @@ -26,6 +26,12 @@ public interface IPlatformTheme Color SplitBarHoverBackground { get; } Color SplitBarSelectedBackground { get; } Color SplitBarSelectedForeground { get; } + /// Line around the preview, so it stands out when it matches the background. + Color DesignerBorder { get; } + /// Handle and outline for resizing the preview. + Color SizeGrip { get; } + Color SizeLabelBackground { get; } + Color SizeLabelForeground { get; } IEnumerable AllColors { get; } } diff --git a/src/Eto.Designer/PreviewEditorView.cs b/src/Eto.Designer/PreviewEditorView.cs index c510893..220a41d 100755 --- a/src/Eto.Designer/PreviewEditorView.cs +++ b/src/Eto.Designer/PreviewEditorView.cs @@ -98,6 +98,13 @@ void Timer_Elapsed(object sender, EventArgs e) } } + /// Call after the editor's theme changes to pick up its new colours. + public void UpdateTheme() + { + toolBarHolder.BackgroundColor = Global.Theme.DesignerBackground; + designPanel?.UpdateTheme(); + } + protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); diff --git a/src/Eto.Designer/PreviewEditorViewSplitter.cs b/src/Eto.Designer/PreviewEditorViewSplitter.cs index b64a805..b2fd1fa 100755 --- a/src/Eto.Designer/PreviewEditorViewSplitter.cs +++ b/src/Eto.Designer/PreviewEditorViewSplitter.cs @@ -149,6 +149,13 @@ void Splitter_PositionChanged(object sender, EventArgs e) lastPosition = designPosition; } + /// Call after the editor's theme changes to pick up its new colours. + public void UpdateTheme() + { + Preview.UpdateTheme(); + bar.UpdateTheme(); + } + protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); diff --git a/src/Eto.Designer/PreviewSplitBar.cs b/src/Eto.Designer/PreviewSplitBar.cs index 4ac7b5e..78b5793 100644 --- a/src/Eto.Designer/PreviewSplitBar.cs +++ b/src/Eto.Designer/PreviewSplitBar.cs @@ -53,6 +53,13 @@ public PreviewSplitBar(PreviewEditorViewSplitter splitter) UpdateState(); } + public void UpdateTheme() + { + BackgroundColor = Global.Theme.SplitBarBackground; + foreach (var button in new[] { designTab, codeTab, swapButton, orientationButton, collapseButton }) + button.Invalidate(); + } + /// Reflects the splitter's current layout. public void UpdateState() { diff --git a/src/Eto.Designer/RemoteDesignPanel.cs b/src/Eto.Designer/RemoteDesignPanel.cs index 8e651c3..320f417 100644 --- a/src/Eto.Designer/RemoteDesignPanel.cs +++ b/src/Eto.Designer/RemoteDesignPanel.cs @@ -23,6 +23,10 @@ public class PreviewRenderResult public DesignError Error { get; set; } /// Name of the platform it was drawn with, if known. public string Platform { get; set; } + /// Theme names the platform offers, if known. + public string[] Themes { get; set; } + /// Name of the theme it was drawn with, if known. + public string Theme { get; set; } } /// @@ -73,6 +77,12 @@ public bool SetBuilder(string fileName) public string GetCodeFile(string fileName) => builder?.GetCodeFile(fileName); + public void UpdateTheme() + { + BackgroundColor = Global.Theme.DesignerBackground; + designSurface.Invalidate(); + } + public void Update(string code) { this.code = code; diff --git a/src/Eto.DevExtension.PreviewHost/HostTheme.cs b/src/Eto.DevExtension.PreviewHost/HostTheme.cs index 9a4af56..8342065 100644 --- a/src/Eto.DevExtension.PreviewHost/HostTheme.cs +++ b/src/Eto.DevExtension.PreviewHost/HostTheme.cs @@ -12,6 +12,9 @@ class HostTheme : IPlatformTheme public HostTheme(IReadOnlyDictionary colors) => this.colors = colors; + /// Set when an Eto theme is applied, so panels take its colours rather than the editor's. + public bool Themed { get; set; } + Color Get(string name, Color fallback) => colors.TryGetValue(name, out var value) && Color.TryParse(value, out var color) ? color : fallback; @@ -23,12 +26,17 @@ Color Get(string name, Color fallback) => public Color SummaryForeground => Get(nameof(SummaryForeground), SystemColors.ControlText); public Color DesignerBackground => Get(nameof(DesignerBackground), SystemColors.Control); // the platform's window colour, so panels look like forms in dark mode, but Gtk can report it as transparent - public Color DesignerPanel => Get(nameof(DesignerPanel), SystemColors.WindowBackground is { A: > 0 } window ? window : Color.FromRgb(0xF0F0F0)); + public Color DesignerPanel => Themed ? PlatformPanel : Get(nameof(DesignerPanel), PlatformPanel); + static Color PlatformPanel => SystemColors.WindowBackground is { A: > 0 } window ? window : Color.FromRgb(0xF0F0F0); public Color SplitBarBackground => Get(nameof(SplitBarBackground), SystemColors.Control); public Color SplitBarForeground => Get(nameof(SplitBarForeground), SystemColors.ControlText); public Color SplitBarHoverBackground => Get(nameof(SplitBarHoverBackground), SystemColors.Highlight); public Color SplitBarSelectedBackground => Get(nameof(SplitBarSelectedBackground), SystemColors.Highlight); public Color SplitBarSelectedForeground => Get(nameof(SplitBarSelectedForeground), SystemColors.HighlightText); + public Color DesignerBorder => Get(nameof(DesignerBorder), Colors.Gray); + public Color SizeGrip => Get(nameof(SizeGrip), SystemColors.Highlight); + public Color SizeLabelBackground => Get(nameof(SizeLabelBackground), SystemColors.Control); + public Color SizeLabelForeground => Get(nameof(SizeLabelForeground), SystemColors.ControlText); public IEnumerable AllColors => Enumerable.Empty(); } } diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs index e1a29f5..02374f3 100644 --- a/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs +++ b/src/Eto.DevExtension.PreviewHost/Platforms/PreviewPlatform.cs @@ -37,6 +37,9 @@ abstract class PreviewPlatform /// Called once the application is running. public virtual void Initialized() { } + /// The Eto.Forms.Theme to use when none is picked, or null to keep the app's own. + public virtual object GetDefaultTheme() => null; + /// Draws the control to a PNG. is an Eto.Forms.Control, and is disposed after. public abstract Task CaptureAsync(object control, RenderRequest request); diff --git a/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs index ecb6be8..e235983 100644 --- a/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs +++ b/src/Eto.DevExtension.PreviewHost/Platforms/WpfPreviewPlatform.cs @@ -32,6 +32,9 @@ public override IEnumerable GetFrameworks(IEnumerable available) // the offscreen forms come and go, and must not end the process public override void Initialized() => System.Windows.Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown; + // Eto.Wpf otherwise starts with the classic look + public override object GetDefaultTheme() => Themes.System; + public override async Task CaptureAsync(object control, RenderRequest request) { var form = OffscreenForm.Create((Control)control, request, out var content); diff --git a/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs index 526579a..abee352 100644 --- a/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs +++ b/src/Eto.DevExtension.PreviewHost/PreviewRenderer.cs @@ -13,6 +13,8 @@ class RenderRequest public int? Width; public int? Height; public double Scale = 1; + /// Theme name, or null for the default. + public string Theme; } class RenderResult @@ -22,6 +24,10 @@ class RenderResult public int Height { get; set; } public RenderError Error { get; set; } public bool? RestartRequired { get; set; } + /// Theme names that can be picked. + public string[] Themes { get; set; } + /// Name of the theme it was drawn with. + public string Theme { get; set; } public static RenderResult Png(byte[] png, int width, int height) => new RenderResult { Image = Convert.ToBase64String(png), Width = width, Height = height }; @@ -41,13 +47,27 @@ class RenderError class PreviewRenderer { readonly PreviewPlatform platform; + readonly PreviewThemes themes; string builderFile; IInterfaceBuilder builder; IBuildToken token; - public PreviewRenderer(PreviewPlatform platform) => this.platform = platform; + public PreviewRenderer(PreviewPlatform platform, PreviewThemes themes) + { + this.platform = platform; + this.themes = themes; + } + + public async Task RenderAsync(RenderRequest request) + { + themes.Apply(request.Theme); + var result = await BuildAsync(request); + result.Themes = themes.Names; + result.Theme = themes.Current; + return result; + } - public Task RenderAsync(RenderRequest request) + Task BuildAsync(RenderRequest request) { var completion = new TaskCompletionSource(); try diff --git a/src/Eto.DevExtension.PreviewHost/PreviewServer.cs b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs index 8ea4ed3..1975253 100644 --- a/src/Eto.DevExtension.PreviewHost/PreviewServer.cs +++ b/src/Eto.DevExtension.PreviewHost/PreviewServer.cs @@ -50,7 +50,8 @@ object Render(JsonNode parameters) Text = parameters?["text"]?.GetValue() ?? string.Empty, Width = parameters?["width"]?.GetValue(), Height = parameters?["height"]?.GetValue(), - Scale = parameters?["scale"]?.GetValue() ?? 1 + Scale = parameters?["scale"]?.GetValue() ?? 1, + Theme = parameters?["theme"]?.GetValue() }; var assemblies = (parameters?["assemblies"] as JsonArray)?.Select(r => r?.GetValue()).Where(r => !string.IsNullOrEmpty(r)).ToList() ?? ProjectAssemblyLocator.Find(request.FileName, connection.Log); @@ -80,7 +81,8 @@ public void RunUI() void StartEto() { var etoPlatform = (Eto.Platform)platform.CreatePlatform(); - etoPlatform.Add(() => new HostTheme(theme)); + var hostTheme = new HostTheme(theme); + etoPlatform.Add(() => hostTheme); Eto.Designer.Builders.BaseCompiledInterfaceBuilder.EtoAssemblyPath = ProjectAssemblies.EtoFile; var app = new Eto.Forms.Application(etoPlatform); @@ -89,7 +91,7 @@ void StartEto() platform.Initialized(); ProjectAssemblies.LoadProject(); ProjectAssemblies.WatchForChanges(Restart); - renderer = new PreviewRenderer(platform); + renderer = new PreviewRenderer(platform, new PreviewThemes(platform, hostTheme, connection.Log)); uiReady.TrySetResult(true); }; app.UnhandledException += (sender, e) => connection.Log($"Unhandled exception: {e.ExceptionObject}"); diff --git a/src/Eto.DevExtension.PreviewHost/PreviewThemes.cs b/src/Eto.DevExtension.PreviewHost/PreviewThemes.cs new file mode 100644 index 0000000..52030c6 --- /dev/null +++ b/src/Eto.DevExtension.PreviewHost/PreviewThemes.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Eto.DevExtension.PreviewHost +{ + /// Switches the Eto theme previews are drawn with. Use on the UI thread. + /// Themes need Eto 2.12 or newer, older projects only ever get the default. + class PreviewThemes + { + readonly PreviewPlatform platform; + readonly HostTheme hostTheme; + readonly Action log; + bool supported = true; + bool loaded; + // Eto.Forms.Theme objects, kept untyped so older Eto can still load this class + object initial; + object current; + List all; + + public PreviewThemes(PreviewPlatform platform, HostTheme hostTheme, Action log) + { + this.platform = platform; + this.hostTheme = hostTheme; + this.log = log; + } + + /// Names of the themes the platform offers, or empty when it has none. + public string[] Names { get; private set; } = Array.Empty(); + + /// Name of the theme now in use, or null when unknown. + public string Current { get; private set; } + + /// A name from , or null for the platform's default. + public void Apply(string name) + { + if (!supported) + return; + try + { + ApplyTheme(name); + } + catch (Exception ex) when (ex is TypeLoadException || ex is MissingMemberException) + { + supported = false; + Names = Array.Empty(); + Current = null; + } + catch (Exception ex) + { + log($"Could not apply the {name ?? "default"} theme: {ex.Message}"); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + void ApplyTheme(string name) + { + var app = Eto.Forms.Application.Instance; + if (!loaded) + { + loaded = true; + initial = current = app.Theme; + all = LoadThemes().Cast().ToList(); + Names = all.Cast().Select(r => r.Name).Distinct().ToArray(); + } + + var theme = all.Cast().FirstOrDefault(r => r.Name == name) + ?? (Eto.Forms.Theme)platform.GetDefaultTheme() + ?? (Eto.Forms.Theme)initial; + if (theme != null && !ReferenceEquals(theme, current)) + { + app.Theme = theme; + current = theme; + } + // the editor's panel colour only suits the platform's untouched look + hostTheme.Themed = !ReferenceEquals(current, initial); + Current = ((Eto.Forms.Theme)current)?.Name; + } + + IEnumerable LoadThemes() + { + try + { + return Eto.Forms.Themes.AllThemes.Where(r => r != null).ToList(); + } + catch (Exception ex) when (!(ex is TypeLoadException || ex is MissingMemberException)) + { + // e.g. Eto.WinForms only has themes when built for .NET 9 or newer + log($"The platform has no themes: {ex.Message}"); + return Enumerable.Empty(); + } + } + } +} diff --git a/src/Eto.DevExtension.Rider/README.md b/src/Eto.DevExtension.Rider/README.md index 20f422e..dc2c928 100644 --- a/src/Eto.DevExtension.Rider/README.md +++ b/src/Eto.DevExtension.Rider/README.md @@ -8,7 +8,7 @@ with the same features as the VS Code extension. Needs Rider 2025.1 or newer. - A live preview of `.xeto`, `.jeto` and `.eto.cs` files in the **Eto Preview** tool window, which appears once you open one of those files. Open it from its button on the right-hand side, **View | Tool Windows**, or **Open Eto Preview** in the editor's right-click menu. It follows the file you're editing and redraws as you type and after each build. Drag the corner handle to try other sizes; click the size label to go back to the form's own size -- Pick the platform to draw with from the drop down in the preview, the same as in VS Code. The **Log** tab shows the preview host's output +- Pick the platform and theme to draw with from the drop downs in the preview, the same as in VS Code. The **Log** tab shows the preview host's output - Completion of controls, properties, events and values, including your own controls once the project is built - Hover documentation from the Eto.Forms xml docs - Syntax highlighting for both file types, using the VS Code extension's grammars diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt index 0b809b8..9222116 100644 --- a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/HostLauncher.kt @@ -16,6 +16,9 @@ private const val CACHE_MS = 10000L const val AUTO = "auto" +/** The platform's usual theme, which for WPF is the system one. */ +const val DEFAULT_THEME = "" + /** A platform the preview can draw with. */ data class PlatformOption(val id: String, val label: String) { override fun toString() = label diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewHost.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewHost.kt index 4714a5c..9af61f6 100644 --- a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewHost.kt +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewHost.kt @@ -1,5 +1,6 @@ package com.picoe.eto.rider.preview +import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonNull import com.google.gson.JsonObject @@ -28,7 +29,8 @@ import java.util.concurrent.atomic.AtomicInteger // long enough for a first render that compiles code, short enough to recover from a hung control private const val RENDER_TIMEOUT_MS = 30000L -data class RenderRequest(val fileName: String, val text: String, val width: Int?, val height: Int?, val scale: Double) +/** @param theme A theme name, or [DEFAULT_THEME]. */ +data class RenderRequest(val fileName: String, val text: String, val width: Int?, val height: Int?, val scale: Double, val theme: String = DEFAULT_THEME) data class RenderResult( /** Base64 png. */ @@ -39,6 +41,10 @@ data class RenderResult( val errorDetails: String? = null, /** Label of the platform it was drawn with. */ val platform: String? = null, + /** Theme names the platform offers, if known. */ + val themes: List? = null, + /** Name of the theme it was drawn with. */ + val theme: String? = null, ) /** @@ -86,6 +92,7 @@ class PreviewHost(private val project: Project) : Disposable { request.width?.let { addProperty("width", it) } request.height?.let { addProperty("height", it) } addProperty("scale", request.scale) + if (request.theme != DEFAULT_THEME) addProperty("theme", request.theme) add("assemblies", JsonNull.INSTANCE) } try { @@ -103,6 +110,8 @@ class PreviewHost(private val project: Project) : Disposable { errorMessage = error?.string("message"), errorDetails = error?.string("details"), platform = launch.platform, + themes = (result.get("themes") as? JsonArray)?.mapNotNull { r -> r.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotEmpty() } }, + theme = result.string("theme"), ) } catch (e: TimeoutException) { stop() diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt index 96bb092..22ad746 100644 --- a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewPanel.kt @@ -68,6 +68,12 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo private var disposed = false // what Auto picked private var autoLabel: String? = null + private val themes = CollectionComboBoxModel() + private val themeBox = ComboBox(themes) + // themes the host offers, and the one it last drew with, shown beside Default + private var hostThemes: List? = null + private var drawnTheme: String? = null + private var loadingThemes = false init { background = editorBackground() @@ -84,6 +90,22 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo val id = platformBox.item?.id ?: AUTO if (id != state.platform) { state.platform = id + // another platform has its own themes + hostThemes = null + drawnTheme = null + loadThemes() + surface.status = "Drawing preview…" + render() + } + } + themeBox.toolTipText = "Theme to draw the preview with" + loadThemes() + themeBox.addActionListener { + if (loadingThemes) return@addActionListener + val name = themeBox.item?.id ?: DEFAULT_THEME + if (name != state.theme) { + state.theme = name + loadThemes() surface.status = "Drawing preview…" render() } @@ -91,6 +113,7 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo val toolbar = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(10), JBUI.scale(6))).apply { isOpaque = false add(platformBox) + add(themeBox) } error.apply { @@ -172,7 +195,7 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo rendering = true renderPending = false val text = ReadAction.compute { FileDocumentManager.getInstance().getDocument(file)?.text ?: "" } - val request = RenderRequest(file.path, text, size?.width, size?.height, scale) + val request = RenderRequest(file.path, text, size?.width, size?.height, scale, state.theme) host.render(request, state.platform).whenComplete { result, e -> ApplicationManager.getApplication().invokeLater({ rendering = false @@ -188,6 +211,11 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo private fun show(result: RenderResult) { autoLabel = result.platform platformBox.repaint() + result.themes?.let { + hostThemes = it + drawnTheme = result.theme + loadThemes() + } if (result.errorMessage != null) { // keep the last good preview up error.text = result.errorMessage @@ -204,6 +232,22 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo repaint() } + private fun loadThemes() { + val choice = state.theme + val names = hostThemes.orEmpty().toMutableList() + // until the host lists its themes, still show what was picked + if (hostThemes == null && choice != DEFAULT_THEME) names += choice + val selected = if (choice in names) choice else DEFAULT_THEME + val label = if (selected == DEFAULT_THEME && drawnTheme != null) "Default ($drawnTheme)" else "Default" + val options = listOf(PlatformOption(DEFAULT_THEME, label)) + names.map { PlatformOption(it, it) } + // replacing the items would close the list if it's open + if (options == themes.items && themeBox.item?.id == selected) return + loadingThemes = true + themes.replaceAll(options) + themeBox.item = options.first { it.id == selected } + loadingThemes = false + } + private fun onResize(width: Int, height: Int) { size = Dimension(width, height) render() @@ -342,6 +386,9 @@ class PreviewPanel(private val project: Project) : JPanel(BorderLayout()), Dispo val frame = frame() g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) g2.drawImage(image, frame.x, frame.y, frame.width, frame.height, null) + // a line just outside the form, so it stands out when it matches the background + g2.color = JBColor.border() + g2.drawRect(frame.x - 1, frame.y - 1, frame.width + 1, frame.height + 1) val focus = JBUI.CurrentTheme.Focus.focusColor() if (hover || drag != null) { diff --git a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt index 3f53c38..5c949ab 100644 --- a/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt +++ b/src/Eto.DevExtension.Rider/src/main/kotlin/com/picoe/eto/rider/preview/PreviewToolWindow.kt @@ -86,12 +86,13 @@ class OpenPreviewAction : AnAction(), DumbAware { } } -/** The platform picked in the preview's drop down, per project. */ +/** The platform and theme picked in the preview's drop downs, per project. */ @Service(Service.Level.PROJECT) @State(name = "EtoPreview", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) class PreviewPlatformState : SimplePersistentStateComponent(Options()) { class Options : BaseState() { var platform by string(AUTO) + var theme by string(DEFAULT_THEME) } /** A platform id, or [AUTO]. */ @@ -100,6 +101,13 @@ class PreviewPlatformState : SimplePersistentStateComponent launcher.getPlatforms(), get: () => context.workspaceState.get(PLATFORM_KEY, AUTO), - set: id => context.workspaceState.update(PLATFORM_KEY, id) + set: id => context.workspaceState.update(PLATFORM_KEY, id), + getTheme: () => context.workspaceState.get(THEME_KEY, DEFAULT_THEME), + setTheme: name => context.workspaceState.update(THEME_KEY, name) }; previewHost = new PreviewHost(fileName => launcher.resolve(fileName, picker.get()), output); } diff --git a/src/Eto.DevExtension.VSCode/src/preview.ts b/src/Eto.DevExtension.VSCode/src/preview.ts index f89aa94..6860abe 100644 --- a/src/Eto.DevExtension.VSCode/src/preview.ts +++ b/src/Eto.DevExtension.VSCode/src/preview.ts @@ -5,6 +5,9 @@ import { PreviewHost, RenderResult } from './previewHost'; const REFRESH_DELAY_MS = 500; +/** The platform's usual theme, which for WPF is the system one. */ +export const DEFAULT_THEME = ''; + /** 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); @@ -16,6 +19,9 @@ export interface PlatformPicker { /** A platform id, or {@link AUTO}. */ get(): string; set(id: string): Thenable; + /** A theme name, or {@link DEFAULT_THEME}. */ + getTheme(): string; + setTheme(name: string): Thenable; } /** @@ -33,6 +39,10 @@ export class PreviewPanel implements vscode.Disposable { private rendering = false; private renderPending = false; private timer: NodeJS.Timeout | undefined; + // themes the host offers, and the one it last drew with, shown beside Default + private themes: string[] | undefined; + private drawnTheme: string | undefined; + private sentThemes: string | undefined; static show(host: PreviewHost, picker: PlatformPicker, document: vscode.TextDocument): void { if (PreviewPanel.current) { @@ -92,12 +102,24 @@ export class PreviewPanel implements vscode.Disposable { 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.sentThemes = undefined; + this.postThemes(); this.render(); break; } case 'platform': + // another platform has its own themes + this.themes = undefined; + this.drawnTheme = undefined; + this.postThemes(); this.picker.set(message.id || AUTO).then(() => this.render()); break; + case 'theme': + this.picker.setTheme(message.id || DEFAULT_THEME).then(() => { + this.postThemes(); + this.render(); + }); + break; case 'scale': this.scale = message.scale || 1; this.render(); @@ -113,6 +135,24 @@ export class PreviewPanel implements vscode.Disposable { } } + private postThemes(): void { + const choice = this.picker.getTheme(); + const names = [...this.themes ?? []]; + // until the host lists its themes, still show what was picked + if (!this.themes && choice !== DEFAULT_THEME) { + names.push(choice); + } + const selected = names.includes(choice) ? choice : DEFAULT_THEME; + const label = selected === DEFAULT_THEME && this.drawnTheme ? `Default (${this.drawnTheme})` : 'Default'; + const options = [{ id: DEFAULT_THEME, label }, ...names.map(r => ({ id: r, label: r }))]; + // rebuilding the list would close it if it's open + const key = JSON.stringify([options, selected]); + if (key !== this.sentThemes) { + this.sentThemes = key; + this.panel.webview.postMessage({ type: 'themes', options, selected }); + } + } + private schedule(): void { clearTimeout(this.timer); this.timer = setTimeout(() => this.render(), REFRESH_DELAY_MS); @@ -137,11 +177,17 @@ export class PreviewPanel implements vscode.Disposable { text: document.getText(), width: this.size?.width, height: this.size?.height, - scale: this.scale + scale: this.scale, + theme: this.picker.getTheme() || undefined }); if (PreviewPanel.current !== this) { return; } + if (result.themes) { + this.themes = result.themes; + this.drawnTheme = result.theme; + this.postThemes(); + } // results for a file the user already moved away from would only flicker if (document === this.document) { this.panel.webview.postMessage(result.error @@ -166,13 +212,14 @@ function getHtml(): string { -
+
Drawing preview…
@@ -196,6 +243,7 @@ function getHtml(): string { const status = document.getElementById('status'); const error = document.getElementById('error'); const platform = document.getElementById('platform'); + const theme = document.getElementById('theme'); let drag, sent = 0, sized = false; function setFrameSize(width, height) { @@ -213,8 +261,9 @@ function getHtml(): string { 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))); + if (message.type === 'platforms' || message.type === 'themes') { + const select = message.type === 'platforms' ? platform : theme; + select.replaceChildren(...message.options.map(r => new Option(r.label, r.id, false, r.id === message.selected))); return; } setPlatform(message.platform); @@ -268,6 +317,10 @@ function getHtml(): string { status.textContent = 'Drawing preview…'; vscode.postMessage({ type: 'platform', id: platform.value }); }); + theme.addEventListener('change', () => { + status.textContent = 'Drawing preview…'; + vscode.postMessage({ type: 'theme', id: theme.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 5b0d24f..69e45bf 100644 --- a/src/Eto.DevExtension.VSCode/src/previewHost.ts +++ b/src/Eto.DevExtension.VSCode/src/previewHost.ts @@ -17,6 +17,8 @@ export interface RenderRequest { width?: number; height?: number; scale: number; + /** Theme name, or undefined for the platform's default. */ + theme?: string; } export interface RenderResult { @@ -26,6 +28,10 @@ export interface RenderResult { error?: { message: string; details?: string }; /** Label of the platform it was drawn with. */ platform?: string; + /** Theme names the platform offers. */ + themes?: string[]; + /** Name of the theme it was drawn with. */ + theme?: string; } /** diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs index 432ac4f..5fa67c7 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/EtoPreviewPane.cs @@ -25,6 +25,7 @@ using System.Reflection; using System.Text.RegularExpressions; using Microsoft.VisualStudio.Utilities; +using Microsoft.VisualStudio.PlatformUI; using Microsoft.VisualStudio.Editor; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor; @@ -54,6 +55,11 @@ public sealed class EtoPreviewPane : Microsoft.VisualStudio.Shell.WindowPane, // label of the platform the last preview was drawn with, shown beside Auto string drawnPlatform; bool loadingPlatforms; + DropDown themeDropDown; + // themes the host offers, and the one it last drew with, shown beside Default + string[] drawnThemes; + string drawnTheme; + bool loadingThemes; Panel editorControl; uint dataEventsCookie; uint linesEventsCookie; @@ -130,6 +136,12 @@ public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines tex drawnPlatform = result.Platform; LoadPlatforms(); } + if (result?.Themes != null && (result.Theme != drawnTheme || drawnThemes == null || !result.Themes.SequenceEqual(drawnThemes))) + { + drawnThemes = result.Themes; + drawnTheme = result.Theme; + LoadThemes(); + } return result; }); @@ -144,9 +156,19 @@ public EtoPreviewPane(EtoAddinPackage package, string fileName, IVsTextLines tex platformDropDown = new DropDown { ToolTip = "Platform to draw the preview with" }; platformDropDown.SelectedKeyChanged += PlatformDropDown_SelectedKeyChanged; - preview.ToolBar = platformDropDown; + themeDropDown = new DropDown { ToolTip = "Theme to draw the preview with" }; + themeDropDown.SelectedKeyChanged += ThemeDropDown_SelectedKeyChanged; + preview.ToolBar = new StackLayout + { + Orientation = Orientation.Horizontal, + Spacing = 4, + Items = { platformDropDown, themeDropDown } + }; LoadPlatforms(); + LoadThemes(); PreviewPlatforms.ChoiceChanged += PreviewPlatforms_ChoiceChanged; + PreviewPlatforms.ThemeChoiceChanged += PreviewPlatforms_ThemeChoiceChanged; + VSColorTheme.ThemeChanged += VSColorTheme_ThemeChanged; var content = previewSplitter.ToNative(true); Wizards.EtoInitializer.ApplyTheme(content); @@ -326,6 +348,8 @@ protected override void Dispose(bool disposing) RegisterIndependentView(false); PreviewPlatforms.ChoiceChanged -= PreviewPlatforms_ChoiceChanged; + PreviewPlatforms.ThemeChoiceChanged -= PreviewPlatforms_ThemeChoiceChanged; + VSColorTheme.ThemeChanged -= VSColorTheme_ThemeChanged; disposed = true; @@ -389,6 +413,16 @@ void ViewCode() void PreviewHost_ProjectChanged(object sender, EventArgs e) => preview?.Update(); + void VSColorTheme_ThemeChanged(ThemeChangedEventArgs e) + { + // may be raised off the UI thread + Eto.Forms.Application.Instance.AsyncInvoke(() => + { + if (!disposed) + previewSplitter?.UpdateTheme(); + }); + } + void LoadPlatforms() { ThreadHelper.ThrowIfNotOnUIThread(); @@ -417,6 +451,45 @@ void PreviewPlatforms_ChoiceChanged(object sender, EventArgs e) ThreadHelper.ThrowIfNotOnUIThread(); drawnPlatform = null; LoadPlatforms(); + // another platform has its own themes + drawnThemes = null; + drawnTheme = null; + LoadThemes(); + preview?.Update(); + } + + void LoadThemes() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (themeDropDown == null) + return; + var choice = PreviewPlatforms.ThemeChoice; + var names = (drawnThemes ?? Array.Empty()).ToList(); + // until the host lists its themes, still show what was picked + if (drawnThemes == null && choice != PreviewPlatforms.DefaultTheme) + names.Add(choice); + var selected = names.Contains(choice) ? choice : PreviewPlatforms.DefaultTheme; + var label = selected == PreviewPlatforms.DefaultTheme && drawnTheme != null ? $"Default ({drawnTheme})" : "Default"; + var items = new List { new ListItem { Key = PreviewPlatforms.DefaultTheme, Text = label } }; + items.AddRange(names.Select(r => new ListItem { Key = r, Text = r })); + + loadingThemes = true; + themeDropDown.DataStore = items; + themeDropDown.SelectedKey = selected; + loadingThemes = false; + } + + void ThemeDropDown_SelectedKeyChanged(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (!loadingThemes && themeDropDown.SelectedKey != null) + PreviewPlatforms.ThemeChoice = themeDropDown.SelectedKey; + } + + void PreviewPlatforms_ThemeChoiceChanged(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + LoadThemes(); preview?.Update(); } diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs index 73d48d7..4d1ffe6 100644 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewHostClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -72,6 +73,7 @@ public async Task RenderAsync(PreviewRenderRequest request, { // a second try covers a host that exited after a rebuild, or that serves an older build var wanted = await PreviewPlatforms.ResolveAsync(); + var theme = PreviewPlatforms.ThemeChoice; for (var attempt = 0; attempt < 2; attempt++) { var server = await GetServerAsync(wanted); @@ -91,7 +93,8 @@ public async Task RenderAsync(PreviewRenderRequest request, assemblies, width = request.Size?.Width, height = request.Size?.Height, - scale = request.Scale + scale = request.Scale, + theme = string.IsNullOrEmpty(theme) ? null : theme }, timeout.Token); @@ -126,16 +129,18 @@ public async Task RenderAsync(PreviewRenderRequest request, static PreviewRenderResult Read(JToken result, PreviewPlatform platform) { var error = result?["error"]; - if (error != null && error.Type == JTokenType.Object) - return Error((string)error["message"], (string)error["details"], platform); - 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), - Platform = platform.Label - }; + var read = error != null && error.Type == JTokenType.Object + ? Error((string)error["message"], (string)error["details"], platform) + : new PreviewRenderResult + { + Image = string.IsNullOrEmpty(image) ? null : Convert.FromBase64String(image), + Size = new Size((int?)result?["width"] ?? 0, (int?)result?["height"] ?? 0), + Platform = platform.Label + }; + read.Themes = (result?["themes"] as JArray)?.Select(r => (string)r).Where(r => !string.IsNullOrEmpty(r)).ToArray(); + read.Theme = (string)result?["theme"]; + return read; } static PreviewRenderResult Error(string message, string details, PreviewPlatform platform) => diff --git a/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewPlatforms.cs b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewPlatforms.cs index 6b7527b..96a4dd4 100644 --- a/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewPlatforms.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/Editor/PreviewPlatforms.cs @@ -35,8 +35,11 @@ sealed class PreviewPlatform static class PreviewPlatforms { public const string Auto = "auto"; + /// The platform's usual theme, which for WPF is the system one. + public const string DefaultTheme = ""; const string HostDll = "Eto.DevExtension.PreviewHost.dll"; const string CollectionPath = @"Eto.DevExtension\PreviewPlatform"; + const string ThemeCollectionPath = @"Eto.DevExtension\PreviewTheme"; const string Requirement = "The preview needs the .NET 8 Desktop Runtime (or newer) to be installed."; // scanning projects on every keystroke is too slow, but an edited project should still be noticed static readonly TimeSpan CacheTime = TimeSpan.FromSeconds(10); @@ -44,10 +47,14 @@ static class PreviewPlatforms static (DateTime Time, string Solution, string Id) autoCache; // used when there's no solution to remember it with static string unsavedChoice = Auto; + static string unsavedTheme = DefaultTheme; /// Raised on the UI thread when changes. public static event EventHandler ChoiceChanged; + /// Raised on the UI thread when changes. + public static event EventHandler ThemeChoiceChanged; + /// Platforms available on this machine, best first. public static List GetAvailable() { @@ -79,38 +86,57 @@ public static List GetAvailable() /// A platform id or , remembered for each solution. Use on the UI thread. public static string Choice { - get + get => GetChoice(CollectionPath, Auto, unsavedChoice); + set { - ThreadHelper.ThrowIfNotOnUIThread(); - var solution = GetSolutionPath(); - if (string.IsNullOrEmpty(solution)) - return unsavedChoice; - var store = GetStore(); - return store?.GetString(CollectionPath, solution, Auto) ?? Auto; + if (SetChoice(CollectionPath, value ?? Auto, Auto, ref unsavedChoice)) + ChoiceChanged?.Invoke(null, EventArgs.Empty); } + } + + /// A theme name from the host, or , remembered for each solution. Use on the UI thread. + public static string ThemeChoice + { + get => GetChoice(ThemeCollectionPath, DefaultTheme, unsavedTheme); set { - ThreadHelper.ThrowIfNotOnUIThread(); - value = value ?? Auto; - if (value == Choice) - return; + if (SetChoice(ThemeCollectionPath, value ?? DefaultTheme, DefaultTheme, ref unsavedTheme)) + ThemeChoiceChanged?.Invoke(null, EventArgs.Empty); + } + } - var solution = GetSolutionPath(); - var store = string.IsNullOrEmpty(solution) ? null : GetStore(); - if (store == null) - unsavedChoice = value; - else if (value == Auto) - { - if (store.CollectionExists(CollectionPath)) - store.DeleteProperty(CollectionPath, solution); - } - else - { - store.CreateCollection(CollectionPath); - store.SetString(CollectionPath, solution, value); - } - ChoiceChanged?.Invoke(null, EventArgs.Empty); + static string GetChoice(string collection, string fallback, string unsaved) + { + ThreadHelper.ThrowIfNotOnUIThread(); + var solution = GetSolutionPath(); + if (string.IsNullOrEmpty(solution)) + return unsaved; + var store = GetStore(); + return store?.GetString(collection, solution, fallback) ?? fallback; + } + + /// true when the choice changed. + static bool SetChoice(string collection, string value, string fallback, ref string unsaved) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (value == GetChoice(collection, fallback, unsaved)) + return false; + + var solution = GetSolutionPath(); + var store = string.IsNullOrEmpty(solution) ? null : GetStore(); + if (store == null) + unsaved = value; + else if (value == fallback) + { + if (store.CollectionExists(collection)) + store.DeleteProperty(collection, solution); + } + else + { + store.CreateCollection(collection); + store.SetString(collection, solution, value); } + return true; } /// The platform to draw with: the one picked, or for Auto the first the solution references, otherwise WPF. diff --git a/src/Eto.DevExtension.VisualStudio.Windows/PlatformThemeHandler.cs b/src/Eto.DevExtension.VisualStudio.Windows/PlatformThemeHandler.cs index 1b31757..9e257e7 100755 --- a/src/Eto.DevExtension.VisualStudio.Windows/PlatformThemeHandler.cs +++ b/src/Eto.DevExtension.VisualStudio.Windows/PlatformThemeHandler.cs @@ -43,6 +43,14 @@ class PlatformThemeHandler : IPlatformTheme public Color SplitBarSelectedForeground => VSColorTheme.GetThemedColor(EnvironmentColors.FileTabSelectedTextColorKey).ToEto(); + public Color DesignerBorder => VSColorTheme.GetThemedColor(EnvironmentColors.ToolWindowBorderColorKey).ToEto(); + + public Color SizeGrip => VSColorTheme.GetThemedColor(EnvironmentColors.FileTabSelectedBorderColorKey).ToEto(); + + public Color SizeLabelBackground => VSColorTheme.GetThemedColor(CommonControlsColors.ButtonColorKey).ToEto(); + + public Color SizeLabelForeground => VSColorTheme.GetThemedColor(CommonControlsColors.ButtonTextColorKey).ToEto(); + public IEnumerable AllColors { get