mirror of
https://github.com/Stone-Red-Code/DesktopMagic.git
synced 2026-11-01 23:04:16 +01:00
Pause plugins when covered by other window
This commit is contained in:
@@ -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
|
internal class BenchmarkPlugin : Plugin
|
||||||
{
|
{
|
||||||
private const int BitmapWidth = 400;
|
|
||||||
private const int BitmapHeight = 300;
|
|
||||||
|
|
||||||
private readonly Random rng = new(42);
|
private readonly Random rng = new(42);
|
||||||
private readonly object rngLock = new();
|
private readonly object rngLock = new();
|
||||||
|
|
||||||
@@ -28,7 +25,7 @@ internal class BenchmarkPlugin : Plugin
|
|||||||
[Setting("fps-counter", "Fps")]
|
[Setting("fps-counter", "Fps")]
|
||||||
private readonly Label fpsCounter = new("");
|
private readonly Label fpsCounter = new("");
|
||||||
|
|
||||||
public override int UpdateInterval => 16;
|
public override int UpdateInterval => 1;
|
||||||
|
|
||||||
public override Bitmap? Main()
|
public override Bitmap? Main()
|
||||||
{
|
{
|
||||||
@@ -40,8 +37,10 @@ internal class BenchmarkPlugin : Plugin
|
|||||||
}
|
}
|
||||||
lastFrameMs = now;
|
lastFrameMs = now;
|
||||||
|
|
||||||
int w = BitmapWidth;
|
// Render at the full device-pixel window size so both benchmarks cover the
|
||||||
int h = BitmapHeight;
|
// 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);
|
Bitmap bmp = new Bitmap(w, h);
|
||||||
using Graphics g = Graphics.FromImage(bmp);
|
using Graphics g = Graphics.FromImage(bmp);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using DesktopMagic.Api.Settings;
|
|||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
namespace DesktopMagic.BuiltInPlugins;
|
namespace DesktopMagic.BuiltInPlugins;
|
||||||
|
|
||||||
@@ -22,9 +23,23 @@ internal class SkiaBenchmarkPlugin : SkiaPlugin
|
|||||||
[Setting("skia-shape-type", "Shape Type")]
|
[Setting("skia-shape-type", "Shape Type")]
|
||||||
private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed");
|
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;
|
long now = frameTimer.ElapsedMilliseconds;
|
||||||
if (lastFrameMs > 0)
|
if (lastFrameMs > 0)
|
||||||
@@ -34,8 +49,14 @@ internal class SkiaBenchmarkPlugin : SkiaPlugin
|
|||||||
}
|
}
|
||||||
lastFrameMs = now;
|
lastFrameMs = now;
|
||||||
|
|
||||||
int w = (int)canvas.DeviceClipBounds.Width;
|
// Render at the full device-pixel window size so the benchmark measures
|
||||||
int h = (int)canvas.DeviceClipBounds.Height;
|
// 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);
|
canvas.Clear(SKColors.Transparent);
|
||||||
|
|
||||||
@@ -112,12 +133,8 @@ internal class SkiaBenchmarkPlugin : SkiaPlugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
using var fpsFont = new SKFont(SKTypeface.Default, 14);
|
fpsCounter.Value = fps.ToString();
|
||||||
using var fpsPaint = new SKPaint
|
|
||||||
{
|
return surface.Snapshot();
|
||||||
Color = color,
|
|
||||||
IsAntialias = false
|
|
||||||
};
|
|
||||||
canvas.DrawText($"FPS: {fps:F1} | Shapes: {count} | Type: {type}", 8, 18, SKTextAlign.Left, fpsFont, fpsPaint);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,56 @@
|
|||||||
using DesktopMagic.Api;
|
using DesktopMagic.Api;
|
||||||
using DesktopMagic.Api.Settings;
|
using DesktopMagic.Api.Settings;
|
||||||
|
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
|
||||||
using System.Drawing.Text;
|
|
||||||
|
|
||||||
namespace DesktopMagic.BuiltInPlugins;
|
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")]
|
[Setting("display-seconds", "Show Seconds")]
|
||||||
private readonly CheckBox displaySecondsCheckBox = new CheckBox(true);
|
private readonly CheckBox displaySecondsCheckBox = new CheckBox(true);
|
||||||
|
|
||||||
private string oldTime = string.Empty;
|
|
||||||
private bool themeChanged;
|
|
||||||
|
|
||||||
public override int UpdateInterval => 1000;
|
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;
|
private string GetTime()
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
themeChanged = true;
|
return displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString();
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnSettingsChanged()
|
|
||||||
{
|
|
||||||
themeChanged = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,18 @@ internal class W32
|
|||||||
|
|
||||||
public const int SM_CYSCREEN = 1;
|
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 SPI_SETDESKWALLPAPER = 20;
|
||||||
public const int SPIF_SENDWININICHANGE = 0x02;
|
public const int SPIF_SENDWININICHANGE = 0x02;
|
||||||
public const int SPIF_UPDATEINIFILE = 0x01;
|
public const int SPIF_UPDATEINIFILE = 0x01;
|
||||||
@@ -55,6 +67,26 @@ internal class W32
|
|||||||
[DllImport("dwmapi.dll")]
|
[DllImport("dwmapi.dll")]
|
||||||
public static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int attrValue, int attrSize);
|
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")]
|
[DllImport("user32.dll")]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
|
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public partial class MainWindow : FluentWindow
|
|||||||
{
|
{
|
||||||
private readonly Manager _manager = Manager.Instance;
|
private readonly Manager _manager = Manager.Instance;
|
||||||
private readonly MainWindowDataContext _mainWindowDataContext = new();
|
private readonly MainWindowDataContext _mainWindowDataContext = new();
|
||||||
|
private OcclusionMonitor? occlusionMonitor;
|
||||||
|
|
||||||
[Obsolete]
|
[Obsolete]
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
@@ -49,6 +50,10 @@ public partial class MainWindow : FluentWindow
|
|||||||
_manager.LoadPlugins();
|
_manager.LoadPlugins();
|
||||||
_manager.LoadLayout();
|
_manager.LoadLayout();
|
||||||
|
|
||||||
|
// Pause plugin rendering while widgets are fully occluded by other windows.
|
||||||
|
occlusionMonitor = new OcclusionMonitor();
|
||||||
|
occlusionMonitor.Start();
|
||||||
|
|
||||||
if (_manager.Settings.IsFirstRun)
|
if (_manager.Settings.IsFirstRun)
|
||||||
{
|
{
|
||||||
_manager.Settings.IsFirstRun = false;
|
_manager.Settings.IsFirstRun = false;
|
||||||
@@ -103,6 +108,9 @@ public partial class MainWindow : FluentWindow
|
|||||||
{
|
{
|
||||||
Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged;
|
Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged;
|
||||||
|
|
||||||
|
occlusionMonitor?.Dispose();
|
||||||
|
occlusionMonitor = null;
|
||||||
|
|
||||||
Visibility = Visibility.Collapsed;
|
Visibility = Visibility.Collapsed;
|
||||||
UpdateLayout();
|
UpdateLayout();
|
||||||
_manager.CloseAllPluginWindows();
|
_manager.CloseAllPluginWindows();
|
||||||
|
|||||||
@@ -24,5 +24,10 @@ public interface IPluginWindow
|
|||||||
void ApplySettingValue(string id, string value);
|
void ApplySettingValue(string id, string value);
|
||||||
void ApplyButtonClick(string id);
|
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;
|
event System.ComponentModel.CancelEventHandler Closing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
using DesktopMagic.Api;
|
using DesktopMagic.Api;
|
||||||
using DesktopMagic.Settings;
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
|
||||||
using Wpf.Ui.Controls;
|
using Wpf.Ui.Controls;
|
||||||
@@ -12,11 +13,23 @@ namespace DesktopMagic.Plugins;
|
|||||||
internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData
|
internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData
|
||||||
{
|
{
|
||||||
private readonly PluginWindow window = window;
|
private readonly PluginWindow window = window;
|
||||||
|
private Size windowPixelSize = new(1, 1);
|
||||||
|
|
||||||
public ITheme Theme => pluginSettings.Theme;
|
public ITheme Theme => pluginSettings.Theme;
|
||||||
|
|
||||||
public Size WindowSize => new Size((int)window.ActualWidth, (int)window.ActualHeight);
|
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 Point WindowPosition => new Point((int)window.Left, (int)window.Top);
|
||||||
|
|
||||||
public string PluginName => window.PluginMetadata.Name;
|
public string PluginName => window.PluginMetadata.Name;
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
private Thread? pluginThread;
|
private Thread? pluginThread;
|
||||||
private System.Timers.Timer? updateTimer;
|
private System.Timers.Timer? updateTimer;
|
||||||
private Plugin? pluginClassInstance;
|
private Plugin? pluginClassInstance;
|
||||||
|
private PluginData? pluginData;
|
||||||
private AssemblyLoadContext assemblyLoadContext;
|
private AssemblyLoadContext assemblyLoadContext;
|
||||||
|
|
||||||
private readonly Rectangle screenBounds;
|
private readonly Rectangle screenBounds;
|
||||||
private readonly string screenDeviceName;
|
private readonly string screenDeviceName;
|
||||||
private bool isUpdatingPosition = false;
|
private bool isUpdatingPosition = false;
|
||||||
private bool _editMode = false;
|
private bool _editMode = false;
|
||||||
|
private volatile bool renderPaused = false;
|
||||||
|
|
||||||
private CancellationTokenSource? pluginCancellationTokenSource;
|
private CancellationTokenSource? pluginCancellationTokenSource;
|
||||||
private FileSystemWatcher? pluginFileWatcher;
|
private FileSystemWatcher? pluginFileWatcher;
|
||||||
@@ -55,6 +57,18 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
private WriteableBitmap? writeableBitmap;
|
private WriteableBitmap? writeableBitmap;
|
||||||
private BitmapScalingMode lastBitmapScalingMode = BitmapScalingMode.Unspecified;
|
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.
|
// Event handlers on long-lived settings objects, tracked so they can be unsubscribed on close.
|
||||||
private readonly PropertyChangedEventHandler settingsPropertyChangedHandler;
|
private readonly PropertyChangedEventHandler settingsPropertyChangedHandler;
|
||||||
private readonly PropertyChangedEventHandler themePropertyChangedHandler;
|
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()
|
public void UpdatePluginWindow()
|
||||||
{
|
{
|
||||||
UpdateTimer_Elapsed(updateTimer, null);
|
UpdateTimer_Elapsed(updateTimer, null);
|
||||||
@@ -400,6 +429,26 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
try
|
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(() =>
|
Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
if (lastBitmapScalingMode != scalingMode)
|
if (lastBitmapScalingMode != scalingMode)
|
||||||
@@ -410,9 +459,19 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
if (writeableBitmap == null || writeableBitmap.PixelWidth != bitmapData.Width || writeableBitmap.PixelHeight != bitmapData.Height)
|
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(
|
writeableBitmap = new WriteableBitmap(
|
||||||
bitmapData.Width, bitmapData.Height,
|
bitmapData.Width, bitmapData.Height,
|
||||||
bitmap.HorizontalResolution, bitmap.VerticalResolution,
|
dpiX, dpiY,
|
||||||
PixelFormats.Bgra32, null);
|
PixelFormats.Bgra32, null);
|
||||||
image.Source = writeableBitmap;
|
image.Source = writeableBitmap;
|
||||||
}
|
}
|
||||||
@@ -435,6 +494,16 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
writeableBitmap.Unlock();
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -560,7 +629,8 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
if (instance is Plugin plugin)
|
if (instance is Plugin plugin)
|
||||||
{
|
{
|
||||||
pluginClassInstance = plugin;
|
pluginClassInstance = plugin;
|
||||||
pluginClassInstance.Application = new PluginData(this, settings);
|
pluginData = new PluginData(this, settings);
|
||||||
|
pluginClassInstance.Application = pluginData;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -589,6 +659,8 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
};
|
};
|
||||||
updateTimer.Elapsed += UpdateTimer_Elapsed;
|
updateTimer.Elapsed += UpdateTimer_Elapsed;
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(RefreshWindowPixelSize);
|
||||||
|
|
||||||
pluginClassInstance.Start();
|
pluginClassInstance.Start();
|
||||||
if (pluginClassInstance is AsyncPlugin asyncPluginStart)
|
if (pluginClassInstance is AsyncPlugin asyncPluginStart)
|
||||||
{
|
{
|
||||||
@@ -599,7 +671,7 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
UpdatePluginWindow();
|
UpdatePluginWindow();
|
||||||
await Dispatcher.InvokeAsync(ThemeChanged);
|
await Dispatcher.InvokeAsync(ThemeChanged);
|
||||||
|
|
||||||
if (pluginClassInstance.UpdateInterval > 0)
|
if (pluginClassInstance.UpdateInterval > 0 && !renderPaused)
|
||||||
{
|
{
|
||||||
updateTimer.Interval = pluginClassInstance.UpdateInterval;
|
updateTimer.Interval = pluginClassInstance.UpdateInterval;
|
||||||
updateTimer.Start();
|
updateTimer.Start();
|
||||||
@@ -963,6 +1035,13 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
if (plugin is SkiaPlugin or SkiaAsyncPlugin)
|
if (plugin is SkiaPlugin or SkiaAsyncPlugin)
|
||||||
@@ -1034,6 +1113,10 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
});
|
});
|
||||||
Exit();
|
Exit();
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref renderInProgress, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RenderSkiaFrame()
|
private async Task RenderSkiaFrame()
|
||||||
@@ -1045,53 +1128,109 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int width = 0;
|
SKImage? skImage;
|
||||||
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);
|
|
||||||
|
|
||||||
if (plugin is SkiaAsyncPlugin skiaAsyncPlugin)
|
if (plugin is SkiaAsyncPlugin skiaAsyncPlugin)
|
||||||
{
|
{
|
||||||
CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None;
|
CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
await skiaAsyncPlugin.MainAsync(surface.Canvas, token);
|
skImage = await skiaAsyncPlugin.RenderAsync(token);
|
||||||
}
|
}
|
||||||
else if (plugin is SkiaPlugin skiaPlugin)
|
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)
|
if (pixmap is null)
|
||||||
{
|
{
|
||||||
return;
|
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(() =>
|
await Dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
|
if (lastBitmapScalingMode != scalingMode)
|
||||||
|
{
|
||||||
|
RenderOptions.SetBitmapScalingMode(image, scalingMode);
|
||||||
|
lastBitmapScalingMode = scalingMode;
|
||||||
|
}
|
||||||
|
|
||||||
if (writeableBitmap is null || writeableBitmap.PixelWidth != width || writeableBitmap.PixelHeight != height)
|
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;
|
image.Source = writeableBitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeableBitmap.Lock();
|
writeableBitmap.Lock();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
int destStride = writeableBitmap.BackBufferStride;
|
||||||
|
int srcStride = pixmap.RowBytes;
|
||||||
|
int copyWidth = Math.Min(srcStride, destStride);
|
||||||
|
int copyRows = Math.Min(pixmap.Height, height);
|
||||||
|
|
||||||
unsafe
|
unsafe
|
||||||
|
{
|
||||||
|
byte* src = (byte*)pixmap.GetPixels().ToPointer();
|
||||||
|
byte* dst = (byte*)writeableBitmap.BackBuffer.ToPointer();
|
||||||
|
|
||||||
|
for (int row = 0; row < copyRows; row++)
|
||||||
{
|
{
|
||||||
Buffer.MemoryCopy(
|
Buffer.MemoryCopy(
|
||||||
pixmap.GetPixels().ToPointer(),
|
src + (row * srcStride),
|
||||||
writeableBitmap.BackBuffer.ToPointer(),
|
dst + (row * destStride),
|
||||||
writeableBitmap.BackBufferStride * height,
|
destStride,
|
||||||
pixmap.RowBytes * pixmap.Height);
|
copyWidth);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
|
writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
|
||||||
@@ -1101,6 +1240,16 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
writeableBitmap.Unlock();
|
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)
|
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);
|
settings.Position = ScreenUtilities.PositionToPercent(new System.Windows.Point(Left, Top), screenBounds);
|
||||||
|
RefreshWindowPixelSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
|
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
@@ -1267,6 +1417,13 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
tileBar.CaptionHeight = ActualHeight - 10;
|
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)
|
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;
|
ImageSource imageSource = image.Source;
|
||||||
BitmapSource bitmapImage = (BitmapSource)imageSource;
|
BitmapSource bitmapImage = (BitmapSource)imageSource;
|
||||||
double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight;
|
double actualWidth = image.ActualWidth;
|
||||||
double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight;
|
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;
|
MouseButton mouseButton;
|
||||||
|
|
||||||
@@ -1306,8 +1470,15 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
{
|
{
|
||||||
ImageSource imageSource = image.Source;
|
ImageSource imageSource = image.Source;
|
||||||
BitmapSource bitmapImage = (BitmapSource)imageSource;
|
BitmapSource bitmapImage = (BitmapSource)imageSource;
|
||||||
double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight;
|
double actualWidth = image.ActualWidth;
|
||||||
double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight;
|
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);
|
System.Drawing.Point point = new System.Drawing.Point((int)pixelMousePositionX, (int)pixelMousePositionY);
|
||||||
pluginClassInstance?.OnMouseMove(point);
|
pluginClassInstance?.OnMouseMove(point);
|
||||||
@@ -1317,8 +1488,15 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
{
|
{
|
||||||
ImageSource imageSource = image.Source;
|
ImageSource imageSource = image.Source;
|
||||||
BitmapSource bitmapImage = (BitmapSource)imageSource;
|
BitmapSource bitmapImage = (BitmapSource)imageSource;
|
||||||
double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight;
|
double actualWidth = image.ActualWidth;
|
||||||
double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight;
|
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);
|
System.Drawing.Point point = new System.Drawing.Point((int)pixelMousePositionX, (int)pixelMousePositionY);
|
||||||
pluginClassInstance?.OnMouseWheel(point, e.Delta);
|
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)
|
protected override void OnSourceInitialized(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnSourceInitialized(e);
|
base.OnSourceInitialized(e);
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ public interface IPluginData
|
|||||||
ITheme Theme { get; }
|
ITheme Theme { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the window size of the plugin window.
|
/// Gets the window size of the plugin window in device independent pixels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Size WindowSize { get; }
|
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>
|
/// <summary>
|
||||||
/// Gets the window position of the plugin window.
|
/// Gets the window position of the plugin window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ namespace DesktopMagic.Api;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides an abstract base class for creating asynchronous plugins using SkiaSharp rendering.
|
/// 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.
|
/// All existing <see cref="Plugin"/>, <see cref="AsyncPlugin"/>, and <see cref="SkiaPlugin"/> code is unaffected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class SkiaAsyncPlugin : AsyncPlugin
|
public abstract class SkiaAsyncPlugin : AsyncPlugin
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This method is sealed and returns null for async Skia plugins.
|
/// 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>
|
/// </summary>
|
||||||
public sealed override Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
|
public sealed override Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -24,8 +27,9 @@ public abstract class SkiaAsyncPlugin : AsyncPlugin
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin asynchronously.
|
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin asynchronously.
|
||||||
|
/// Return <see langword="null"/> to keep the previously rendered image.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="canvas">The SkiaSharp canvas to draw on.</param>
|
|
||||||
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ namespace DesktopMagic.Api;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides an abstract base class for creating plugins using SkiaSharp rendering.
|
/// 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.
|
/// All existing <see cref="Plugin"/> and <see cref="AsyncPlugin"/> code is unaffected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class SkiaPlugin : Plugin
|
public abstract class SkiaPlugin : Plugin
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This method is sealed and returns null for Skia plugins.
|
/// This method is sealed and returns null for Skia plugins.
|
||||||
/// Override <see cref="Main(SKCanvas)"/> instead.
|
/// Override <see cref="Render()"/> instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Always null.</returns>
|
/// <returns>Always null.</returns>
|
||||||
public sealed override Bitmap? Main()
|
public sealed override Bitmap? Main()
|
||||||
@@ -23,7 +25,8 @@ public abstract class SkiaPlugin : Plugin
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin.
|
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin.
|
||||||
|
/// Return <see langword="null"/> to keep the previously rendered image.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="canvas">The SkiaSharp canvas to draw on.</param>
|
/// <returns>The rendered image, or <see langword="null"/> to keep the previous frame.</returns>
|
||||||
public abstract void Main(SKCanvas canvas);
|
public abstract SKImage? Render();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user