diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..60b8c6f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/modio.net"] + path = lib/modio.net + url = https://github.com/nickelc/modio.net.git diff --git a/lib/modio.net b/lib/modio.net new file mode 160000 index 0000000..cc684b1 --- /dev/null +++ b/lib/modio.net @@ -0,0 +1 @@ +Subproject commit cc684b198febed1e01de44fd6c365954167558ba diff --git a/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs b/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs index fee899b..e1939e5 100644 --- a/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs @@ -9,9 +9,6 @@ namespace DesktopMagic.BuiltInPlugins; internal class BenchmarkPlugin : Plugin { - private const int BitmapWidth = 400; - private const int BitmapHeight = 300; - private readonly Random rng = new(42); private readonly object rngLock = new(); @@ -28,7 +25,7 @@ internal class BenchmarkPlugin : Plugin [Setting("fps-counter", "Fps")] private readonly Label fpsCounter = new(""); - public override int UpdateInterval => 16; + public override int UpdateInterval => 1; public override Bitmap? Main() { @@ -40,8 +37,10 @@ internal class BenchmarkPlugin : Plugin } lastFrameMs = now; - int w = BitmapWidth; - int h = BitmapHeight; + // Render at the full device-pixel window size so both benchmarks cover the + // same on-screen area at the same resolution for a fair comparison. + int w = Application.WindowPixelSize.Width; + int h = Application.WindowPixelSize.Height; Bitmap bmp = new Bitmap(w, h); using Graphics g = Graphics.FromImage(bmp); diff --git a/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs b/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs index 814e2c5..79a692f 100644 --- a/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs @@ -4,6 +4,7 @@ using DesktopMagic.Api.Settings; using SkiaSharp; using System; +using System.Drawing; namespace DesktopMagic.BuiltInPlugins; @@ -22,9 +23,23 @@ internal class SkiaBenchmarkPlugin : SkiaPlugin [Setting("skia-shape-type", "Shape Type")] private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed"); - public override int UpdateInterval => 16; + [Setting("skia-fps-counter", "Fps")] + private readonly Label fpsCounter = new(""); - public override void Main(SKCanvas canvas) + public override int UpdateInterval => 1; + + SKSurface surface; + + + public override void Start() + { + Size pixelSize = Application.WindowPixelSize; + int w = Math.Max(1, pixelSize.Width); + int h = Math.Max(1, pixelSize.Height); + surface = SKSurface.Create(new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Premul)); + } + + public override SKImage? Render() { long now = frameTimer.ElapsedMilliseconds; if (lastFrameMs > 0) @@ -34,8 +49,14 @@ internal class SkiaBenchmarkPlugin : SkiaPlugin } lastFrameMs = now; - int w = (int)canvas.DeviceClipBounds.Width; - int h = (int)canvas.DeviceClipBounds.Height; + // Render at the full device-pixel window size so the benchmark measures + // real on-screen resolution instead of being scaled down. + Size pixelSize = Application.WindowPixelSize; + int w = Math.Max(1, pixelSize.Width); + int h = Math.Max(1, pixelSize.Height); + + + SKCanvas canvas = surface.Canvas; canvas.Clear(SKColors.Transparent); @@ -112,12 +133,8 @@ internal class SkiaBenchmarkPlugin : SkiaPlugin } } - using var fpsFont = new SKFont(SKTypeface.Default, 14); - using var fpsPaint = new SKPaint - { - Color = color, - IsAntialias = false - }; - canvas.DrawText($"FPS: {fps:F1} | Shapes: {count} | Type: {type}", 8, 18, SKTextAlign.Left, fpsFont, fpsPaint); + fpsCounter.Value = fps.ToString(); + + return surface.Snapshot(); } } diff --git a/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs b/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs index c8030be..64a06c3 100644 --- a/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs @@ -1,62 +1,56 @@ using DesktopMagic.Api; using DesktopMagic.Api.Settings; +using SkiaSharp; + using System; -using System.Drawing; -using System.Drawing.Text; namespace DesktopMagic.BuiltInPlugins; -internal class TimePlugin : Plugin +internal class TimePlugin : SkiaPlugin { + private const float FontSize = 200; + private const float ContentPadding = 8; + [Setting("display-seconds", "Show Seconds")] private readonly CheckBox displaySecondsCheckBox = new CheckBox(true); - private string oldTime = string.Empty; - private bool themeChanged; - public override int UpdateInterval => 1000; - public override Bitmap? Main() + public override SKImage? Render() { - string time = displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString(); + string time = GetTime(); - if (oldTime == time && !themeChanged) + using SKTypeface typeface = SKTypeface.FromFamilyName(Application.Theme.Font); + using SKFont font = new(typeface, FontSize); + using SKPaint paint = new() { - return null; - } + Color = new SKColor( + Application.Theme.PrimaryColor.R, + Application.Theme.PrimaryColor.G, + Application.Theme.PrimaryColor.B, + Application.Theme.PrimaryColor.A), + IsAntialias = true + }; - oldTime = time; - themeChanged = false; + float textWidth = font.MeasureText(time, paint); + SKFontMetrics metrics = font.Metrics; + float textHeight = metrics.Descent - metrics.Ascent; - using Font font = new Font(Application.Theme.Font, 200); + int width = (int)Math.Ceiling(textWidth) + (int)(ContentPadding * 2); + int height = (int)Math.Ceiling(textHeight) + (int)(ContentPadding * 2); - Bitmap bmp = new Bitmap(1, 1); - bmp.SetResolution(100, 100); - using Graphics tmpGr = Graphics.FromImage(bmp); - tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias; + using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul)); + surface.Canvas.Clear(SKColors.Transparent); - SizeF size = tmpGr.MeasureString(time, font); + // Draw the text at a fixed size into a content-sized image; the window scales it to fit. + surface.Canvas.DrawText(time, ContentPadding, -metrics.Ascent + ContentPadding, SKTextAlign.Left, font, paint); - bmp = new Bitmap((int)size.Width, (int)size.Height); - bmp.SetResolution(100, 100); - - using Graphics gr = Graphics.FromImage(bmp); - using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor); - - gr.TextRenderingHint = TextRenderingHint.AntiAlias; - gr.DrawString(time, font, brush, 0, 0); - - return bmp; + return surface.Snapshot(); } - public override void OnThemeChanged() + private string GetTime() { - themeChanged = true; + return displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString(); } - - public override void OnSettingsChanged() - { - themeChanged = true; - } -} \ No newline at end of file +} diff --git a/src/DesktopMagic/Helpers/OcclusionMonitor.cs b/src/DesktopMagic/Helpers/OcclusionMonitor.cs new file mode 100644 index 0000000..8a466d6 --- /dev/null +++ b/src/DesktopMagic/Helpers/OcclusionMonitor.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading; +using System.Windows; +using System.Windows.Interop; + +namespace DesktopMagic.Helpers; + +/// +/// Periodically checks whether plugin windows are occluded by other top-level windows and pauses +/// their rendering while they are (a coverage-based pause algorithm, same idea as Lively's). +/// Pausing stops the plugin update timer, which stops re-rendering and re-uploading frames to the +/// GPU, so fully covered widgets consume almost no resources. +/// +internal sealed class OcclusionMonitor : IDisposable +{ + private const int CheckIntervalMs = 100; + + // Pause only when essentially the whole window is covered so widgets stay responsive even + // when a corner is overlapped by another window. + private const double CoverageThreshold = 0.95; + + private readonly System.Timers.Timer timer = new(CheckIntervalMs); + private readonly Dictionary lastPaused = []; + private readonly object gate = new(); + private bool disposed; + + public bool IsRunning { get; private set; } + + public void Start() + { + if (IsRunning) + { + return; + } + + timer.Elapsed += Timer_Elapsed; + timer.Start(); + IsRunning = true; + } + + private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + if (disposed) + { + return; + } + + // System.Timers.Timer can re-enter this handler on another ThreadPool thread when the + // previous invocation is still blocked in Dispatcher.Invoke, so serialize the whole pass. + if (!Monitor.TryEnter(gate)) + { + return; + } + + try + { + RunOcclusionPass(); + } + finally + { + Monitor.Exit(gate); + } + } + + private void RunOcclusionPass() + { + uint currentProcessId = (uint)Environment.ProcessId; + + // The manager mutates PluginWindows on the UI thread, and the window handle can only be + // obtained there, so snapshot (window, handle) pairs over the dispatcher. + List<(PluginWindow Window, nint Hwnd)> windows; + try + { + windows = Application.Current.Dispatcher.Invoke(() => Manager.Instance.PluginWindows + .OfType() + .Where(window => window.IsRunning) + .Select(window => (Window: window, Hwnd: new WindowInteropHelper(window).Handle)) + .Where(item => item.Hwnd != nint.Zero) + .ToList()); + } + catch + { + return; + } + + HashSet live = [.. windows.Select(item => item.Window)]; + + foreach ((PluginWindow window, nint hwnd) in windows) + { + bool paused; + + try + { + if (!W32.GetWindowRect(hwnd, out W32.RECT windowRect)) + { + continue; + } + + Rectangle windowArea = windowRect; + double coverage = ComputeCoverage(windowArea, hwnd, currentProcessId); + bool foregroundCovers = IsForegroundCovering(windowArea, hwnd); + + paused = foregroundCovers || coverage >= CoverageThreshold; + } + catch + { + continue; + } + + if (lastPaused.TryGetValue(window, out bool previous) && previous == paused) + { + continue; + } + + lastPaused[window] = paused; + + try + { + _ = window.Dispatcher.InvokeAsync(() => window.SetRenderPaused(paused)); + } + catch + { + // Window is closing; ignore. + } + } + + // Drop tracking for windows that no longer exist. + foreach (PluginWindow stale in lastPaused.Keys.Where(key => !live.Contains(key)).ToArray()) + { + _ = lastPaused.Remove(stale); + } + } + + /// + /// Computes how much of (in virtual screen coordinates) is covered by + /// visible top-level windows from other processes that are above it in z-order. + /// + private static double ComputeCoverage(Rectangle target, nint selfHwnd, uint currentProcessId) + { + List coveringRects = []; + + _ = W32.EnumWindows((hwnd, _) => + { + if (hwnd == selfHwnd || !W32.IsWindowVisible(hwnd)) + { + return true; + } + + // Only windows from other processes occlude our widgets; only non-cloaked windows are + // actually visible (exclusive-fullscreen games cloak the desktop). + if (W32.GetWindowThreadProcessId(hwnd, out uint processId) == 0 + || processId == currentProcessId + || IsCloaked(hwnd) + || !W32.GetWindowRect(hwnd, out W32.RECT rect)) + { + return true; + } + + // Explorer's desktop/wallpaper host windows (WorkerW, Progman, ...) report as visible + // and span the whole screen but never occlude anything, so ignore them. + if (IsDesktopShellWindow(hwnd)) + { + return true; + } + + // Fully transparent layered overlays (game overlays etc.) span the whole screen but + // cannot be seen, so they never occlude anything. + if (IsFullyTransparent(hwnd)) + { + return true; + } + + // Ignore windows that are behind ours in z-order. + if (!IsWindowAbove(hwnd, selfHwnd)) + { + return true; + } + + Rectangle intersection = Rectangle.Intersect(target, rect); + if (intersection.Width > 0 && intersection.Height > 0) + { + coveringRects.Add(intersection); + } + + return true; + }, nint.Zero); + + if (coveringRects.Count == 0 || target.Width <= 0 || target.Height <= 0) + { + return 0; + } + + return UnionArea(coveringRects) / (double)(target.Width * target.Height); + } + + private static bool IsForegroundCovering(Rectangle target, nint selfHwnd) + { + nint foreground = W32.GetForegroundWindow(); + if (foreground == nint.Zero || foreground == selfHwnd) + { + return false; + } + + if (W32.GetWindowThreadProcessId(foreground, out _) == 0) + { + return false; + } + + // A maximized/fullscreen app from another process covering the widget pauses it. + _ = W32.GetWindowRect(foreground, out W32.RECT rect); + Rectangle intersection = Rectangle.Intersect(target, rect); + + return target.Width > 0 && target.Height > 0 + && intersection.Width >= target.Width * CoverageThreshold + && intersection.Height >= target.Height * CoverageThreshold; + } + + private static bool IsCloaked(nint hwnd) + { + return W32.DwmGetWindowAttribute(hwnd, W32.DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0; + } + + /// + /// Returns true for layered windows that are fully transparent (alpha 0), i.e. invisible + /// overlay hosts that span the screen but never visibly occlude anything. + /// + private static bool IsFullyTransparent(nint hwnd) + { + if ((W32.GetWindowLong(hwnd, W32.GWL_EXSTYLE) & W32.WS_EX_LAYERED) == 0) + { + return false; + } + + return W32.GetLayeredWindowAttributes(hwnd, out _, out byte alpha, out int flags) + && (flags & W32.LWA_ALPHA) != 0 + && alpha == 0; + } + + /// + /// Returns true for the Explorer desktop/wallpaper host windows that span the whole screen but + /// never visually occlude anything (wallpaper, hidden desktop icon layer, ...). + /// + private static bool IsDesktopShellWindow(nint hwnd) + { + StringBuilder className = new(256); + _ = W32.GetClassName(hwnd, className, className.Capacity); + + return className.ToString() is "WorkerW" or "Progman" or "SHELLDLL_DefView" or "SysListView32"; + } + + /// + /// Returns true when is above in z-order, i.e. + /// when walking toward the front (GW_HWNDPREV) from we reach + /// . + /// + private static bool IsWindowAbove(nint other, nint self) + { + nint current = self; + while ((current = W32.GetWindow(current, W32.GW_HWNDPREV)) != nint.Zero) + { + if (current == other) + { + return true; + } + } + + return false; + } + + /// + /// Computes the total (union) area of using a y-scanline sweep so + /// overlapping windows are not double counted. + /// + private static long UnionArea(List rects) + { + if (rects.Count == 0) + { + return 0; + } + + List yEdges = []; + foreach (Rectangle rect in rects) + { + yEdges.Add(rect.Top); + yEdges.Add(rect.Bottom); + } + yEdges.Sort(); + + long area = 0; + for (int i = 0; i < yEdges.Count - 1; i++) + { + int y0 = yEdges[i]; + int y1 = yEdges[i + 1]; + if (y0 == y1) + { + continue; + } + + // Collect the x extents of all rects that span this band, then merge them. + List<(int Left, int Right)> xRanges = []; + foreach (Rectangle rect in rects) + { + if (rect.Top <= y0 && rect.Bottom >= y1) + { + xRanges.Add((rect.Left, rect.Right)); + } + } + + if (xRanges.Count == 0) + { + continue; + } + + xRanges.Sort((a, b) => a.Left.CompareTo(b.Left)); + int mergedLeft = xRanges[0].Left; + int mergedRight = xRanges[0].Right; + long bandWidth = 0; + + for (int j = 1; j < xRanges.Count; j++) + { + if (xRanges[j].Left <= mergedRight) + { + mergedRight = Math.Max(mergedRight, xRanges[j].Right); + } + else + { + bandWidth += mergedRight - mergedLeft; + mergedLeft = xRanges[j].Left; + mergedRight = xRanges[j].Right; + } + } + bandWidth += mergedRight - mergedLeft; + + area += bandWidth * (y1 - y0); + } + + return area; + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + timer.Elapsed -= Timer_Elapsed; + timer.Stop(); + timer.Dispose(); + } +} \ No newline at end of file diff --git a/src/DesktopMagic/Helpers/Win32Wrapper.cs b/src/DesktopMagic/Helpers/Win32Wrapper.cs index 6c46996..d950990 100644 --- a/src/DesktopMagic/Helpers/Win32Wrapper.cs +++ b/src/DesktopMagic/Helpers/Win32Wrapper.cs @@ -10,6 +10,18 @@ internal class W32 public const int SM_CYSCREEN = 1; + public const int DWMWA_CLOAKED = 14; + + public const int GWL_EXSTYLE = -20; + + public const uint GW_HWNDNEXT = 2; + + public const uint GW_HWNDPREV = 3; + + public const int LWA_ALPHA = 0x2; + + public const long WS_EX_LAYERED = 0x00080000L; + public const int SPI_SETDESKWALLPAPER = 20; public const int SPIF_SENDWININICHANGE = 0x02; public const int SPIF_UPDATEINIFILE = 0x01; @@ -55,6 +67,26 @@ internal class W32 [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int attrValue, int attrSize); + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(nint hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetWindowRect(nint hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + public static extern int GetWindowLong(nint hWnd, int nIndex); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetLayeredWindowAttributes(nint hWnd, out int colorKey, out byte alpha, out int dwFlags); + + [DllImport("user32.dll")] + public static extern nint GetWindow(nint hWnd, uint uCmd); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(nint hWnd, out uint processId); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam); diff --git a/src/DesktopMagic/MainWindow.xaml.cs b/src/DesktopMagic/MainWindow.xaml.cs index 41d163c..2ca6e78 100644 --- a/src/DesktopMagic/MainWindow.xaml.cs +++ b/src/DesktopMagic/MainWindow.xaml.cs @@ -19,6 +19,7 @@ public partial class MainWindow : FluentWindow { private readonly Manager _manager = Manager.Instance; private readonly MainWindowDataContext _mainWindowDataContext = new(); + private OcclusionMonitor? occlusionMonitor; [Obsolete] public MainWindow() @@ -49,6 +50,10 @@ public partial class MainWindow : FluentWindow _manager.LoadPlugins(); _manager.LoadLayout(); + // Pause plugin rendering while widgets are fully occluded by other windows. + occlusionMonitor = new OcclusionMonitor(); + occlusionMonitor.Start(); + if (_manager.Settings.IsFirstRun) { _manager.Settings.IsFirstRun = false; @@ -103,6 +108,9 @@ public partial class MainWindow : FluentWindow { Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged; + occlusionMonitor?.Dispose(); + occlusionMonitor = null; + Visibility = Visibility.Collapsed; UpdateLayout(); _manager.CloseAllPluginWindows(); diff --git a/src/DesktopMagic/Plugins/IPluginWindow.cs b/src/DesktopMagic/Plugins/IPluginWindow.cs index d840b7f..6b05dc5 100644 --- a/src/DesktopMagic/Plugins/IPluginWindow.cs +++ b/src/DesktopMagic/Plugins/IPluginWindow.cs @@ -24,5 +24,10 @@ public interface IPluginWindow void ApplySettingValue(string id, string value); void ApplyButtonClick(string id); + /// + /// Pauses or resumes rendering of the plugin window (e.g. when it is fully occluded by other windows). + /// + void SetRenderPaused(bool paused); + event System.ComponentModel.CancelEventHandler Closing; } diff --git a/src/DesktopMagic/Plugins/PluginData.cs b/src/DesktopMagic/Plugins/PluginData.cs index 92fd242..e0caa1e 100644 --- a/src/DesktopMagic/Plugins/PluginData.cs +++ b/src/DesktopMagic/Plugins/PluginData.cs @@ -3,6 +3,7 @@ using DesktopMagic.Api; using DesktopMagic.Settings; +using System; using System.Drawing; using Wpf.Ui.Controls; @@ -12,11 +13,23 @@ namespace DesktopMagic.Plugins; internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData { private readonly PluginWindow window = window; + private Size windowPixelSize = new(1, 1); public ITheme Theme => pluginSettings.Theme; public Size WindowSize => new Size((int)window.ActualWidth, (int)window.ActualHeight); + public Size WindowPixelSize => windowPixelSize; + + /// + /// Refreshes the cached device-pixel window size. Must be called from the UI thread + /// so the value can be read safely by plugins on their render thread. + /// + internal void UpdateWindowPixelSize(int width, int height) + { + windowPixelSize = new Size(Math.Max(1, width), Math.Max(1, height)); + } + public Point WindowPosition => new Point((int)window.Left, (int)window.Top); public string PluginName => window.PluginMetadata.Name; diff --git a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs index bcc0205..899f851 100644 --- a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs @@ -40,12 +40,14 @@ public partial class PluginWindow : Window, IPluginWindow private Thread? pluginThread; private System.Timers.Timer? updateTimer; private Plugin? pluginClassInstance; + private PluginData? pluginData; private AssemblyLoadContext assemblyLoadContext; private readonly Rectangle screenBounds; private readonly string screenDeviceName; private bool isUpdatingPosition = false; private bool _editMode = false; + private volatile bool renderPaused = false; private CancellationTokenSource? pluginCancellationTokenSource; private FileSystemWatcher? pluginFileWatcher; @@ -55,6 +57,18 @@ public partial class PluginWindow : Window, IPluginWindow private WriteableBitmap? writeableBitmap; private BitmapScalingMode lastBitmapScalingMode = BitmapScalingMode.Unspecified; + // Last presented frame bytes; used to skip re-uploading identical frames so WPF does not + // re-upload the texture or re-composite the window (GPU stays idle for static widgets). + private byte[]? lastFramePixels; + private int lastFrameWidth; + private int lastFrameHeight; + private int lastFrameStride; + + // Guards against overlapping render ticks. The timer can re-enter while an async + // render awaits the UI thread, which would run the plugin concurrently and present + // frames out of order. Only one render may be in flight at a time. + private long renderInProgress; + // Event handlers on long-lived settings objects, tracked so they can be unsubscribed on close. private readonly PropertyChangedEventHandler settingsPropertyChangedHandler; private readonly PropertyChangedEventHandler themePropertyChangedHandler; @@ -334,6 +348,21 @@ public partial class PluginWindow : Window, IPluginWindow } } + public void SetRenderPaused(bool paused) + { + renderPaused = paused; + + if (paused) + { + updateTimer?.Stop(); + } + else if (IsRunning && pluginClassInstance is { UpdateInterval: > 0 } && updateTimer is not null) + { + updateTimer.Interval = pluginClassInstance.UpdateInterval; + updateTimer.Start(); + } + } + public void UpdatePluginWindow() { UpdateTimer_Elapsed(updateTimer, null); @@ -400,8 +429,28 @@ public partial class PluginWindow : Window, IPluginWindow try { - Dispatcher.Invoke(() => + int stride = bitmapData.Stride; + int height = bitmapData.Height; + int byteCount = stride * height; + + unsafe { + byte* scan0 = (byte*)bitmapData.Scan0.ToPointer(); + + // Skip the present when the frame is byte-identical to the last presented one so WPF + // does not re-upload the texture or re-composite the window (GPU stays idle for + // static widgets that keep ticking). + if (lastFramePixels is { Length: > 0 } + && lastFrameWidth == bitmapData.Width + && lastFrameHeight == height + && lastFrameStride == stride + && lastFramePixels.AsSpan().SequenceEqual(new ReadOnlySpan(scan0, byteCount))) + { + return; + } + + Dispatcher.Invoke(() => + { if (lastBitmapScalingMode != scalingMode) { RenderOptions.SetBitmapScalingMode(image, scalingMode); @@ -410,9 +459,19 @@ public partial class PluginWindow : Window, IPluginWindow if (writeableBitmap == null || writeableBitmap.PixelWidth != bitmapData.Width || writeableBitmap.PixelHeight != bitmapData.Height) { + // A frame sized to the window maps 1:1 to the display when the bitmap uses the + // actual monitor DPI, so WPF skips the (expensive) per-frame scaling filter. + // Smaller content-sized frames keep 96 DPI to preserve their on-screen semantics. + DpiScale dpi = VisualTreeHelper.GetDpi(this); + bool fullWindowFrame = pluginData != null + && bitmapData.Width == pluginData.WindowPixelSize.Width + && bitmapData.Height == pluginData.WindowPixelSize.Height; + double dpiX = fullWindowFrame ? dpi.DpiScaleX * 96 : bitmap.HorizontalResolution; + double dpiY = fullWindowFrame ? dpi.DpiScaleY * 96 : bitmap.VerticalResolution; + writeableBitmap = new WriteableBitmap( bitmapData.Width, bitmapData.Height, - bitmap.HorizontalResolution, bitmap.VerticalResolution, + dpiX, dpiY, PixelFormats.Bgra32, null); image.Source = writeableBitmap; } @@ -434,7 +493,17 @@ public partial class PluginWindow : Window, IPluginWindow { writeableBitmap.Unlock(); } - }); + }); + + if (lastFramePixels is null || lastFramePixels.Length != byteCount) + { + lastFramePixels = new byte[byteCount]; + } + lastFrameWidth = bitmapData.Width; + lastFrameHeight = height; + lastFrameStride = stride; + new ReadOnlySpan(scan0, byteCount).CopyTo(lastFramePixels); + } } finally { @@ -560,7 +629,8 @@ public partial class PluginWindow : Window, IPluginWindow if (instance is Plugin plugin) { pluginClassInstance = plugin; - pluginClassInstance.Application = new PluginData(this, settings); + pluginData = new PluginData(this, settings); + pluginClassInstance.Application = pluginData; } else { @@ -589,6 +659,8 @@ public partial class PluginWindow : Window, IPluginWindow }; updateTimer.Elapsed += UpdateTimer_Elapsed; + await Dispatcher.InvokeAsync(RefreshWindowPixelSize); + pluginClassInstance.Start(); if (pluginClassInstance is AsyncPlugin asyncPluginStart) { @@ -599,7 +671,7 @@ public partial class PluginWindow : Window, IPluginWindow UpdatePluginWindow(); await Dispatcher.InvokeAsync(ThemeChanged); - if (pluginClassInstance.UpdateInterval > 0) + if (pluginClassInstance.UpdateInterval > 0 && !renderPaused) { updateTimer.Interval = pluginClassInstance.UpdateInterval; updateTimer.Start(); @@ -963,6 +1035,13 @@ public partial class PluginWindow : Window, IPluginWindow return; } + // Drop the tick when a render is still in flight instead of running the plugin + // concurrently (the timer can re-enter while the async render awaits the UI thread). + if (Interlocked.CompareExchange(ref renderInProgress, 1, 0) != 0) + { + return; + } + try { if (plugin is SkiaPlugin or SkiaAsyncPlugin) @@ -1034,6 +1113,10 @@ public partial class PluginWindow : Window, IPluginWindow }); Exit(); } + finally + { + Interlocked.Exchange(ref renderInProgress, 0); + } } private async Task RenderSkiaFrame() @@ -1045,62 +1128,128 @@ public partial class PluginWindow : Window, IPluginWindow return; } - int width = 0; - int height = 0; - - await Dispatcher.InvokeAsync(() => - { - width = Math.Max(1, (int)ActualWidth); - height = Math.Max(1, (int)ActualHeight); - }); - - using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul)); - surface.Canvas.Clear(SKColors.Transparent); + SKImage? skImage; if (plugin is SkiaAsyncPlugin skiaAsyncPlugin) { CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None; - await skiaAsyncPlugin.MainAsync(surface.Canvas, token); + skImage = await skiaAsyncPlugin.RenderAsync(token); } else if (plugin is SkiaPlugin skiaPlugin) { - skiaPlugin.Main(surface.Canvas); + skImage = skiaPlugin.Render(); } - - SKPixmap? pixmap = surface.PeekPixels(); - - if (pixmap is null) + else { return; } - await Dispatcher.InvokeAsync(() => + // Null keeps the previous frame, matching how GDI plugins return null from Main(). + if (skImage is null) { - if (writeableBitmap is null || writeableBitmap.PixelWidth != width || writeableBitmap.PixelHeight != height) + return; + } + + using (skImage) + { + using SKImage raster = skImage.ToRasterImage(); + using SKPixmap? pixmap = raster.PeekPixels(); + + if (pixmap is null) { - writeableBitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null); - image.Source = writeableBitmap; + return; } - writeableBitmap.Lock(); - try + int width = pixmap.Width; + int height = pixmap.Height; + int byteCount = pixmap.RowBytes * height; + + // Skip the present when the frame is byte-identical to the last presented one so WPF + // does not re-upload the texture or re-composite the window (GPU stays idle for + // static widgets that keep ticking). + if (lastFramePixels is { Length: > 0 } + && lastFrameWidth == width + && lastFrameHeight == height + && lastFrameStride == pixmap.RowBytes + && lastFramePixels.AsSpan().SequenceEqual(pixmap.GetPixelSpan())) { - unsafe + return; + } + + BitmapScalingMode scalingMode = plugin.RenderQuality switch + { + RenderQuality.High => BitmapScalingMode.HighQuality, + RenderQuality.Low => BitmapScalingMode.LowQuality, + RenderQuality.Performance => BitmapScalingMode.NearestNeighbor, + _ => BitmapScalingMode.Unspecified + }; + + await Dispatcher.InvokeAsync(() => + { + if (lastBitmapScalingMode != scalingMode) { - Buffer.MemoryCopy( - pixmap.GetPixels().ToPointer(), - writeableBitmap.BackBuffer.ToPointer(), - writeableBitmap.BackBufferStride * height, - pixmap.RowBytes * pixmap.Height); + RenderOptions.SetBitmapScalingMode(image, scalingMode); + lastBitmapScalingMode = scalingMode; } - writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height)); - } - finally + if (writeableBitmap is null || writeableBitmap.PixelWidth != width || writeableBitmap.PixelHeight != height) + { + // A frame sized to the window maps 1:1 to the display when the bitmap uses the + // actual monitor DPI, so WPF skips the (expensive) per-frame scaling filter. + // Smaller content-sized frames keep 96 DPI to preserve their on-screen semantics. + DpiScale dpi = VisualTreeHelper.GetDpi(this); + bool fullWindowFrame = pluginData != null + && width == pluginData.WindowPixelSize.Width + && height == pluginData.WindowPixelSize.Height; + double dpiX = fullWindowFrame ? dpi.DpiScaleX * 96 : 96; + double dpiY = fullWindowFrame ? dpi.DpiScaleY * 96 : 96; + + // Skia outputs premultiplied alpha, so use Pbgra32 (premultiplied BGRA). + // Bgra32 is straight alpha and would render semi-transparent pixels too dark. + writeableBitmap = new WriteableBitmap(width, height, dpiX, dpiY, PixelFormats.Pbgra32, null); + image.Source = writeableBitmap; + } + + writeableBitmap.Lock(); + try + { + int destStride = writeableBitmap.BackBufferStride; + int srcStride = pixmap.RowBytes; + int copyWidth = Math.Min(srcStride, destStride); + int copyRows = Math.Min(pixmap.Height, height); + + unsafe + { + byte* src = (byte*)pixmap.GetPixels().ToPointer(); + byte* dst = (byte*)writeableBitmap.BackBuffer.ToPointer(); + + for (int row = 0; row < copyRows; row++) + { + Buffer.MemoryCopy( + src + (row * srcStride), + dst + (row * destStride), + destStride, + copyWidth); + } + } + + writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height)); + } + finally + { + writeableBitmap.Unlock(); + } + }); + + if (lastFramePixels is null || lastFramePixels.Length != byteCount) { - writeableBitmap.Unlock(); + lastFramePixels = new byte[byteCount]; } - }); + lastFrameWidth = width; + lastFrameHeight = height; + lastFrameStride = pixmap.RowBytes; + pixmap.GetPixelSpan().CopyTo(lastFramePixels); + } } private void Border_SizeChanged(object sender, SizeChangedEventArgs e) @@ -1257,6 +1406,7 @@ public partial class PluginWindow : Window, IPluginWindow } settings.Position = ScreenUtilities.PositionToPercent(new System.Windows.Point(Left, Top), screenBounds); + RefreshWindowPixelSize(); } private void Window_SizeChanged(object sender, SizeChangedEventArgs e) @@ -1267,6 +1417,13 @@ public partial class PluginWindow : Window, IPluginWindow } tileBar.CaptionHeight = ActualHeight - 10; + RefreshWindowPixelSize(); + } + + private void RefreshWindowPixelSize() + { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + pluginData?.UpdateWindowPixelSize((int)Math.Ceiling(ActualWidth * dpi.DpiScaleX), (int)Math.Ceiling(ActualHeight * dpi.DpiScaleY)); } private void Image_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e) @@ -1275,8 +1432,15 @@ public partial class PluginWindow : Window, IPluginWindow ImageSource imageSource = image.Source; BitmapSource bitmapImage = (BitmapSource)imageSource; - double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight; - double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight; + double actualWidth = image.ActualWidth; + double actualHeight = image.ActualHeight; + if (actualWidth <= 0 || actualHeight <= 0) + { + return; + } + + double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / actualWidth; + double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / actualHeight; MouseButton mouseButton; @@ -1306,8 +1470,15 @@ public partial class PluginWindow : Window, IPluginWindow { ImageSource imageSource = image.Source; BitmapSource bitmapImage = (BitmapSource)imageSource; - double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight; - double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight; + double actualWidth = image.ActualWidth; + double actualHeight = image.ActualHeight; + if (actualWidth <= 0 || actualHeight <= 0) + { + return; + } + + double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / actualWidth; + double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / actualHeight; System.Drawing.Point point = new System.Drawing.Point((int)pixelMousePositionX, (int)pixelMousePositionY); pluginClassInstance?.OnMouseMove(point); @@ -1317,8 +1488,15 @@ public partial class PluginWindow : Window, IPluginWindow { ImageSource imageSource = image.Source; BitmapSource bitmapImage = (BitmapSource)imageSource; - double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight; - double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight; + double actualWidth = image.ActualWidth; + double actualHeight = image.ActualHeight; + if (actualWidth <= 0 || actualHeight <= 0) + { + return; + } + + double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / actualWidth; + double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / actualHeight; System.Drawing.Point point = new System.Drawing.Point((int)pixelMousePositionX, (int)pixelMousePositionY); pluginClassInstance?.OnMouseWheel(point, e.Delta); diff --git a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs index a33b5c2..2987563 100644 --- a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs @@ -194,6 +194,23 @@ public partial class WebPluginWindow : Window, IPluginWindow } } + public void SetRenderPaused(bool paused) + { + if (webView.CoreWebView2 is null) + { + return; + } + + if (paused) + { + _ = webView.CoreWebView2.TrySuspendAsync(); + } + else + { + webView.CoreWebView2.Resume(); + } + } + protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); diff --git a/src/DesktopMagicPluginAPI/IPluginData.cs b/src/DesktopMagicPluginAPI/IPluginData.cs index 10a6639..bd1478e 100644 --- a/src/DesktopMagicPluginAPI/IPluginData.cs +++ b/src/DesktopMagicPluginAPI/IPluginData.cs @@ -13,10 +13,16 @@ public interface IPluginData ITheme Theme { get; } /// - /// Gets the window size of the plugin window. + /// Gets the window size of the plugin window in device independent pixels. /// Size WindowSize { get; } + /// + /// Gets the window size of the plugin window in physical (device) pixels. + /// Use this to create a rendering surface that maps 1:1 to the display. + /// + Size WindowPixelSize { get; } + /// /// Gets the window position of the plugin window. /// diff --git a/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs b/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs index b46b0a4..1f6a3ad 100644 --- a/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs +++ b/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs @@ -8,14 +8,17 @@ namespace DesktopMagic.Api; /// /// Provides an abstract base class for creating asynchronous plugins using SkiaSharp rendering. -/// Plugins override instead of . +/// Plugins override and return an +/// of their own size, just like returns a +/// . The returned image is scaled to fit the plugin window while preserving +/// its aspect ratio. /// All existing , , and code is unaffected. /// public abstract class SkiaAsyncPlugin : AsyncPlugin { /// /// This method is sealed and returns null for async Skia plugins. - /// Override instead. + /// Override instead. /// public sealed override Task MainAsync(CancellationToken cancellationToken) { @@ -24,8 +27,9 @@ public abstract class SkiaAsyncPlugin : AsyncPlugin /// /// Called every milliseconds to render the plugin asynchronously. + /// Return to keep the previously rendered image. /// - /// The SkiaSharp canvas to draw on. /// Token signaled when the host requests cancellation. - public abstract Task MainAsync(SKCanvas canvas, CancellationToken cancellationToken); + /// The rendered image, or to keep the previous frame. + public abstract Task RenderAsync(CancellationToken cancellationToken); } diff --git a/src/DesktopMagicPluginAPI/SkiaPlugin.cs b/src/DesktopMagicPluginAPI/SkiaPlugin.cs index a53f79c..09cc8d9 100644 --- a/src/DesktopMagicPluginAPI/SkiaPlugin.cs +++ b/src/DesktopMagicPluginAPI/SkiaPlugin.cs @@ -6,14 +6,16 @@ namespace DesktopMagic.Api; /// /// Provides an abstract base class for creating plugins using SkiaSharp rendering. -/// Plugins override instead of . +/// Plugins override and return an of their own size, +/// just like returns a . The returned image is +/// scaled to fit the plugin window while preserving its aspect ratio. /// All existing and code is unaffected. /// public abstract class SkiaPlugin : Plugin { /// /// This method is sealed and returns null for Skia plugins. - /// Override instead. + /// Override instead. /// /// Always null. public sealed override Bitmap? Main() @@ -23,7 +25,8 @@ public abstract class SkiaPlugin : Plugin /// /// Called every milliseconds to render the plugin. + /// Return to keep the previously rendered image. /// - /// The SkiaSharp canvas to draw on. - public abstract void Main(SKCanvas canvas); + /// The rendered image, or to keep the previous frame. + public abstract SKImage? Render(); }