From 1507f7a36d6f6200cbda226a8fb06a363ec39a49 Mon Sep 17 00:00:00 2001
From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com>
Date: Sun, 4 Oct 2026 19:04:02 +0200
Subject: [PATCH] Pause plugins when covered by other window
---
.gitmodules | 3 +
lib/modio.net | 1 +
.../BuiltInPlugins/BenchmarkPlugin.cs | 11 +-
.../BuiltInPlugins/SkiaBenchmarkPlugin.cs | 39 +-
src/DesktopMagic/BuiltInPlugins/TimePlugin.cs | 68 ++--
src/DesktopMagic/Helpers/OcclusionMonitor.cs | 356 ++++++++++++++++++
src/DesktopMagic/Helpers/Win32Wrapper.cs | 32 ++
src/DesktopMagic/MainWindow.xaml.cs | 8 +
src/DesktopMagic/Plugins/IPluginWindow.cs | 5 +
src/DesktopMagic/Plugins/PluginData.cs | 13 +
src/DesktopMagic/Plugins/PluginWindow.xaml.cs | 268 ++++++++++---
.../Plugins/WebPluginWindow.xaml.cs | 17 +
src/DesktopMagicPluginAPI/IPluginData.cs | 8 +-
src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs | 12 +-
src/DesktopMagicPluginAPI/SkiaPlugin.cs | 11 +-
15 files changed, 744 insertions(+), 108 deletions(-)
create mode 100644 .gitmodules
create mode 160000 lib/modio.net
create mode 100644 src/DesktopMagic/Helpers/OcclusionMonitor.cs
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();
}