Pause plugins when covered by other window

This commit is contained in:
Stone_Red
2026-10-04 19:04:02 +02:00
parent 4ba39c67ce
commit 1507f7a36d
15 changed files with 744 additions and 108 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "lib/modio.net"]
path = lib/modio.net
url = https://github.com/nickelc/modio.net.git
Submodule
+1
Submodule lib/modio.net added at cc684b198f
@@ -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);
@@ -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();
}
}
+35 -41
View File
@@ -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
};
float textWidth = font.MeasureText(time, paint);
SKFontMetrics metrics = font.Metrics;
float textHeight = metrics.Descent - metrics.Ascent;
int width = (int)Math.Ceiling(textWidth) + (int)(ContentPadding * 2);
int height = (int)Math.Ceiling(textHeight) + (int)(ContentPadding * 2);
using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul));
surface.Canvas.Clear(SKColors.Transparent);
// 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);
return surface.Snapshot();
}
oldTime = time;
themeChanged = false;
using Font font = new Font(Application.Theme.Font, 200);
Bitmap bmp = new Bitmap(1, 1);
bmp.SetResolution(100, 100);
using Graphics tmpGr = Graphics.FromImage(bmp);
tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias;
SizeF size = tmpGr.MeasureString(time, font);
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;
}
public override void OnThemeChanged()
private string GetTime()
{
themeChanged = true;
}
public override void OnSettingsChanged()
{
themeChanged = true;
return displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString();
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<PluginWindow, bool> 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<PluginWindow>()
.Where(window => window.IsRunning)
.Select(window => (Window: window, Hwnd: new WindowInteropHelper(window).Handle))
.Where(item => item.Hwnd != nint.Zero)
.ToList());
}
catch
{
return;
}
HashSet<PluginWindow> 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);
}
}
/// <summary>
/// Computes how much of <paramref name="target"/> (in virtual screen coordinates) is covered by
/// visible top-level windows from other processes that are above it in z-order.
/// </summary>
private static double ComputeCoverage(Rectangle target, nint selfHwnd, uint currentProcessId)
{
List<Rectangle> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Returns true for the Explorer desktop/wallpaper host windows that span the whole screen but
/// never visually occlude anything (wallpaper, hidden desktop icon layer, ...).
/// </summary>
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";
}
/// <summary>
/// Returns true when <paramref name="other"/> is above <paramref name="self"/> in z-order, i.e.
/// when walking toward the front (GW_HWNDPREV) from <paramref name="self"/> we reach
/// <paramref name="other"/>.
/// </summary>
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;
}
/// <summary>
/// Computes the total (union) area of <paramref name="rects"/> using a y-scanline sweep so
/// overlapping windows are not double counted.
/// </summary>
private static long UnionArea(List<Rectangle> rects)
{
if (rects.Count == 0)
{
return 0;
}
List<int> 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();
}
}
+32
View File
@@ -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);
+8
View File
@@ -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();
@@ -24,5 +24,10 @@ public interface IPluginWindow
void ApplySettingValue(string id, string value);
void ApplyButtonClick(string id);
/// <summary>
/// Pauses or resumes rendering of the plugin window (e.g. when it is fully occluded by other windows).
/// </summary>
void SetRenderPaused(bool paused);
event System.ComponentModel.CancelEventHandler Closing;
}
+13
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
+206 -28
View File
@@ -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,6 +429,26 @@ public partial class PluginWindow : Window, IPluginWindow
try
{
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<byte>(scan0, byteCount)))
{
return;
}
Dispatcher.Invoke(() =>
{
if (lastBitmapScalingMode != 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;
}
@@ -435,6 +494,16 @@ 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<byte>(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,53 +1128,109 @@ 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();
}
else
{
return;
}
SKPixmap? pixmap = surface.PeekPixels();
// Null keeps the previous frame, matching how GDI plugins return null from Main().
if (skImage is null)
{
return;
}
using (skImage)
{
using SKImage raster = skImage.ToRasterImage();
using SKPixmap? pixmap = raster.PeekPixels();
if (pixmap is null)
{
return;
}
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()))
{
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)
{
RenderOptions.SetBitmapScalingMode(image, scalingMode);
lastBitmapScalingMode = scalingMode;
}
if (writeableBitmap is null || writeableBitmap.PixelWidth != width || writeableBitmap.PixelHeight != height)
{
writeableBitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
// 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(
pixmap.GetPixels().ToPointer(),
writeableBitmap.BackBuffer.ToPointer(),
writeableBitmap.BackBufferStride * height,
pixmap.RowBytes * pixmap.Height);
src + (row * srcStride),
dst + (row * destStride),
destStride,
copyWidth);
}
}
writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
@@ -1101,6 +1240,16 @@ public partial class PluginWindow : Window, IPluginWindow
writeableBitmap.Unlock();
}
});
if (lastFramePixels is null || lastFramePixels.Length != byteCount)
{
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);
@@ -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);
+7 -1
View File
@@ -13,10 +13,16 @@ public interface IPluginData
ITheme Theme { get; }
/// <summary>
/// Gets the window size of the plugin window.
/// Gets the window size of the plugin window in device independent pixels.
/// </summary>
Size WindowSize { get; }
/// <summary>
/// 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.
/// </summary>
Size WindowPixelSize { get; }
/// <summary>
/// Gets the window position of the plugin window.
/// </summary>
+8 -4
View File
@@ -8,14 +8,17 @@ namespace DesktopMagic.Api;
/// <summary>
/// Provides an abstract base class for creating asynchronous plugins using SkiaSharp rendering.
/// Plugins override <see cref="MainAsync(SKCanvas, CancellationToken)"/> instead of <see cref="Plugin.Main()"/>.
/// Plugins override <see cref="RenderAsync(CancellationToken)"/> and return an <see cref="SKImage"/>
/// of their own size, just like <see cref="AsyncPlugin.MainAsync(CancellationToken)"/> returns a
/// <see cref="Bitmap"/>. The returned image is scaled to fit the plugin window while preserving
/// its aspect ratio.
/// All existing <see cref="Plugin"/>, <see cref="AsyncPlugin"/>, and <see cref="SkiaPlugin"/> code is unaffected.
/// </summary>
public abstract class SkiaAsyncPlugin : AsyncPlugin
{
/// <summary>
/// This method is sealed and returns null for async Skia plugins.
/// Override <see cref="MainAsync(SKCanvas, CancellationToken)"/> instead.
/// Override <see cref="RenderAsync(CancellationToken)"/> instead.
/// </summary>
public sealed override Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
{
@@ -24,8 +27,9 @@ public abstract class SkiaAsyncPlugin : AsyncPlugin
/// <summary>
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin asynchronously.
/// Return <see langword="null"/> to keep the previously rendered image.
/// </summary>
/// <param name="canvas">The SkiaSharp canvas to draw on.</param>
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</param>
public abstract Task MainAsync(SKCanvas canvas, CancellationToken cancellationToken);
/// <returns>The rendered image, or <see langword="null"/> to keep the previous frame.</returns>
public abstract Task<SKImage?> RenderAsync(CancellationToken cancellationToken);
}
+7 -4
View File
@@ -6,14 +6,16 @@ namespace DesktopMagic.Api;
/// <summary>
/// Provides an abstract base class for creating plugins using SkiaSharp rendering.
/// Plugins override <see cref="Main(SKCanvas)"/> instead of <see cref="Plugin.Main()"/>.
/// Plugins override <see cref="Render()"/> and return an <see cref="SKImage"/> of their own size,
/// just like <see cref="Plugin.Main()"/> returns a <see cref="Bitmap"/>. The returned image is
/// scaled to fit the plugin window while preserving its aspect ratio.
/// All existing <see cref="Plugin"/> and <see cref="AsyncPlugin"/> code is unaffected.
/// </summary>
public abstract class SkiaPlugin : Plugin
{
/// <summary>
/// This method is sealed and returns null for Skia plugins.
/// Override <see cref="Main(SKCanvas)"/> instead.
/// Override <see cref="Render()"/> instead.
/// </summary>
/// <returns>Always null.</returns>
public sealed override Bitmap? Main()
@@ -23,7 +25,8 @@ public abstract class SkiaPlugin : Plugin
/// <summary>
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin.
/// Return <see langword="null"/> to keep the previously rendered image.
/// </summary>
/// <param name="canvas">The SkiaSharp canvas to draw on.</param>
public abstract void Main(SKCanvas canvas);
/// <returns>The rendered image, or <see langword="null"/> to keep the previous frame.</returns>
public abstract SKImage? Render();
}