mirror of
https://github.com/Stone-Red-Code/DesktopMagic.git
synced 2026-11-01 23:04:16 +01:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5290c35bbf | ||
|
|
8d691f626a | ||
|
|
894f215adf | ||
|
|
6bca09413d | ||
|
|
a5e56961b6 | ||
|
|
839ffdaaf3 | ||
|
|
7d365643f7 | ||
|
|
d0ad90f83a | ||
|
|
1aa06238c2 | ||
|
|
1507f7a36d | ||
|
|
4ba39c67ce | ||
|
|
507a0e9458 | ||
|
|
af2666f9a6 | ||
|
|
c48eb532f2 | ||
|
|
a6696a87d8 | ||
|
|
df650afd00 | ||
|
|
393ccb0ba6 | ||
|
|
bd29bcdb37 | ||
|
|
2e219e66c9 |
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "lib/modio.net"]
|
||||||
|
path = lib/modio.net
|
||||||
|
url = https://github.com/nickelc/modio.net.git
|
||||||
Binary file not shown.
Submodule
+1
Submodule lib/modio.net added at cc684b198f
@@ -0,0 +1,131 @@
|
|||||||
|
using DesktopMagic.Api;
|
||||||
|
using DesktopMagic.Api.Settings;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
|
||||||
|
namespace DesktopMagic.BuiltInPlugins;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
|
||||||
|
internal class BenchmarkPlugin : Plugin
|
||||||
|
{
|
||||||
|
private readonly Random rng = new(42);
|
||||||
|
private readonly object rngLock = new();
|
||||||
|
|
||||||
|
private readonly System.Diagnostics.Stopwatch frameTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
private double fps;
|
||||||
|
private long lastFrameMs;
|
||||||
|
|
||||||
|
[Setting("shape-count", "Shapes")]
|
||||||
|
private readonly IntegerUpDown shapeCount = new(10, 10000, 1000);
|
||||||
|
|
||||||
|
[Setting("shape-type", "Shape Type")]
|
||||||
|
private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed");
|
||||||
|
|
||||||
|
[Setting("fps-counter", "Fps")]
|
||||||
|
private readonly Label fpsCounter = new("");
|
||||||
|
|
||||||
|
public override int UpdateInterval => 1;
|
||||||
|
|
||||||
|
public override Bitmap? Main()
|
||||||
|
{
|
||||||
|
long now = frameTimer.ElapsedMilliseconds;
|
||||||
|
if (lastFrameMs > 0)
|
||||||
|
{
|
||||||
|
double elapsed = now - lastFrameMs;
|
||||||
|
fps = (fps * 0.9) + (1000.0 / Math.Max(elapsed, 1) * 0.1);
|
||||||
|
}
|
||||||
|
lastFrameMs = now;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
g.Clear(Color.Transparent);
|
||||||
|
g.SmoothingMode = SmoothingMode.HighSpeed;
|
||||||
|
g.CompositingQuality = CompositingQuality.HighSpeed;
|
||||||
|
g.InterpolationMode = InterpolationMode.Low;
|
||||||
|
|
||||||
|
Color color = Application.Theme.PrimaryColor;
|
||||||
|
int count = shapeCount.Value;
|
||||||
|
string type = shapeType.Value;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
int x, y, rw, rh;
|
||||||
|
lock (rngLock)
|
||||||
|
{
|
||||||
|
x = rng.Next(0, w);
|
||||||
|
y = rng.Next(0, h);
|
||||||
|
rw = rng.Next(5, 60);
|
||||||
|
rh = rng.Next(5, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
using Brush brush = new SolidBrush(Color.FromArgb(rng.Next(100, 255), color));
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "Rectangles":
|
||||||
|
g.FillRectangle(brush, x, y, rw, rh);
|
||||||
|
break;
|
||||||
|
case "Ellipses":
|
||||||
|
g.FillEllipse(brush, x, y, rw, rh);
|
||||||
|
break;
|
||||||
|
case "Lines":
|
||||||
|
{
|
||||||
|
int thickness;
|
||||||
|
lock (rngLock) { thickness = rng.Next(1, 5); }
|
||||||
|
g.DrawLine(new Pen(brush, thickness), x, y, x + rw, y + rh);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Text":
|
||||||
|
{
|
||||||
|
int fontSize;
|
||||||
|
lock (rngLock) { fontSize = rng.Next(8, 24); }
|
||||||
|
using Font font = new("Arial", fontSize);
|
||||||
|
g.DrawString("Mg", font, brush, x, y);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
int shape, thickness, fontSize;
|
||||||
|
lock (rngLock)
|
||||||
|
{
|
||||||
|
shape = rng.Next(0, 4);
|
||||||
|
thickness = rng.Next(1, 5);
|
||||||
|
fontSize = rng.Next(8, 24);
|
||||||
|
}
|
||||||
|
if (shape == 0)
|
||||||
|
{
|
||||||
|
g.FillRectangle(brush, x, y, rw, rh);
|
||||||
|
}
|
||||||
|
else if (shape == 1)
|
||||||
|
{
|
||||||
|
g.FillEllipse(brush, x, y, rw, rh);
|
||||||
|
}
|
||||||
|
else if (shape == 2)
|
||||||
|
{
|
||||||
|
g.DrawLine(new Pen(brush, thickness), x, y, x + rw, y + rh);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using Font font = new("Arial", fontSize);
|
||||||
|
g.DrawString("Mg", font, brush, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fpsCounter.Value = fps.ToString();
|
||||||
|
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using DesktopMagic.Api;
|
||||||
|
using DesktopMagic.Api.Settings;
|
||||||
|
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace DesktopMagic.BuiltInPlugins;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
|
||||||
|
internal class SkiaBenchmarkPlugin : SkiaPlugin
|
||||||
|
{
|
||||||
|
private readonly Random rng = new(42);
|
||||||
|
private readonly object rngLock = new();
|
||||||
|
|
||||||
|
private readonly System.Diagnostics.Stopwatch frameTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
private double fps;
|
||||||
|
private long lastFrameMs;
|
||||||
|
|
||||||
|
[Setting("skia-shape-count", "Shapes")]
|
||||||
|
private readonly IntegerUpDown shapeCount = new(10, 10000, 1000);
|
||||||
|
|
||||||
|
[Setting("skia-shape-type", "Shape Type")]
|
||||||
|
private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed");
|
||||||
|
|
||||||
|
[Setting("skia-fps-counter", "Fps")]
|
||||||
|
private readonly Label fpsCounter = new("");
|
||||||
|
|
||||||
|
public override int UpdateInterval => 1;
|
||||||
|
|
||||||
|
private 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? Main()
|
||||||
|
{
|
||||||
|
long now = frameTimer.ElapsedMilliseconds;
|
||||||
|
if (lastFrameMs > 0)
|
||||||
|
{
|
||||||
|
double elapsed = now - lastFrameMs;
|
||||||
|
fps = (fps * 0.9) + (1000.0 / Math.Max(elapsed, 1) * 0.1);
|
||||||
|
}
|
||||||
|
lastFrameMs = now;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
SKColor color = new(
|
||||||
|
Application.Theme.PrimaryColor.R,
|
||||||
|
Application.Theme.PrimaryColor.G,
|
||||||
|
Application.Theme.PrimaryColor.B,
|
||||||
|
Application.Theme.PrimaryColor.A);
|
||||||
|
|
||||||
|
int count = shapeCount.Value;
|
||||||
|
string type = shapeType.Value;
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
int x, y, rw, rh;
|
||||||
|
lock (rngLock)
|
||||||
|
{
|
||||||
|
x = rng.Next(0, w);
|
||||||
|
y = rng.Next(0, h);
|
||||||
|
rw = rng.Next(5, 60);
|
||||||
|
rh = rng.Next(5, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte alpha = (byte)rng.Next(100, 255);
|
||||||
|
using SKPaint paint = new SKPaint
|
||||||
|
{
|
||||||
|
Color = color.WithAlpha(alpha),
|
||||||
|
IsAntialias = false,
|
||||||
|
Style = SKPaintStyle.Fill
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "Rectangles":
|
||||||
|
canvas.DrawRect(x, y, rw, rh, paint);
|
||||||
|
break;
|
||||||
|
case "Ellipses":
|
||||||
|
canvas.DrawOval(x + (rw / 2f), y + (rh / 2f), rw / 2f, rh / 2f, paint);
|
||||||
|
break;
|
||||||
|
case "Lines":
|
||||||
|
paint.Style = SKPaintStyle.Stroke;
|
||||||
|
paint.StrokeWidth = 2;
|
||||||
|
canvas.DrawLine(x, y, x + rw, y + rh, paint);
|
||||||
|
break;
|
||||||
|
case "Text":
|
||||||
|
using (SKFont textFont = new SKFont(SKTypeface.Default, 16))
|
||||||
|
{
|
||||||
|
canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont, paint);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
int shape;
|
||||||
|
lock (rngLock) { shape = rng.Next(0, 4); }
|
||||||
|
if (shape == 0)
|
||||||
|
{
|
||||||
|
canvas.DrawRect(x, y, rw, rh, paint);
|
||||||
|
}
|
||||||
|
else if (shape == 1)
|
||||||
|
{
|
||||||
|
canvas.DrawOval(x + (rw / 2f), y + (rh / 2f), rw / 2f, rh / 2f, paint);
|
||||||
|
}
|
||||||
|
else if (shape == 2)
|
||||||
|
{
|
||||||
|
paint.Style = SKPaintStyle.Stroke;
|
||||||
|
paint.StrokeWidth = 2;
|
||||||
|
canvas.DrawLine(x, y, x + rw, y + rh, paint);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using SKFont textFont2 = new SKFont(SKTypeface.Default, 16);
|
||||||
|
canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont2, paint);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fpsCounter.Value = fps.ToString();
|
||||||
|
|
||||||
|
return surface.Snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -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? Main()
|
||||||
{
|
{
|
||||||
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;
|
float textWidth = font.MeasureText(time, paint);
|
||||||
themeChanged = false;
|
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);
|
using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul));
|
||||||
bmp.SetResolution(100, 100);
|
surface.Canvas.Clear(SKColors.Transparent);
|
||||||
using Graphics tmpGr = Graphics.FromImage(bmp);
|
|
||||||
tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias;
|
|
||||||
|
|
||||||
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);
|
return surface.Snapshot();
|
||||||
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;
|
return displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public override void OnSettingsChanged()
|
|
||||||
{
|
|
||||||
themeChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ public class WeatherPlugin : AsyncPlugin
|
|||||||
showTime.OnValueChanged += Application.UpdateWindow;
|
showTime.OnValueChanged += Application.UpdateWindow;
|
||||||
fontSizeSlider.OnValueChanged += Application.UpdateWindow;
|
fontSizeSlider.OnValueChanged += Application.UpdateWindow;
|
||||||
|
|
||||||
await UpdateLocationAndWeather();
|
|
||||||
Application.UpdateWindow();
|
|
||||||
|
|
||||||
searchButton.OnClick += () =>
|
searchButton.OnClick += () =>
|
||||||
{
|
{
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -73,6 +70,9 @@ public class WeatherPlugin : AsyncPlugin
|
|||||||
Application.UpdateWindow();
|
Application.UpdateWindow();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await UpdateLocationAndWeather();
|
||||||
|
Application.UpdateWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
|
public override async Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<UserControl x:Class="DesktopMagic.Controls.ScreenSelector"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="60"
|
||||||
|
d:DesignWidth="600"
|
||||||
|
Loaded="UserControl_Loaded"
|
||||||
|
SizeChanged="UserControl_SizeChanged"
|
||||||
|
Unloaded="UserControl_Unloaded">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Grid Background="Transparent">
|
||||||
|
<ItemsControl ItemsSource="{Binding ItemsSource, RelativeSource={RelativeSource AncestorType=UserControl}}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<Canvas />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border x:Name="ScreenBorder"
|
||||||
|
Width="{Binding Width}"
|
||||||
|
Height="{Binding Height}"
|
||||||
|
MouseLeftButtonDown="Screen_MouseLeftButtonDown"
|
||||||
|
Cursor="Hand"
|
||||||
|
ToolTipService.ToolTip="{Binding ToolTipText}">
|
||||||
|
<Border.RenderTransform>
|
||||||
|
<TranslateTransform X="{Binding X}" Y="{Binding Y}" />
|
||||||
|
</Border.RenderTransform>
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ControlFillColorDefaultBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
|
||||||
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AccentFillColorDefaultBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="2" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<TextBlock
|
||||||
|
Margin="4,1,0,0"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
Text="{Binding Index}" />
|
||||||
|
|
||||||
|
<Ellipse
|
||||||
|
Width="7"
|
||||||
|
Height="7"
|
||||||
|
Margin="0,0,3,3"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Fill="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Visibility="{Binding IsPrimary, Converter={StaticResource BooleanToVisibilityConverter}}"
|
||||||
|
ToolTipService.ToolTip="Primary screen" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using DesktopMagic.DataContexts;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visual screen selector showing the connected monitors as a mini-map
|
||||||
|
/// scaled to the control's size while preserving their relative positions.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ScreenSelector : UserControl
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty ItemsSourceProperty =
|
||||||
|
DependencyProperty.Register(nameof(ItemsSource), typeof(IEnumerable), typeof(ScreenSelector),
|
||||||
|
new PropertyMetadata(null, OnItemsSourceChanged));
|
||||||
|
|
||||||
|
public IEnumerable? ItemsSource
|
||||||
|
{
|
||||||
|
get => (IEnumerable?)GetValue(ItemsSourceProperty);
|
||||||
|
set => SetValue(ItemsSourceProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty SelectedItemProperty =
|
||||||
|
DependencyProperty.Register(nameof(SelectedItem), typeof(ScreenDisplay), typeof(ScreenSelector),
|
||||||
|
new PropertyMetadata(null, OnSelectedItemChanged));
|
||||||
|
|
||||||
|
public ScreenDisplay? SelectedItem
|
||||||
|
{
|
||||||
|
get => (ScreenDisplay?)GetValue(SelectedItemProperty);
|
||||||
|
set => SetValue(SelectedItemProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private INotifyCollectionChanged? notifySource;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user picks a screen by clicking it.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? SelectionChanged;
|
||||||
|
|
||||||
|
public ScreenSelector()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ScreenSelector control = (ScreenSelector)d;
|
||||||
|
control.AttachSource();
|
||||||
|
control.UpdateCanvas();
|
||||||
|
control.UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
((ScreenSelector)d).UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachSource()
|
||||||
|
{
|
||||||
|
if (notifySource is INotifyCollectionChanged oldSource)
|
||||||
|
{
|
||||||
|
oldSource.CollectionChanged -= Source_CollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySource = ItemsSource as INotifyCollectionChanged;
|
||||||
|
if (notifySource is not null)
|
||||||
|
{
|
||||||
|
notifySource.CollectionChanged += Source_CollectionChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Source_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateCanvas();
|
||||||
|
UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Re-attach in case the control was unloaded and the source instance changed.
|
||||||
|
AttachSource();
|
||||||
|
UpdateCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_Unloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (notifySource is INotifyCollectionChanged source)
|
||||||
|
{
|
||||||
|
source.CollectionChanged -= Source_CollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scales the real screen bounds so they fit the control while keeping their
|
||||||
|
/// relative positions (including negative coordinates), then centers them.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateCanvas()
|
||||||
|
{
|
||||||
|
if (ItemsSource is null || ActualWidth <= 0 || ActualHeight <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ScreenDisplay> screens = ItemsSource.Cast<ScreenDisplay>().ToList();
|
||||||
|
if (screens.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union of all monitor bounds (virtual screen space, negative coords kept).
|
||||||
|
Rectangle totalBounds = new Rectangle();
|
||||||
|
foreach (var item in screens)
|
||||||
|
{
|
||||||
|
totalBounds = Rectangle.Union(totalBounds, item.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniform scale factor + a little margin so the mini-map never touches the edge.
|
||||||
|
double factor = Math.Max(totalBounds.Height / ActualHeight, totalBounds.Width / ActualWidth) + 2;
|
||||||
|
|
||||||
|
foreach (var item in screens)
|
||||||
|
{
|
||||||
|
item.X = item.Bounds.Left / factor;
|
||||||
|
item.Y = item.Bounds.Top / factor;
|
||||||
|
item.Width = item.Bounds.Width / factor;
|
||||||
|
item.Height = item.Bounds.Height / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the whole arrangement in the control.
|
||||||
|
double minLeft = screens.Min(item => item.X);
|
||||||
|
double maxRight = screens.Max(item => item.X + item.Width);
|
||||||
|
double minTop = screens.Min(item => item.Y);
|
||||||
|
double maxBottom = screens.Max(item => item.Y + item.Height);
|
||||||
|
|
||||||
|
double horizontalOffset = ((maxRight + minLeft) / 2) - (ActualWidth / 2);
|
||||||
|
double verticalOffset = ((maxBottom + minTop) / 2) - (ActualHeight / 2);
|
||||||
|
|
||||||
|
foreach (var item in screens)
|
||||||
|
{
|
||||||
|
item.X -= horizontalOffset;
|
||||||
|
item.Y -= verticalOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSelection()
|
||||||
|
{
|
||||||
|
if (ItemsSource is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (ScreenDisplay item in ItemsSource)
|
||||||
|
{
|
||||||
|
item.IsSelected = item == SelectedItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Screen_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement element && element.DataContext is ScreenDisplay screen)
|
||||||
|
{
|
||||||
|
SelectedItem = screen;
|
||||||
|
SelectionChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using DesktopMagic.Helpers;
|
using DesktopMagic.Helpers;
|
||||||
using DesktopMagic.Settings;
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace DesktopMagic.DataContexts;
|
namespace DesktopMagic.DataContexts;
|
||||||
@@ -11,8 +14,11 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
private static DesktopMagicSettings settings = new();
|
private static DesktopMagicSettings settings = new();
|
||||||
|
private static string? selectedScreenDeviceName;
|
||||||
|
private string? selectedLayoutName;
|
||||||
|
|
||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
|
private bool isSharing;
|
||||||
private string? pluginsSearchText;
|
private string? pluginsSearchText;
|
||||||
|
|
||||||
public string Title =>
|
public string Title =>
|
||||||
@@ -24,6 +30,19 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string AppName => App.AppName;
|
public string AppName => App.AppName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a layout or theme is currently being uploaded to mod.io.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSharing
|
||||||
|
{
|
||||||
|
get => isSharing;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
isSharing = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public DesktopMagicSettings Settings
|
public DesktopMagicSettings Settings
|
||||||
{
|
{
|
||||||
get => settings;
|
get => settings;
|
||||||
@@ -34,6 +53,43 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<ScreenDisplay> Screens { get; } = [];
|
||||||
|
|
||||||
|
public string? SelectedScreenId
|
||||||
|
{
|
||||||
|
get => selectedScreenDeviceName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
selectedScreenDeviceName = value;
|
||||||
|
UpdateSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScreenDisplay? SelectedScreen
|
||||||
|
{
|
||||||
|
get => Screens.FirstOrDefault(screen => screen.DeviceName == SelectedScreenId);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
SelectedScreenId = value.DeviceName;
|
||||||
|
}
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Layout SelectedLayout => Manager.Instance.SelectedLayout;
|
||||||
|
|
||||||
|
public string? SelectedLayoutName
|
||||||
|
{
|
||||||
|
get => selectedLayoutName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
selectedLayoutName = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsLoading
|
public bool IsLoading
|
||||||
{
|
{
|
||||||
get => isLoading;
|
get => isLoading;
|
||||||
@@ -76,8 +132,163 @@ internal class MainWindowDataContext : INotifyPropertyChanged
|
|||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the list of detected screens and ensures a screen is selected.
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshScreens()
|
||||||
|
{
|
||||||
|
List<System.Windows.Forms.Screen> allScreens = ScreenUtilities.GetAllScreens();
|
||||||
|
Dictionary<string, System.Drawing.Rectangle> physicalBounds = ScreenUtilities.GetDisplayPhysicalBounds();
|
||||||
|
|
||||||
|
// Capture the hardware id of the currently selected screen so the selection can be
|
||||||
|
// preserved across display changes (e.g. unplug/replug renumbers device names).
|
||||||
|
ScreenDisplay? previousSelected = SelectedScreen;
|
||||||
|
|
||||||
|
Screens.Clear();
|
||||||
|
for (int i = 0; i < allScreens.Count; i++)
|
||||||
|
{
|
||||||
|
System.Windows.Forms.Screen screen = allScreens[i];
|
||||||
|
physicalBounds.TryGetValue(screen.DeviceName, out System.Drawing.Rectangle physicalBoundsRect);
|
||||||
|
Screens.Add(new ScreenDisplay(screen, i, physicalBoundsRect, ScreenUtilities.GetMonitorHardwareId(screen)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedScreenDeviceName is null || !Screens.Any(screen => screen.DeviceName == selectedScreenDeviceName))
|
||||||
|
{
|
||||||
|
ScreenDisplay? byHardwareId = previousSelected is null
|
||||||
|
? null
|
||||||
|
: Screens.FirstOrDefault(screen => screen.HardwareId == previousSelected.HardwareId);
|
||||||
|
SelectedScreenId = byHardwareId?.DeviceName ?? Screens.FirstOrDefault()?.DeviceName;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UpdateSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises change notifications for the currently selected screen's layout.
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshSelection()
|
||||||
|
{
|
||||||
|
UpdateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the manager's screen selection in sync and raises notifications for the selected layout.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateSelection()
|
||||||
|
{
|
||||||
|
Manager.Instance.SelectedScreenDeviceName = selectedScreenDeviceName;
|
||||||
|
selectedLayoutName = Manager.Instance.SelectedLayout.Name;
|
||||||
|
OnPropertyChanged(nameof(SelectedScreenId));
|
||||||
|
OnPropertyChanged(nameof(SelectedScreen));
|
||||||
|
OnPropertyChanged(nameof(SelectedLayout));
|
||||||
|
OnPropertyChanged(nameof(SelectedLayoutName));
|
||||||
|
}
|
||||||
|
|
||||||
protected void OnPropertyChanged([CallerMemberName] string? name = null)
|
protected void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
{
|
{
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A detected screen displayed in the UI.
|
||||||
|
/// </summary>
|
||||||
|
public class ScreenDisplay : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private bool isSelected;
|
||||||
|
private double x;
|
||||||
|
private double y;
|
||||||
|
private double width;
|
||||||
|
private double height;
|
||||||
|
|
||||||
|
public ScreenDisplay(System.Windows.Forms.Screen screen, int index, System.Drawing.Rectangle physicalBounds, string hardwareId)
|
||||||
|
{
|
||||||
|
DeviceName = screen.DeviceName;
|
||||||
|
DisplayName = ScreenUtilities.GetScreenLabel(screen, index);
|
||||||
|
Bounds = physicalBounds.Width > 0 && physicalBounds.Height > 0 ? physicalBounds : screen.Bounds;
|
||||||
|
IsPrimary = screen.Primary;
|
||||||
|
Index = index + 1;
|
||||||
|
HardwareId = hardwareId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeviceName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable hardware identifier of the monitor (see <see cref="ScreenUtilities.GetMonitorHardwareId"/>),
|
||||||
|
/// used to persist screen bindings across display changes.
|
||||||
|
/// </summary>
|
||||||
|
public string HardwareId { get; }
|
||||||
|
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
public System.Drawing.Rectangle Bounds { get; }
|
||||||
|
|
||||||
|
public bool IsPrimary { get; }
|
||||||
|
|
||||||
|
public int Index { get; }
|
||||||
|
|
||||||
|
public string ToolTipText => IsPrimary ? $"{DisplayName} · Primary" : DisplayName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this screen is currently selected in the screen selector.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
isSelected = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalized (control-local) position/size set by the screen selector.
|
||||||
|
public double X
|
||||||
|
{
|
||||||
|
get => x;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
x = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Y
|
||||||
|
{
|
||||||
|
get => y;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
y = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Width
|
||||||
|
{
|
||||||
|
get => width;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
width = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Height
|
||||||
|
{
|
||||||
|
get => height;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
height = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,9 +17,38 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
|
|||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private bool isSearching;
|
private bool isSearching;
|
||||||
private bool isAuthenticated = false;
|
private bool isAuthenticated = false;
|
||||||
|
private ModCategory category = ModCategory.Plugins;
|
||||||
|
|
||||||
public ObservableCollection<PluginEntryDataContext> AllPlugins { get; } = [];
|
public ObservableCollection<PluginEntryDataContext> AllPlugins { get; } = [];
|
||||||
public ObservableCollection<PluginEntryDataContext> InstalledPlugins { get; } = [];
|
public ObservableCollection<PluginEntryDataContext> InstalledPlugins { get; } = [];
|
||||||
|
|
||||||
|
// Layouts or themes, depending on the selected category.
|
||||||
|
public ObservableCollection<PluginEntryDataContext> AllShared { get; } = [];
|
||||||
|
public ObservableCollection<PluginEntryDataContext> InstalledShared { get; } = [];
|
||||||
|
|
||||||
|
public ModCategory Category
|
||||||
|
{
|
||||||
|
get => category;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
category = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(CategoryIndex));
|
||||||
|
OnPropertyChanged(nameof(ShownAll));
|
||||||
|
OnPropertyChanged(nameof(ShownInstalled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CategoryIndex
|
||||||
|
{
|
||||||
|
get => (int)category;
|
||||||
|
set => Category = (ModCategory)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<PluginEntryDataContext> ShownAll => category == ModCategory.Plugins ? AllPlugins : AllShared;
|
||||||
|
|
||||||
|
public ObservableCollection<PluginEntryDataContext> ShownInstalled => category == ModCategory.Plugins ? InstalledPlugins : InstalledShared;
|
||||||
|
|
||||||
public bool IsLoading
|
public bool IsLoading
|
||||||
{
|
{
|
||||||
get => isLoading;
|
get => isLoading;
|
||||||
@@ -97,4 +126,11 @@ internal class PluginManagerDataContext : INotifyPropertyChanged
|
|||||||
{
|
{
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal enum ModCategory
|
||||||
|
{
|
||||||
|
Plugins,
|
||||||
|
Layouts,
|
||||||
|
Themes
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<ApplicationIcon>icon.ico</ApplicationIcon>
|
<ApplicationIcon>icon.ico</ApplicationIcon>
|
||||||
<RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent>
|
<RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent>
|
||||||
<Authors>Stone_Red</Authors>
|
<Authors>Stone_Red</Authors>
|
||||||
<Version>1.3.3.0</Version>
|
<Version>1.4.0.0</Version>
|
||||||
<PackageProjectUrl>https://github.com/Stone-Red-Code/DesktopMagic</PackageProjectUrl>
|
<PackageProjectUrl>https://github.com/Stone-Red-Code/DesktopMagic</PackageProjectUrl>
|
||||||
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
|
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
|
||||||
<AssemblyVersion>1.3.3.0</AssemblyVersion>
|
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.3.3.0</FileVersion>
|
<FileVersion>1.4.0.0</FileVersion>
|
||||||
<TargetFramework>net8.0-windows7.0</TargetFramework>
|
<TargetFramework>net8.0-windows7.0</TargetFramework>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
<PackageReference Include="Interop.IWshRuntimeLibrary" Version="1.0.1" />
|
<PackageReference Include="Interop.IWshRuntimeLibrary" Version="1.0.1" />
|
||||||
<PackageReference Include="Ical.Net" Version="4.3.1" />
|
<PackageReference Include="Ical.Net" Version="4.3.1" />
|
||||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" />
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" />
|
||||||
<PackageReference Include="Modio" Version="1.0.0" />
|
|
||||||
<PackageReference Include="NAudio" Version="2.2.1" />
|
<PackageReference Include="NAudio" Version="2.2.1" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="4.148.0" />
|
||||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||||
<PackageReference Include="WPF-UI" Version="4.2.0" />
|
<PackageReference Include="WPF-UI" Version="4.2.0" />
|
||||||
<PackageReference Include="WPF-UI.Markdown" Version="4.0.2" />
|
<PackageReference Include="WPF-UI.Markdown" Version="4.0.2" />
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DesktopMagicPluginAPI\DesktopMagic.Api.csproj" />
|
<ProjectReference Include="..\DesktopMagicPluginAPI\DesktopMagic.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\..\lib\modio.net\Modio\Modio.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.ScreenSelectorDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:controls="clr-namespace:DesktopMagic.Controls"
|
||||||
|
xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
d:DataContext="{d:DesignInstance Type=dataContext:MainWindowDataContext}"
|
||||||
|
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
|
||||||
|
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowBackdropType="Tabbed"
|
||||||
|
SizeToContent="Height"
|
||||||
|
Width="380"
|
||||||
|
MinWidth="380"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ExtendsContentIntoTitleBar="True">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
|
||||||
|
<ui:TitleBar.Icon>
|
||||||
|
<ui:ImageIcon Source="{StaticResource Icon}" />
|
||||||
|
</ui:TitleBar.Icon>
|
||||||
|
</ui:TitleBar>
|
||||||
|
|
||||||
|
<controls:ScreenSelector x:Name="screenSelector" Grid.Row="1" Margin="15" Width="340" Height="200" ItemsSource="{Binding Screens}" SelectedItem="{Binding SelectedScreen, Mode=TwoWay}" />
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using DesktopMagic.DataContexts;
|
||||||
|
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal picker for selecting a screen from a visual monitor layout.
|
||||||
|
/// Closes itself once a screen has been picked.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ScreenSelectorDialog : Wpf.Ui.Controls.FluentWindow
|
||||||
|
{
|
||||||
|
internal ScreenSelectorDialog(MainWindowDataContext dataContext, string title = App.AppName)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
Resources.MergedDictionaries.Add(App.LanguageDictionary);
|
||||||
|
|
||||||
|
DataContext = dataContext;
|
||||||
|
titleBar.Title = title;
|
||||||
|
Title = title;
|
||||||
|
|
||||||
|
// Close the dialog once a screen has been picked.
|
||||||
|
screenSelector.SelectionChanged += () => DialogResult = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
using DesktopMagic.Dialogs;
|
||||||
|
using DesktopMagic.Plugins;
|
||||||
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
|
using Modio;
|
||||||
|
using Modio.Filters;
|
||||||
|
using Modio.Models;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
using File = System.IO.File;
|
||||||
|
using Path = System.IO.Path;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared mod.io access: authentication and sharing/syncing of layouts and themes.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ModIoService
|
||||||
|
{
|
||||||
|
public const uint GameId = 5665;
|
||||||
|
public const string ApiKey = "88e6ea774c3a502b06114e7fee0829ac";
|
||||||
|
|
||||||
|
private const string Source = "ModIo";
|
||||||
|
private const int MaxSummaryLength = 250;
|
||||||
|
|
||||||
|
private static readonly HttpClient httpClient = new();
|
||||||
|
private static (string Token, uint UserId)? currentUser;
|
||||||
|
|
||||||
|
public static bool IsAuthenticated => AppSettings.ModIoAccessToken is not null;
|
||||||
|
|
||||||
|
private static DesktopMagicSettings AppSettings => Manager.Instance.Settings;
|
||||||
|
|
||||||
|
public static Client CreateClient()
|
||||||
|
{
|
||||||
|
Client.Builder builder = CreateBuilder();
|
||||||
|
|
||||||
|
if (AppSettings.ModIoAccessToken is string token)
|
||||||
|
{
|
||||||
|
_ = builder.WithToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared api.mod.io host is deprecated, requests have to go to the game specific host.
|
||||||
|
/// </summary>
|
||||||
|
private static Client.Builder CreateBuilder()
|
||||||
|
{
|
||||||
|
return Client.GetBuilder(ApiKey).WithGameHost(GameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the mod is a plugin (as opposed to a shared layout or theme).
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPluginMod(Mod mod)
|
||||||
|
{
|
||||||
|
return !mod.Tags.Any(tag => tag.Name is SharePackage.LayoutTag or SharePackage.ThemeTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the e-mail security code login. Returns false when cancelled.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<bool> LogInAsync(Window? owner, string title)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo("Starting mod.io authentication", source: Source);
|
||||||
|
Client client = CreateBuilder().Build();
|
||||||
|
|
||||||
|
InputDialog inputDialog = new(GetString("enterModIoEmail"), title)
|
||||||
|
{
|
||||||
|
Owner = owner,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inputDialog.ShowDialog() != true)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo("Authentication cancelled by user", source: Source);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.Auth.RequestCode(ApiKey, inputDialog.ResponseText);
|
||||||
|
|
||||||
|
inputDialog = new(GetString("enterModIoAccessToken"), title)
|
||||||
|
{
|
||||||
|
Owner = owner,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inputDialog.ShowDialog() != true)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo("Authentication cancelled by user", source: Source);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessToken accessToken = await client.Auth.SecurityCode(ApiKey, inputDialog.ResponseText);
|
||||||
|
if (accessToken.Value is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings.ModIoAccessToken = accessToken.Value;
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
App.Logger.LogInfo("Successfully authenticated with mod.io", source: Source);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LogOut()
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo("Logging out from mod.io", source: Source);
|
||||||
|
AppSettings.ModIoAccessToken = null;
|
||||||
|
currentUser = null;
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares the local content with the last published or downloaded version.
|
||||||
|
/// </summary>
|
||||||
|
public static void RefreshShareState(IModIoShareable item)
|
||||||
|
{
|
||||||
|
item.ShareState = item.ModIo switch
|
||||||
|
{
|
||||||
|
null => ModIoShareState.NotShared,
|
||||||
|
{ IsOwner: false } => ModIoShareState.NotOwner,
|
||||||
|
{ PublishedHash: string hash } when hash == SharePackage.Build(item).Hash => ModIoShareState.UpToDate,
|
||||||
|
_ => ModIoShareState.Modified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes the item as a new mod, uploads a new version of the linked mod, or opens the mod page when nothing changed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="setBusy">Shows or hides the caller's waiting overlay while the upload runs.</param>
|
||||||
|
public static async Task ShareAsync(IModIoShareable item, Window? owner, Action<bool> setBusy)
|
||||||
|
{
|
||||||
|
if (item is Layout { Name: Manager.EmptyLayoutName })
|
||||||
|
{
|
||||||
|
await ShowMessageAsync(GetString("cannotShareEmptyLayout"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshShareState(item);
|
||||||
|
|
||||||
|
if (item.ShareState == ModIoShareState.UpToDate)
|
||||||
|
{
|
||||||
|
OpenProfile(item.ModIo?.ProfileUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAuthenticated && !await LogInAsync(owner, App.AppName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharePackage.Package package = SharePackage.Build(item);
|
||||||
|
bool isUpdate = item.ModIo is { IsOwner: true };
|
||||||
|
|
||||||
|
string? summary = null;
|
||||||
|
string? changelog = null;
|
||||||
|
|
||||||
|
if (isUpdate)
|
||||||
|
{
|
||||||
|
InputDialog changelogDialog = new(GetString("enterModIoChangelog")) { Owner = owner };
|
||||||
|
if (changelogDialog.ShowDialog() != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changelog = changelogDialog.ResponseText.Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string warning = GetString(item is Layout ? "modIoShareLayoutWarning" : "modIoShareThemeWarning");
|
||||||
|
if (package.LocalPlugins.Count > 0)
|
||||||
|
{
|
||||||
|
warning += Environment.NewLine + Environment.NewLine + string.Format(GetString("modIoLocalPluginsWarning"), string.Join(", ", package.LocalPlugins));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await ConfirmAsync(warning, GetString("shareOnModIo")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog summaryDialog = new(GetString("enterModIoSummary")) { Owner = owner };
|
||||||
|
if (summaryDialog.ShowDialog() != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = summaryDialog.ResponseText.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(summary))
|
||||||
|
{
|
||||||
|
summary = item.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.Length > MaxSummaryLength)
|
||||||
|
{
|
||||||
|
summary = summary[..MaxSummaryLength];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string workingDirectory = Path.Combine(Path.GetTempPath(), App.AppName, Guid.NewGuid().ToString());
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = Directory.CreateDirectory(workingDirectory);
|
||||||
|
|
||||||
|
string contentDirectory = Path.Combine(workingDirectory, "content");
|
||||||
|
_ = Directory.CreateDirectory(contentDirectory);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(contentDirectory, SharePackage.ContentFileName), package.Json.ToJsonString());
|
||||||
|
|
||||||
|
string zipPath = Path.Combine(workingDirectory, "content.zip");
|
||||||
|
ZipFile.CreateFromDirectory(contentDirectory, zipPath);
|
||||||
|
|
||||||
|
string logoPath = Path.Combine(workingDirectory, "logo.png");
|
||||||
|
SharePreviewRenderer.Render(item, logoPath);
|
||||||
|
|
||||||
|
Client client = CreateClient();
|
||||||
|
ModIoLink link;
|
||||||
|
|
||||||
|
if (isUpdate)
|
||||||
|
{
|
||||||
|
link = item.ModIo!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"Creating mod.io mod for \"{item.Name}\"", source: Source);
|
||||||
|
NewMod newMod = new(item.Name, summary!, new FileInfo(logoPath))
|
||||||
|
{
|
||||||
|
Visible = Modio.Models.Visibility.Public,
|
||||||
|
};
|
||||||
|
newMod.Tags.Add(SharePackage.GetTag(item));
|
||||||
|
|
||||||
|
Mod mod = await client.Games[GameId].Mods.Add(newMod);
|
||||||
|
|
||||||
|
// A downloaded item shared as a new mod no longer follows the original, which would otherwise be downloaded again.
|
||||||
|
if (item.ModIo is { IsOwner: false } originalLink)
|
||||||
|
{
|
||||||
|
await UnsubscribeAsync(originalLink.ModId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the link right away, so a failed upload is retried on the same mod instead of creating another one.
|
||||||
|
link = new ModIoLink
|
||||||
|
{
|
||||||
|
ModId = mod.Id,
|
||||||
|
IsOwner = true,
|
||||||
|
ProfileUri = mod.ProfileUrl,
|
||||||
|
};
|
||||||
|
item.ModIo = link;
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"Uploading new version of \"{item.Name}\" (mod {link.ModId})", source: Source);
|
||||||
|
Modio.Models.File modfile = await client.Games[GameId].Mods[link.ModId].Files.Add(new NewFile(new FileInfo(zipPath))
|
||||||
|
{
|
||||||
|
Version = DateTime.UtcNow.ToString("yyyy.MM.dd.HHmm"),
|
||||||
|
Changelog = string.IsNullOrWhiteSpace(changelog) ? null : changelog,
|
||||||
|
Active = true,
|
||||||
|
}, new UploadOptions());
|
||||||
|
|
||||||
|
link.ModfileId = modfile.Id;
|
||||||
|
link.PublishedHash = package.Hash;
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
|
||||||
|
if (isUpdate)
|
||||||
|
{
|
||||||
|
await TryUpdateLogo(client, link.ModId, logoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item is Layout)
|
||||||
|
{
|
||||||
|
await TryUpdateDependencies(client, link.ModId, package.RequiredPlugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshShareState(item);
|
||||||
|
App.Logger.LogInfo($"Published \"{item.Name}\" on mod.io", source: Source);
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
if (await ConfirmAsync(string.Format(GetString(isUpdate ? "modIoUpdateSuccess" : "modIoShareSuccess"), item.Name), GetString("open"), GetString("close")))
|
||||||
|
{
|
||||||
|
OpenProfile(link.ProfileUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedException ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Sharing \"{item.Name}\" failed, not authorized: {ex.Message}", source: Source);
|
||||||
|
LogOut();
|
||||||
|
setBusy(false);
|
||||||
|
await ShowMessageAsync(GetString("modIoSessionExpired"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Sharing \"{item.Name}\" failed: {ex.Message}", source: Source);
|
||||||
|
setBusy(false);
|
||||||
|
await ShowMessageAsync(string.Format(GetString("modIoShareFailed"), ex.Message));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setBusy(false);
|
||||||
|
RefreshShareState(item);
|
||||||
|
TryDeleteDirectory(workingDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes from the mod an item is linked to, so deleting it locally does not bring it back on the next sync.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task UnsubscribeAsync(IModIoShareable item)
|
||||||
|
{
|
||||||
|
if (item.ModIo is not null)
|
||||||
|
{
|
||||||
|
await UnsubscribeAsync(item.ModIo.ModId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UnsubscribeAsync(uint modId)
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CreateClient().Games[GameId].Mods.Unsubscribe(modId);
|
||||||
|
App.Logger.LogInfo($"Unsubscribed from mod {modId}", source: Source);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to unsubscribe from mod {modId}: {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to the mods of downloaded layouts and themes, e.g. after logging in,
|
||||||
|
/// so the subscription sync keeps them instead of treating them as unsubscribed.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task SubscribeToLinkedItemsAsync()
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Client client = CreateClient();
|
||||||
|
|
||||||
|
foreach (ModIoLink link in GetSharedItems().Select(item => item.ModIo).OfType<ModIoLink>().Where(link => !link.IsOwner).ToList())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.Games[GameId].Mods.Subscribe(link.ModId);
|
||||||
|
App.Logger.LogInfo($"Subscribed to mod {link.ModId}", source: Source);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to subscribe to mod {link.ModId}: {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<IModIoShareable> GetSharedItems()
|
||||||
|
{
|
||||||
|
DesktopMagicSettings settings = AppSettings;
|
||||||
|
return settings.Layouts.Cast<IModIoShareable>().Concat(settings.Themes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IModIoShareable? FindLinkedItem(uint modId)
|
||||||
|
{
|
||||||
|
return GetSharedItems().FirstOrDefault(item => item.ModIo?.ModId == modId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads a layout or theme from mod.io and subscribes to it when logged in.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ensurePluginInstalled">Installs the mod.io plugin with the given ID if it is missing.</param>
|
||||||
|
/// <returns>Whether the item was installed.</returns>
|
||||||
|
public static async Task<bool> InstallAsync(Mod mod, Func<uint, Task> ensurePluginInstalled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsAuthenticated)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CreateClient().Games[GameId].Mods.Subscribe(mod.Id);
|
||||||
|
App.Logger.LogInfo($"Subscribed to mod {mod.Id}", source: Source);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to subscribe to mod {mod.Id}: {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
|
||||||
|
await ShowMessageAsync(string.Format(GetString("modIoInstallFailed"), mod.Name, ex.Message));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a linked layout or theme when mod.io has a newer version.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether the item changed.</returns>
|
||||||
|
public static async Task<bool> UpdateAsync(IModIoShareable item, Mod mod, Func<uint, Task> ensurePluginInstalled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await ApplyLatestVersionAsync(mod, item, ensurePluginInstalled))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to update \"{item.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a layout or theme locally after confirmation and unsubscribes from its mod.
|
||||||
|
/// The mod itself stays on mod.io.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether the item was removed.</returns>
|
||||||
|
public static async Task<bool> RemoveAsync(IModIoShareable item)
|
||||||
|
{
|
||||||
|
DesktopMagicSettings settings = AppSettings;
|
||||||
|
|
||||||
|
string? blocker = item switch
|
||||||
|
{
|
||||||
|
Layout { Name: Manager.EmptyLayoutName } => "cannotDeleteEmptyLayout",
|
||||||
|
Layout when settings.Layouts.Count <= 1 => "cannotDeleteLastLayout",
|
||||||
|
Theme when settings.Themes.Count <= 1 => "cannotDeleteLastTheme",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (blocker is not null)
|
||||||
|
{
|
||||||
|
await ShowMessageAsync(GetString(blocker));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await ConfirmAsync(GetString(item is Layout ? "confirmDeleteLayout" : "confirmDeleteTheme"), "Yes", "No"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UnsubscribeAsync(item);
|
||||||
|
|
||||||
|
switch (item)
|
||||||
|
{
|
||||||
|
case Layout layout:
|
||||||
|
Manager.Instance.RemoveLayout(layout);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Theme theme:
|
||||||
|
_ = settings.Themes.Remove(theme);
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"Removed \"{item.Name}\"", source: Source);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads subscribed layouts and themes that are new or have a newer version on mod.io.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subscriptions">The user's subscribed mods for this game.</param>
|
||||||
|
/// <param name="ensurePluginInstalled">Installs the mod.io plugin with the given ID if it is missing.</param>
|
||||||
|
/// <returns>Whether any layout or theme changed.</returns>
|
||||||
|
public static async Task<bool> SyncSubscriptionsAsync(IReadOnlyList<Mod> subscriptions, Func<uint, Task> ensurePluginInstalled)
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = false;
|
||||||
|
|
||||||
|
List<Mod> sharedMods = subscriptions.Where(mod => !IsPluginMod(mod)).ToList();
|
||||||
|
App.Logger.LogInfo($"Found {sharedMods.Count} subscribed layouts and themes on mod.io", source: Source);
|
||||||
|
|
||||||
|
foreach (Mod mod in sharedMods)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
changed |= await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloaded items whose subscription was removed on mod.io stay as local copies.
|
||||||
|
foreach (IModIoShareable item in GetSharedItems().Where(item => item.ModIo is { IsOwner: false } && !subscriptions.Any(mod => mod.Id == item.ModIo.ModId)).ToList())
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"\"{item.Name}\" is no longer subscribed, keeping it as a local copy", source: Source);
|
||||||
|
item.ModIo = null;
|
||||||
|
RefreshShareState(item);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
Manager.Instance.SaveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads the current version of the mod into a new item, or into <paramref name="local"/> when it is outdated.
|
||||||
|
/// Asks before overwriting local changes. Throws when the download or import fails.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether anything changed.</returns>
|
||||||
|
private static async Task<bool> ApplyLatestVersionAsync(Mod mod, IModIoShareable? local, Func<uint, Task> ensurePluginInstalled)
|
||||||
|
{
|
||||||
|
if (mod.Modfile?.Download?.BinaryUrl is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The mod has no file to download.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local?.ModIo?.ModfileId == mod.Modfile.Id)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local?.ModIo is ModIoLink localLink && localLink.PublishedHash != SharePackage.Build(local).Hash)
|
||||||
|
{
|
||||||
|
bool overwrite = await ConfirmAsync(string.Format(GetString("modIoUpdateConflict"), local.Name), GetString("overwrite"), GetString("keepMine"));
|
||||||
|
|
||||||
|
if (!overwrite)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"Keeping local changes of \"{local.Name}\" instead of updating to mod {mod.Id} version {mod.Modfile.Id}", source: Source);
|
||||||
|
|
||||||
|
if (localLink.IsOwner)
|
||||||
|
{
|
||||||
|
// The owner can publish their version over the remote one.
|
||||||
|
localLink.ModfileId = mod.Modfile.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Detach the local copy so it is no longer replaced by updates.
|
||||||
|
await UnsubscribeAsync(local);
|
||||||
|
local.ModIo = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"Downloading \"{mod.Name}\" (mod {mod.Id}, version {mod.Modfile.Id})", source: Source);
|
||||||
|
JsonObject content = await DownloadContentAsync(mod.Modfile.Download.BinaryUrl);
|
||||||
|
|
||||||
|
foreach (uint pluginId in SharePackage.GetRequiredPlugins(content).Where(id => !Manager.Instance.Plugins.ContainsKey(id)))
|
||||||
|
{
|
||||||
|
await ensurePluginInstalled(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint? userId = IsAuthenticated ? await GetCurrentUserIdAsync(CreateClient()) : null;
|
||||||
|
|
||||||
|
IModIoShareable item = SharePackage.Apply(content, local);
|
||||||
|
item.ModIo = new ModIoLink
|
||||||
|
{
|
||||||
|
ModId = mod.Id,
|
||||||
|
ModfileId = mod.Modfile.Id,
|
||||||
|
IsOwner = userId is not null && mod.SubmittedBy?.Id == userId,
|
||||||
|
ProfileUri = mod.ProfileUrl,
|
||||||
|
};
|
||||||
|
item.ModIo.PublishedHash = SharePackage.Build(item).Hash;
|
||||||
|
RefreshShareState(item);
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"Installed \"{item.Name}\" from mod.io", source: Source);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonObject> DownloadContentAsync(Uri url)
|
||||||
|
{
|
||||||
|
using MemoryStream zipStream = new();
|
||||||
|
using (Stream download = await httpClient.GetStreamAsync(url))
|
||||||
|
{
|
||||||
|
await download.CopyToAsync(zipStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
zipStream.Position = 0;
|
||||||
|
using ZipArchive archive = new(zipStream, ZipArchiveMode.Read);
|
||||||
|
ZipArchiveEntry entry = archive.GetEntry(SharePackage.ContentFileName) ?? throw new FormatException($"The download does not contain \"{SharePackage.ContentFileName}\".");
|
||||||
|
|
||||||
|
using StreamReader reader = new(entry.Open());
|
||||||
|
return SharePackage.Parse(await reader.ReadToEndAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<uint> GetCurrentUserIdAsync(Client client)
|
||||||
|
{
|
||||||
|
string token = AppSettings.ModIoAccessToken ?? string.Empty;
|
||||||
|
|
||||||
|
if (currentUser is not { } user || user.Token != token)
|
||||||
|
{
|
||||||
|
User me = await client.User.GetCurrentUser();
|
||||||
|
user = (token, me.Id);
|
||||||
|
currentUser = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task TryUpdateLogo(Client client, uint modId, string logoPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.Games[GameId].Mods[modId].AddMedia(new NewModMedia { Logo = new FileInfo(logoPath) });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogWarn($"Failed to update logo of mod {modId}: {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task TryUpdateDependencies(Client client, uint modId, IReadOnlyList<uint> requiredPlugins)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DependenciesClient dependencies = client.Games[GameId].Mods[modId].Dependencies;
|
||||||
|
List<uint> current = (await dependencies.Get(false)).Select(dependency => dependency.ModId).ToList();
|
||||||
|
|
||||||
|
List<uint> removed = current.Except(requiredPlugins).ToList();
|
||||||
|
List<uint> added = requiredPlugins.Except(current).ToList();
|
||||||
|
|
||||||
|
if (removed.Count > 0)
|
||||||
|
{
|
||||||
|
await dependencies.Delete(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.Count > 0)
|
||||||
|
{
|
||||||
|
await dependencies.Add(added);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogWarn($"Failed to update dependencies of mod {modId}: {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OpenProfile(Uri? profileUri)
|
||||||
|
{
|
||||||
|
if (profileUri is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
UseShellExecute = true,
|
||||||
|
FileName = profileUri.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogWarn($"Failed to delete temporary directory {path}: {ex.Message}", source: Source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ShowMessageAsync(string content)
|
||||||
|
{
|
||||||
|
Wpf.Ui.Controls.MessageBox messageBox = new()
|
||||||
|
{
|
||||||
|
Title = App.AppName,
|
||||||
|
Content = content,
|
||||||
|
CloseButtonText = "Ok"
|
||||||
|
};
|
||||||
|
_ = await messageBox.ShowDialogAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> ConfirmAsync(string content, string confirmText, string? cancelText = null)
|
||||||
|
{
|
||||||
|
Wpf.Ui.Controls.MessageBox messageBox = new()
|
||||||
|
{
|
||||||
|
Title = App.AppName,
|
||||||
|
Content = content,
|
||||||
|
PrimaryButtonText = confirmText,
|
||||||
|
CloseButtonText = cancelText ?? GetString("cancel"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await messageBox.ShowDialogAsync() == Wpf.Ui.Controls.MessageBoxResult.Primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetString(string key)
|
||||||
|
{
|
||||||
|
return App.LanguageDictionary[key] as string ?? key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
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;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
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<IPluginWindow, bool> lastPaused = [];
|
||||||
|
private readonly object gate = new();
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
// (window, handle) pairs of the plugin windows, refreshed asynchronously on the UI thread.
|
||||||
|
private volatile IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windowSnapshot = [];
|
||||||
|
private int snapshotRefreshPending;
|
||||||
|
|
||||||
|
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 running, so serialize the whole pass.
|
||||||
|
if (!Monitor.TryEnter(gate))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunOcclusionPass();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Monitor.Exit(gate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunOcclusionPass()
|
||||||
|
{
|
||||||
|
RequestSnapshotRefresh();
|
||||||
|
|
||||||
|
IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windows = windowSnapshot;
|
||||||
|
HashSet<IPluginWindow> live = [.. windows.Select(item => item.Window)];
|
||||||
|
|
||||||
|
Dictionary<IPluginWindow, bool> pausedStates;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pausedStates = ComputePausedStates(windows);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((IPluginWindow window, bool paused) in pausedStates)
|
||||||
|
{
|
||||||
|
if (lastPaused.TryGetValue(window, out bool previous) && previous == paused)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPaused[window] = paused;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = ((Window)window).Dispatcher.InvokeAsync(() => window.SetRenderPaused(paused));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Window is closing; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop tracking for windows that no longer exist.
|
||||||
|
foreach (IPluginWindow stale in lastPaused.Keys.Where(key => !live.Contains(key)).ToArray())
|
||||||
|
{
|
||||||
|
_ = lastPaused.Remove(stale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Posts a refresh of <see cref="windowSnapshot"/> to the UI thread without waiting for it.
|
||||||
|
/// The manager mutates PluginWindows on the UI thread and window handles can only be obtained
|
||||||
|
/// there; a snapshot that is one pass old is good enough, and the timer thread never blocks.
|
||||||
|
/// </summary>
|
||||||
|
private void RequestSnapshotRefresh()
|
||||||
|
{
|
||||||
|
// Skip when the previous refresh has not run yet (e.g. the UI thread is busy).
|
||||||
|
if (Interlocked.Exchange(ref snapshotRefreshPending, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
windowSnapshot = Manager.Instance.PluginWindows
|
||||||
|
.Where(window => window.IsRunning)
|
||||||
|
.Select(window => (Window: window, Hwnd: new WindowInteropHelper((Window)window).Handle))
|
||||||
|
.Where(item => item.Hwnd != nint.Zero)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ = Interlocked.Exchange(ref snapshotRefreshPending, 0);
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The application is shutting down.
|
||||||
|
_ = Interlocked.Exchange(ref snapshotRefreshPending, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides for every plugin window whether it is occluded, using a single top-to-bottom pass
|
||||||
|
/// over all top-level windows. EnumWindows enumerates in z-order (front to back), so every
|
||||||
|
/// window seen before a plugin window is above it. The pass stops at the lowest plugin window.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<IPluginWindow, bool> ComputePausedStates(IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windows)
|
||||||
|
{
|
||||||
|
Dictionary<IPluginWindow, bool> pausedStates = [];
|
||||||
|
|
||||||
|
// Plugin windows that have not been reached in z-order yet.
|
||||||
|
Dictionary<nint, (IPluginWindow Window, Rectangle Area)> remaining = [];
|
||||||
|
foreach ((IPluginWindow window, nint hwnd) in windows)
|
||||||
|
{
|
||||||
|
if (W32.GetWindowRect(hwnd, out W32.RECT rect))
|
||||||
|
{
|
||||||
|
remaining[hwnd] = (window, rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.Count == 0)
|
||||||
|
{
|
||||||
|
return pausedStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint currentProcessId = (uint)Environment.ProcessId;
|
||||||
|
|
||||||
|
// Visible windows above the current z-order position that overlap a remaining plugin window.
|
||||||
|
List<Rectangle> occluders = [];
|
||||||
|
|
||||||
|
_ = W32.EnumWindows((hwnd, _) =>
|
||||||
|
{
|
||||||
|
if (remaining.Remove(hwnd, out (IPluginWindow Window, Rectangle Area) target))
|
||||||
|
{
|
||||||
|
double coverage = ComputeCoverage(target.Area, occluders);
|
||||||
|
pausedStates[target.Window] = IsForegroundCovering(target.Area, hwnd) || coverage >= CoverageThreshold;
|
||||||
|
|
||||||
|
// Windows below the lowest plugin window cannot occlude anything.
|
||||||
|
return remaining.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetOccluderRect(hwnd, currentProcessId, remaining.Values, out Rectangle occluderRect))
|
||||||
|
{
|
||||||
|
occluders.Add(occluderRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, nint.Zero);
|
||||||
|
|
||||||
|
return pausedStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the bounds of <paramref name="hwnd"/> when it is a visible window from another process
|
||||||
|
/// that overlaps at least one of <paramref name="targets"/>. Cheap checks run first so the more
|
||||||
|
/// expensive ones only run for windows that actually overlap a plugin window.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryGetOccluderRect(nint hwnd, uint currentProcessId, IEnumerable<(IPluginWindow Window, Rectangle Area)> targets, out Rectangle occluderRect)
|
||||||
|
{
|
||||||
|
occluderRect = Rectangle.Empty;
|
||||||
|
|
||||||
|
// Only windows from other processes occlude our widgets.
|
||||||
|
if (!W32.IsWindowVisible(hwnd)
|
||||||
|
|| W32.GetWindowThreadProcessId(hwnd, out uint processId) == 0
|
||||||
|
|| processId == currentProcessId
|
||||||
|
|| !W32.GetWindowRect(hwnd, out W32.RECT rect))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle bounds = rect;
|
||||||
|
if (!targets.Any(target => target.Area.IntersectsWith(bounds)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only non-cloaked windows are actually visible (exclusive-fullscreen games cloak the desktop).
|
||||||
|
// Explorer's desktop/wallpaper host windows (WorkerW, Progman, ...) report as visible and span
|
||||||
|
// the whole screen but never occlude anything. Fully transparent layered overlays (game
|
||||||
|
// overlays etc.) span the whole screen but cannot be seen either.
|
||||||
|
if (IsCloaked(hwnd) || IsDesktopShellWindow(hwnd) || IsFullyTransparent(hwnd))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
occluderRect = bounds;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes how much of <paramref name="target"/> (in virtual screen coordinates) is covered by
|
||||||
|
/// <paramref name="occluders"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static double ComputeCoverage(Rectangle target, List<Rectangle> occluders)
|
||||||
|
{
|
||||||
|
List<Rectangle> coveringRects = [];
|
||||||
|
|
||||||
|
foreach (Rectangle occluder in occluders)
|
||||||
|
{
|
||||||
|
Rectangle intersection = Rectangle.Intersect(target, occluder);
|
||||||
|
if (intersection.Width > 0 && intersection.Height > 0)
|
||||||
|
{
|
||||||
|
coveringRects.Add(intersection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers for enumerating screens and converting between percentage based
|
||||||
|
/// positions/sizes (relative to a screen's bounds) and absolute positions.
|
||||||
|
/// All conversions work in WPF DIP space because WPF window <see cref="Window.Left"/>,
|
||||||
|
/// <see cref="Window.Top"/>, <see cref="Window.Width"/> and <see cref="Window.Height"/>
|
||||||
|
/// are expressed in device independent pixels, while <see cref="System.Windows.Forms.Screen.Bounds"/>
|
||||||
|
/// is expressed in physical pixels. Each screen is therefore converted to DIP space
|
||||||
|
/// using its own DPI scaling factor.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScreenUtilities
|
||||||
|
{
|
||||||
|
private const uint MonitorDefaultToNearest = 0x00000002;
|
||||||
|
|
||||||
|
private const int MDT_EFFECTIVE_DPI = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of all screens currently connected, in the order reported by Windows.
|
||||||
|
/// </summary>
|
||||||
|
public static List<System.Windows.Forms.Screen> GetAllScreens()
|
||||||
|
{
|
||||||
|
return System.Windows.Forms.Screen.AllScreens.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the screen matching the given device name (e.g. "\\.\DISPLAY1"), or null.
|
||||||
|
/// </summary>
|
||||||
|
public static System.Windows.Forms.Screen? GetScreenByDeviceName(string? deviceName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return System.Windows.Forms.Screen.AllScreens.FirstOrDefault(screen => string.Equals(screen.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the primary screen, or the first available screen as a fallback.
|
||||||
|
/// </summary>
|
||||||
|
public static System.Windows.Forms.Screen GetPrimaryScreen()
|
||||||
|
{
|
||||||
|
return System.Windows.Forms.Screen.PrimaryScreen ?? System.Windows.Forms.Screen.AllScreens.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException("No screens detected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aspect ratio (Width / Height) of the screen's bounds.
|
||||||
|
/// </summary>
|
||||||
|
public static double GetAspectRatio(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
if (screen.Bounds.Height == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return screen.Bounds.Width / (double)screen.Bounds.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a percentage based position (0..1 relative to the screen bounds) to an absolute
|
||||||
|
/// WPF position (DIPs) on that screen.
|
||||||
|
/// </summary>
|
||||||
|
public static Point PercentToPosition(Point percent, System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
Rect dips = GetScreenDips(bounds);
|
||||||
|
return new Point(
|
||||||
|
dips.Left + (percent.X * dips.Width),
|
||||||
|
dips.Top + (percent.Y * dips.Height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an absolute WPF position (DIPs) to a percentage (0..1) of the screen bounds.
|
||||||
|
/// </summary>
|
||||||
|
public static Point PositionToPercent(Point position, System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
Rect dips = GetScreenDips(bounds);
|
||||||
|
if (dips.Width == 0 || dips.Height == 0)
|
||||||
|
{
|
||||||
|
return new Point(0.05, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Point(
|
||||||
|
(position.X - dips.Left) / dips.Width,
|
||||||
|
(position.Y - dips.Top) / dips.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a percentage based size (0..1 of the screen bounds) to an absolute WPF size (DIPs).
|
||||||
|
/// </summary>
|
||||||
|
public static Point PercentSizeToSize(Point percent, System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
Rect dips = GetScreenDips(bounds);
|
||||||
|
return new Point(
|
||||||
|
percent.X * dips.Width,
|
||||||
|
percent.Y * dips.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an absolute WPF size (DIPs) to a percentage (0..1) of the screen bounds.
|
||||||
|
/// </summary>
|
||||||
|
public static Point SizeToPercent(Point size, System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
Rect dips = GetScreenDips(bounds);
|
||||||
|
if (dips.Width == 0 || dips.Height == 0)
|
||||||
|
{
|
||||||
|
return new Point(0.3, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Point(
|
||||||
|
size.X / dips.Width,
|
||||||
|
size.Y / dips.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Window hook that keeps a window inside the given screen bounds. <see cref="Window.LocationChanged"/>
|
||||||
|
/// fires only after a move has already happened, and the system move loop overrides any correction
|
||||||
|
/// made there on the next mouse move, so the proposed position is adjusted in WM_WINDOWPOSCHANGING
|
||||||
|
/// instead, which every move and resize goes through. The position and the bounds are both in this
|
||||||
|
/// process' screen coordinates, so no DIP conversion is needed.
|
||||||
|
/// </summary>
|
||||||
|
public static IntPtr ClampToScreenHook(IntPtr hwnd, int msg, IntPtr lParam, System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
if (msg != NativeMethods.WM_WINDOWPOSCHANGING || bounds.Width == 0 || bounds.Height == 0)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeMethods.WINDOWPOS pos = Marshal.PtrToStructure<NativeMethods.WINDOWPOS>(lParam);
|
||||||
|
if ((pos.flags & NativeMethods.SWP_NOMOVE) != 0)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = pos.cx;
|
||||||
|
int height = pos.cy;
|
||||||
|
if ((pos.flags & NativeMethods.SWP_NOSIZE) != 0)
|
||||||
|
{
|
||||||
|
if (!W32.GetWindowRect(hwnd, out W32.RECT current))
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
width = current.Width;
|
||||||
|
height = current.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
int x = width <= bounds.Width ? Math.Clamp(pos.x, bounds.Left, bounds.Right - width) : bounds.Left;
|
||||||
|
int y = height <= bounds.Height ? Math.Clamp(pos.y, bounds.Top, bounds.Bottom - height) : bounds.Top;
|
||||||
|
|
||||||
|
if (x != pos.x || y != pos.y)
|
||||||
|
{
|
||||||
|
pos.x = x;
|
||||||
|
pos.y = y;
|
||||||
|
Marshal.StructureToPtr(pos, lParam, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a human readable label for a screen, e.g. "Display 1 · DELL U2715H · 3840x2160".
|
||||||
|
/// </summary>
|
||||||
|
public static string GetScreenLabel(System.Windows.Forms.Screen screen, int index)
|
||||||
|
{
|
||||||
|
string name = GetFriendlyName(screen);
|
||||||
|
string resolution = $"{screen.Bounds.Width}x{screen.Bounds.Height}";
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(name) || string.Equals(name, "Generic PnP Monitor", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"Display {index + 1} · {resolution}"
|
||||||
|
: $"Display {index + 1} · {name} · {resolution}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the friendly monitor model name (e.g. "DELL U2715H") for the given screen,
|
||||||
|
/// or an empty string when it cannot be determined.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetFriendlyName(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
return GetMonitorDisplayDevice(screen)?.DeviceString ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a stable hardware identifier for the screen: the PnP device instance ID of its
|
||||||
|
/// monitor (e.g. "MONITOR\DEL41F1\{...}\{0001}"), which is derived from the monitor's
|
||||||
|
/// EDID and survives disconnects and reconnects of the same monitor. When no hardware ID
|
||||||
|
/// is available (e.g. virtual or remote displays), a deterministic SHA-256 hash of the
|
||||||
|
/// bounds is used so the identifier is never empty.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetMonitorHardwareId(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
string? deviceId = GetMonitorDisplayDevice(screen)?.DeviceID;
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Drawing.Rectangle bounds = screen.Bounds;
|
||||||
|
string boundsString = $"{bounds.X}-{bounds.Y}-{bounds.Width}-{bounds.Height}";
|
||||||
|
string hashString = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(boundsString))).ToLowerInvariant();
|
||||||
|
return $"DISPLAY#{hashString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the monitor device info (second-level <see cref="EnumDisplayDevices"/> entry) for
|
||||||
|
/// the given screen, or null when it cannot be determined.
|
||||||
|
/// </summary>
|
||||||
|
private static DISPLAY_DEVICE? GetMonitorDisplayDevice(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DISPLAY_DEVICE device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
|
||||||
|
if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0))
|
||||||
|
{
|
||||||
|
DISPLAY_DEVICE monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf<DISPLAY_DEVICE>() };
|
||||||
|
if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0))
|
||||||
|
{
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Fall through to null.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates monitors in a Per-Monitor V2 DPI awareness context and returns each
|
||||||
|
/// monitor's bounds in physical (device) pixels on the virtual screen, keyed by device
|
||||||
|
/// name (e.g. "\\.\DISPLAY1"). This uses the same coordinate space as DPI-aware apps
|
||||||
|
/// such as Lively Wallpaper, keeping multi-screen layouts with mixed DPI scaling
|
||||||
|
/// consistent regardless of this app's own DPI awareness mode.
|
||||||
|
/// </summary>
|
||||||
|
public static Dictionary<string, System.Drawing.Rectangle> GetDisplayPhysicalBounds()
|
||||||
|
{
|
||||||
|
Dictionary<string, System.Drawing.Rectangle> result = new Dictionary<string, System.Drawing.Rectangle>();
|
||||||
|
|
||||||
|
IntPtr prevContext = IntPtr.Zero;
|
||||||
|
bool contextChanged = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
prevContext = SetThreadDpiAwarenessContext((IntPtr)DpiAwarenessContextPerMonitorV2);
|
||||||
|
contextChanged = prevContext != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// SetThreadDpiAwarenessContext unavailable; fall back to default context.
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (hMonitor, hdcMonitor, lprcMonitor, dwData) =>
|
||||||
|
{
|
||||||
|
MONITORINFOEX info = new MONITORINFOEX { cbSize = (uint)Marshal.SizeOf<MONITORINFOEX>() };
|
||||||
|
if (GetMonitorInfo(hMonitor, ref info))
|
||||||
|
{
|
||||||
|
string deviceName = info.szDevice.TrimEnd('\0');
|
||||||
|
result[deviceName] = new System.Drawing.Rectangle(
|
||||||
|
info.rcMonitor.Left, info.rcMonitor.Top,
|
||||||
|
info.rcMonitor.Right - info.rcMonitor.Left,
|
||||||
|
info.rcMonitor.Bottom - info.rcMonitor.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (contextChanged)
|
||||||
|
{
|
||||||
|
_ = SetThreadDpiAwarenessContext(prevContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the screen bounds converted to WPF DIP space using the screen's own DPI scaling factor.
|
||||||
|
/// </summary>
|
||||||
|
private static Rect GetScreenDips(System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
double scale = GetDpiScale(bounds);
|
||||||
|
return new Rect(bounds.Left / scale, bounds.Top / scale, bounds.Width / scale, bounds.Height / scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the DPI scaling factor (relative to 96 DPI) of the screen containing the given bounds.
|
||||||
|
/// </summary>
|
||||||
|
private static double GetDpiScale(System.Drawing.Rectangle bounds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
POINT center = new POINT
|
||||||
|
{
|
||||||
|
X = bounds.Left + (bounds.Width / 2),
|
||||||
|
Y = bounds.Top + (bounds.Height / 2)
|
||||||
|
};
|
||||||
|
IntPtr monitor = MonitorFromPoint(center, MonitorDefaultToNearest);
|
||||||
|
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0)
|
||||||
|
{
|
||||||
|
return dpiX / 96.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Fall through to no scaling if DPI APIs are unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromPoint(POINT point, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("shcore.dll")]
|
||||||
|
private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool EnumDisplayDevices(string? lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct DISPLAY_DEVICE
|
||||||
|
{
|
||||||
|
public uint cb;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||||
|
public string DeviceName;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string DeviceString;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string DeviceID;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string DeviceKey;
|
||||||
|
|
||||||
|
public uint StateFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct POINT
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const long DpiAwarenessContextPerMonitorV2 = -4;
|
||||||
|
|
||||||
|
private delegate bool EnumMonitorsProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, EnumMonitorsProc lpfnEnum, IntPtr dwData);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MONITORRECT
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct MONITORINFOEX
|
||||||
|
{
|
||||||
|
public uint cbSize;
|
||||||
|
|
||||||
|
public MONITORRECT rcMonitor;
|
||||||
|
|
||||||
|
public MONITORRECT rcWork;
|
||||||
|
|
||||||
|
public uint dwFlags;
|
||||||
|
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||||
|
public string szDevice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using DesktopMagic.Plugins;
|
||||||
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts layouts and themes to and from the portable JSON content that is uploaded to mod.io.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SharePackage
|
||||||
|
{
|
||||||
|
public const int FormatVersion = 1;
|
||||||
|
public const string LayoutTag = "Layout";
|
||||||
|
public const string ThemeTag = "Theme";
|
||||||
|
public const string ContentFileName = "content.json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions jsonOptions = new()
|
||||||
|
{
|
||||||
|
Converters = { new ColorJsonConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <param name="Json">The content written to <see cref="ContentFileName"/>.</param>
|
||||||
|
/// <param name="Hash">Hash of the shared content, used to detect local changes.</param>
|
||||||
|
/// <param name="RequiredPlugins">mod.io plugin IDs the layout uses.</param>
|
||||||
|
/// <param name="LocalPlugins">Names of locally developed plugins the layout uses, which others cannot install.</param>
|
||||||
|
public sealed record Package(JsonObject Json, string Hash, IReadOnlyList<uint> RequiredPlugins, IReadOnlyList<string> LocalPlugins);
|
||||||
|
|
||||||
|
public static string GetTag(IModIoShareable item) => item is Layout ? LayoutTag : ThemeTag;
|
||||||
|
|
||||||
|
public static Package Build(IModIoShareable item)
|
||||||
|
{
|
||||||
|
return item switch
|
||||||
|
{
|
||||||
|
Layout layout => BuildLayout(layout),
|
||||||
|
Theme theme => BuildTheme(theme),
|
||||||
|
_ => throw new ArgumentException($"Unsupported item type {item.GetType().Name}", nameof(item))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Package BuildTheme(Theme theme)
|
||||||
|
{
|
||||||
|
JsonObject root = new()
|
||||||
|
{
|
||||||
|
["format"] = FormatVersion,
|
||||||
|
["type"] = ThemeTag,
|
||||||
|
["themes"] = new JsonArray(SerializeTheme(theme)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Package(root, ComputeHash(root), [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Package BuildLayout(Layout layout)
|
||||||
|
{
|
||||||
|
DesktopMagicSettings settings = Manager.Instance.Settings;
|
||||||
|
IReadOnlyDictionary<uint, InternalPluginData> installedPlugins = Manager.Instance.Plugins;
|
||||||
|
|
||||||
|
Layout copy = new(layout.Name)
|
||||||
|
{
|
||||||
|
ScreenAspectRatio = layout.ScreenAspectRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<string> themeNames = [layout.Theme.Name];
|
||||||
|
List<uint> requiredPlugins = [];
|
||||||
|
List<string> localPlugins = [];
|
||||||
|
|
||||||
|
// Only enabled widgets are shared. Disabled entries are just placeholders for every installed plugin.
|
||||||
|
foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled).OrderBy(kvp => kvp.Key))
|
||||||
|
{
|
||||||
|
copy.Plugins[pluginId] = new PluginSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Position = pluginSettings.Position,
|
||||||
|
Size = pluginSettings.Size,
|
||||||
|
CurrentThemeName = pluginSettings.CurrentThemeName,
|
||||||
|
Settings = pluginSettings.Settings.Where(setting => setting.Shareable).ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pluginSettings.CurrentThemeName is not null && settings.Themes.Any(t => t.Name == pluginSettings.CurrentThemeName) && !themeNames.Contains(pluginSettings.CurrentThemeName))
|
||||||
|
{
|
||||||
|
themeNames.Add(pluginSettings.CurrentThemeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in plugins have no directory and are available everywhere.
|
||||||
|
if (installedPlugins.TryGetValue(pluginId, out InternalPluginData? pluginData) && !string.IsNullOrEmpty(pluginData.DirectoryPath))
|
||||||
|
{
|
||||||
|
if (pluginData.Metadata.IsLocalPlugin)
|
||||||
|
{
|
||||||
|
localPlugins.Add(pluginData.Metadata.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
requiredPlugins.Add(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy.CurrentThemeName = layout.Theme.Name;
|
||||||
|
|
||||||
|
JsonObject layoutNode = JsonSerializer.SerializeToNode(copy, jsonOptions)!.AsObject();
|
||||||
|
_ = layoutNode.Remove(nameof(Layout.ModIo));
|
||||||
|
|
||||||
|
JsonArray themesNode = [];
|
||||||
|
foreach (string themeName in themeNames)
|
||||||
|
{
|
||||||
|
Theme? theme = settings.Themes.FirstOrDefault(t => t.Name == themeName);
|
||||||
|
if (theme is not null)
|
||||||
|
{
|
||||||
|
themesNode.Add(SerializeTheme(theme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject root = new()
|
||||||
|
{
|
||||||
|
["format"] = FormatVersion,
|
||||||
|
["type"] = LayoutTag,
|
||||||
|
["layout"] = layoutNode,
|
||||||
|
["themes"] = themesNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Required plugins are derived from the layout and depend on what is currently loaded, so they are not part of the hash.
|
||||||
|
string hash = ComputeHash(root);
|
||||||
|
root["requiredPlugins"] = new JsonArray(requiredPlugins.Select(id => (JsonNode)id).ToArray());
|
||||||
|
|
||||||
|
return new Package(root, hash, requiredPlugins, localPlugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject Parse(string json)
|
||||||
|
{
|
||||||
|
JsonObject root = JsonNode.Parse(json)?.AsObject() ?? throw new FormatException("The downloaded content is empty.");
|
||||||
|
|
||||||
|
int format = root["format"]?.GetValue<int>() ?? 0;
|
||||||
|
if (format is < 1 or > FormatVersion)
|
||||||
|
{
|
||||||
|
throw new FormatException($"Unsupported content format {format}. Please update DesktopMagic.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<uint> GetRequiredPlugins(JsonObject root)
|
||||||
|
{
|
||||||
|
return root["requiredPlugins"]?.AsArray().Select(node => node!.GetValue<uint>()).ToList() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the downloaded content to the settings, or updates <paramref name="existing"/> with it.
|
||||||
|
/// </summary>
|
||||||
|
public static IModIoShareable Apply(JsonObject root, IModIoShareable? existing)
|
||||||
|
{
|
||||||
|
DesktopMagicSettings settings = Manager.Instance.Settings;
|
||||||
|
string type = root["type"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
|
||||||
|
List<Theme> themes = root["themes"]?.AsArray()
|
||||||
|
.Select(node => node.Deserialize<Theme>(jsonOptions))
|
||||||
|
.OfType<Theme>()
|
||||||
|
.ToList() ?? [];
|
||||||
|
|
||||||
|
if (type == ThemeTag)
|
||||||
|
{
|
||||||
|
Theme incoming = themes.FirstOrDefault() ?? throw new FormatException("The downloaded theme is empty.");
|
||||||
|
incoming.ModIo = null;
|
||||||
|
|
||||||
|
if (existing is Theme existingTheme)
|
||||||
|
{
|
||||||
|
CopyThemeValues(incoming, existingTheme);
|
||||||
|
return existingTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.Name = GetUniqueName(incoming.Name, settings.Themes.Select(t => t.Name));
|
||||||
|
settings.Themes.Add(incoming);
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type != LayoutTag)
|
||||||
|
{
|
||||||
|
throw new FormatException($"Unknown content type \"{type}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject layoutNode = root["layout"]?.AsObject() ?? throw new FormatException("The downloaded layout is empty.");
|
||||||
|
Layout incomingLayout = layoutNode.Deserialize<Layout>(jsonOptions) ?? throw new FormatException("The downloaded layout is invalid.");
|
||||||
|
string layoutName = existing?.Name ?? GetUniqueName(incomingLayout.Name, settings.Layouts.Select(l => l.Name));
|
||||||
|
|
||||||
|
Dictionary<string, string> themeNameMap = [];
|
||||||
|
foreach (Theme theme in themes)
|
||||||
|
{
|
||||||
|
themeNameMap[theme.Name] = AddEmbeddedTheme(theme, layoutName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the raw name, the getter falls back to another theme when the name does not exist yet.
|
||||||
|
string? layoutThemeName = layoutNode[nameof(Layout.CurrentThemeName)]?.GetValue<string>();
|
||||||
|
string? mappedLayoutThemeName = layoutThemeName is not null && themeNameMap.TryGetValue(layoutThemeName, out string? mapped) ? mapped : layoutThemeName;
|
||||||
|
|
||||||
|
Layout target = existing as Layout ?? new Layout(layoutName);
|
||||||
|
|
||||||
|
Dictionary<uint, PluginSettings> plugins = [];
|
||||||
|
foreach ((uint pluginId, PluginSettings incomingPlugin) in incomingLayout.Plugins)
|
||||||
|
{
|
||||||
|
if (incomingPlugin.CurrentThemeName is not null && themeNameMap.TryGetValue(incomingPlugin.CurrentThemeName, out string? pluginThemeName))
|
||||||
|
{
|
||||||
|
incomingPlugin.CurrentThemeName = pluginThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep values that are never shared (e.g. URLs or locations) and saved widget data from the local copy.
|
||||||
|
if (target.Plugins.TryGetValue(pluginId, out PluginSettings? localPlugin))
|
||||||
|
{
|
||||||
|
incomingPlugin.State = localPlugin.State;
|
||||||
|
incomingPlugin.Settings = [.. incomingPlugin.Settings, .. localPlugin.Settings.Where(local => !incomingPlugin.Settings.Exists(s => s.Id == local.Id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
incomingPlugin.Owner = target;
|
||||||
|
plugins[pluginId] = incomingPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widgets the new version no longer uses are disabled, keeping their settings.
|
||||||
|
foreach ((uint pluginId, PluginSettings localPlugin) in target.Plugins.Where(kvp => !plugins.ContainsKey(kvp.Key)))
|
||||||
|
{
|
||||||
|
localPlugin.Enabled = false;
|
||||||
|
plugins[pluginId] = localPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.ScreenAspectRatio = incomingLayout.ScreenAspectRatio;
|
||||||
|
target.Plugins = plugins;
|
||||||
|
target.CurrentThemeName = mappedLayoutThemeName;
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
settings.Layouts.Add(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a theme that is embedded in a layout and returns the local name it is available under.
|
||||||
|
/// </summary>
|
||||||
|
private static string AddEmbeddedTheme(Theme theme, string layoutName)
|
||||||
|
{
|
||||||
|
DesktopMagicSettings settings = Manager.Instance.Settings;
|
||||||
|
theme.ModIo = null;
|
||||||
|
|
||||||
|
Theme? sameName = settings.Themes.FirstOrDefault(t => t.Name == theme.Name);
|
||||||
|
if (sameName is null)
|
||||||
|
{
|
||||||
|
settings.Themes.Add(theme);
|
||||||
|
return theme.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasSameValues(sameName, theme))
|
||||||
|
{
|
||||||
|
return sameName.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different theme with the same name exists (e.g. "Default"). Use a name tied to the layout,
|
||||||
|
// which later updates of the same layout overwrite instead of adding more copies.
|
||||||
|
string layoutThemeName = $"{theme.Name} ({layoutName})";
|
||||||
|
Theme? layoutTheme = settings.Themes.FirstOrDefault(t => t.Name == layoutThemeName);
|
||||||
|
|
||||||
|
if (layoutTheme is null)
|
||||||
|
{
|
||||||
|
theme.Name = layoutThemeName;
|
||||||
|
settings.Themes.Add(theme);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CopyThemeValues(theme, layoutTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layoutThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyThemeValues(Theme source, Theme target)
|
||||||
|
{
|
||||||
|
target.PrimaryColor = source.PrimaryColor;
|
||||||
|
target.SecondaryColor = source.SecondaryColor;
|
||||||
|
target.BackgroundColor = source.BackgroundColor;
|
||||||
|
target.Font = source.Font;
|
||||||
|
target.CornerRadius = source.CornerRadius;
|
||||||
|
target.Margin = source.Margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasSameValues(Theme a, Theme b)
|
||||||
|
{
|
||||||
|
JsonObject nodeA = SerializeTheme(a);
|
||||||
|
JsonObject nodeB = SerializeTheme(b);
|
||||||
|
_ = nodeA.Remove(nameof(Theme.Name));
|
||||||
|
_ = nodeB.Remove(nameof(Theme.Name));
|
||||||
|
|
||||||
|
return JsonNode.DeepEquals(nodeA, nodeB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject SerializeTheme(Theme theme)
|
||||||
|
{
|
||||||
|
JsonObject node = JsonSerializer.SerializeToNode(theme, jsonOptions)!.AsObject();
|
||||||
|
_ = node.Remove(nameof(Theme.ModIo));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetUniqueName(string name, IEnumerable<string> existingNames)
|
||||||
|
{
|
||||||
|
HashSet<string> names = existingNames.ToHashSet();
|
||||||
|
if (!names.Contains(name))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
int index = 2;
|
||||||
|
while (names.Contains($"{name} ({index})"))
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{name} ({index})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeHash(JsonObject root)
|
||||||
|
{
|
||||||
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(root.ToJsonString()));
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using DesktopMagic.Plugins;
|
||||||
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
using Point = System.Windows.Point;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the logo image used for layouts and themes on mod.io (mod.io requires one, at least 512x288).
|
||||||
|
/// </summary>
|
||||||
|
internal static class SharePreviewRenderer
|
||||||
|
{
|
||||||
|
private const int Width = 1280;
|
||||||
|
private const int Height = 720;
|
||||||
|
|
||||||
|
private static readonly Color canvasColor = Color.FromRgb(0x1C, 0x1C, 0x24);
|
||||||
|
private static readonly Color screenColor = Color.FromRgb(0x2E, 0x2E, 0x3A);
|
||||||
|
|
||||||
|
public static void Render(IModIoShareable item, string path)
|
||||||
|
{
|
||||||
|
DrawingVisual visual = new();
|
||||||
|
|
||||||
|
using (DrawingContext context = visual.RenderOpen())
|
||||||
|
{
|
||||||
|
context.DrawRectangle(new SolidColorBrush(canvasColor), null, new Rect(0, 0, Width, Height));
|
||||||
|
|
||||||
|
switch (item)
|
||||||
|
{
|
||||||
|
case Layout layout:
|
||||||
|
DrawLayout(context, layout);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Theme theme:
|
||||||
|
DrawTheme(context, theme);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderTargetBitmap bitmap = new(Width, Height, 96, 96, PixelFormats.Pbgra32);
|
||||||
|
bitmap.Render(visual);
|
||||||
|
|
||||||
|
PngBitmapEncoder encoder = new();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||||
|
|
||||||
|
using FileStream stream = File.Create(path);
|
||||||
|
encoder.Save(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawLayout(DrawingContext context, Layout layout)
|
||||||
|
{
|
||||||
|
const double padding = 60;
|
||||||
|
double aspectRatio = layout.ScreenAspectRatio > 0 ? layout.ScreenAspectRatio : 16.0 / 9.0;
|
||||||
|
|
||||||
|
double availableWidth = Width - (padding * 2);
|
||||||
|
double availableHeight = Height - (padding * 2);
|
||||||
|
double screenWidth = Math.Min(availableWidth, availableHeight * aspectRatio);
|
||||||
|
double screenHeight = screenWidth / aspectRatio;
|
||||||
|
|
||||||
|
Rect screen = new((Width - screenWidth) / 2, (Height - screenHeight) / 2, screenWidth, screenHeight);
|
||||||
|
context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, screen, 12, 12);
|
||||||
|
|
||||||
|
foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled))
|
||||||
|
{
|
||||||
|
Theme theme = pluginSettings.Theme;
|
||||||
|
|
||||||
|
Rect widget = new(
|
||||||
|
screen.X + (pluginSettings.Position.X * screen.Width),
|
||||||
|
screen.Y + (pluginSettings.Position.Y * screen.Height),
|
||||||
|
Math.Max(4, pluginSettings.Size.X * screen.Width),
|
||||||
|
Math.Max(4, pluginSettings.Size.Y * screen.Height));
|
||||||
|
widget.Intersect(screen);
|
||||||
|
|
||||||
|
if (widget.IsEmpty)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor);
|
||||||
|
if (background.A < 0x30)
|
||||||
|
{
|
||||||
|
background = Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor);
|
||||||
|
double radius = Math.Min(theme.CornerRadius * screen.Width / 1920, Math.Min(widget.Width, widget.Height) / 2);
|
||||||
|
|
||||||
|
context.DrawRoundedRectangle(new SolidColorBrush(background), new Pen(new SolidColorBrush(primary), 2), widget, radius, radius);
|
||||||
|
|
||||||
|
string name = Manager.Instance.Plugins.TryGetValue(pluginId, out InternalPluginData? pluginData) ? pluginData.Metadata.Name : pluginSettings.Metadata.Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FormattedText text = CreateText(name, theme.Font, Math.Clamp(widget.Height / 4, 10, 28), primary);
|
||||||
|
text.MaxTextWidth = Math.Max(1, widget.Width - 12);
|
||||||
|
text.MaxTextHeight = Math.Max(1, widget.Height - 8);
|
||||||
|
text.Trimming = TextTrimming.CharacterEllipsis;
|
||||||
|
|
||||||
|
context.DrawText(text, new Point(widget.X + 6, widget.Y + ((widget.Height - Math.Min(text.Height, text.MaxTextHeight)) / 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawTheme(DrawingContext context, Theme theme)
|
||||||
|
{
|
||||||
|
Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor);
|
||||||
|
Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor);
|
||||||
|
Color secondary = MultiColorConverter.ConvertToMediaColor(theme.SecondaryColor);
|
||||||
|
|
||||||
|
Rect card = new(120, 100, Width - 240, Height - 200);
|
||||||
|
double radius = Math.Clamp(theme.CornerRadius * 2, 0, card.Height / 2);
|
||||||
|
|
||||||
|
context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, card, radius, radius);
|
||||||
|
context.DrawRoundedRectangle(new SolidColorBrush(background), null, card, radius, radius);
|
||||||
|
|
||||||
|
FormattedText title = CreateText(theme.Name, theme.Font, 96, primary);
|
||||||
|
title.MaxTextWidth = card.Width - 120;
|
||||||
|
title.MaxLineCount = 1;
|
||||||
|
title.Trimming = TextTrimming.CharacterEllipsis;
|
||||||
|
context.DrawText(title, new Point(card.X + 60, card.Y + 60));
|
||||||
|
|
||||||
|
FormattedText subtitle = CreateText(theme.Font, theme.Font, 48, secondary);
|
||||||
|
subtitle.MaxTextWidth = card.Width - 120;
|
||||||
|
subtitle.MaxLineCount = 1;
|
||||||
|
subtitle.Trimming = TextTrimming.CharacterEllipsis;
|
||||||
|
context.DrawText(subtitle, new Point(card.X + 60, card.Y + 60 + title.Height + 10));
|
||||||
|
|
||||||
|
Color[] swatches = [primary, secondary, background];
|
||||||
|
for (int i = 0; i < swatches.Length; i++)
|
||||||
|
{
|
||||||
|
Point center = new(card.X + 110 + (i * 130), card.Bottom - 110);
|
||||||
|
context.DrawEllipse(new SolidColorBrush(swatches[i]), new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x80, 0x80, 0x80)), 3), center, 50, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormattedText CreateText(string text, string font, double size, Color color)
|
||||||
|
{
|
||||||
|
return new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, new Typeface(font), size, new SolidColorBrush(color), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
MinHeight="600"
|
MinHeight="600"
|
||||||
MinWidth="940"
|
MinWidth="940"
|
||||||
ExtendsContentIntoTitleBar="True"
|
ExtendsContentIntoTitleBar="True"
|
||||||
WindowState="Minimized"
|
WindowState="Normal"
|
||||||
Loaded="Window_Loaded">
|
Loaded="Window_Loaded">
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -39,6 +40,8 @@ public partial class MainWindow : FluentWindow
|
|||||||
{
|
{
|
||||||
App.Logger.LogInfo("Loading application", source: "MainWindow");
|
App.Logger.LogInfo("Loading application", source: "MainWindow");
|
||||||
|
|
||||||
|
Microsoft.Win32.SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged;
|
||||||
|
|
||||||
_mainWindowDataContext.IsLoading = true;
|
_mainWindowDataContext.IsLoading = true;
|
||||||
|
|
||||||
// Load plugins and settings through manager
|
// Load plugins and settings through manager
|
||||||
@@ -47,6 +50,22 @@ 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)
|
||||||
|
{
|
||||||
|
_manager.Settings.IsFirstRun = false;
|
||||||
|
_ = Activate();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WindowState = WindowState.Minimized;
|
||||||
|
Visibility = Visibility.Collapsed;
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
}
|
||||||
|
|
||||||
_manager.IsLoaded = true;
|
_manager.IsLoaded = true;
|
||||||
_mainWindowDataContext.IsLoading = false;
|
_mainWindowDataContext.IsLoading = false;
|
||||||
await ShowLatestReleaseInfoAfterUpdateAsync();
|
await ShowLatestReleaseInfoAfterUpdateAsync();
|
||||||
@@ -87,6 +106,11 @@ public partial class MainWindow : FluentWindow
|
|||||||
|
|
||||||
private void Window_Closed(object sender, EventArgs e)
|
private void Window_Closed(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged;
|
||||||
|
|
||||||
|
occlusionMonitor?.Dispose();
|
||||||
|
occlusionMonitor = null;
|
||||||
|
|
||||||
Visibility = Visibility.Collapsed;
|
Visibility = Visibility.Collapsed;
|
||||||
UpdateLayout();
|
UpdateLayout();
|
||||||
_manager.CloseAllPluginWindows();
|
_manager.CloseAllPluginWindows();
|
||||||
@@ -101,6 +125,16 @@ public partial class MainWindow : FluentWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SystemEvents_DisplaySettingsChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// Re-enumerate screens and reload all widget windows when monitors are added or removed.
|
||||||
|
_ = Application.Current.Dispatcher.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
_mainWindowDataContext.RefreshScreens();
|
||||||
|
_manager.LoadLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
internal void RestoreWindow()
|
internal void RestoreWindow()
|
||||||
{
|
{
|
||||||
for (int i = 0; i < 10; i++)
|
for (int i = 0; i < 10; i++)
|
||||||
|
|||||||
+267
-35
@@ -5,6 +5,7 @@ using DesktopMagic.Settings;
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -35,6 +36,9 @@ public sealed class Manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name of the built-in layout that shows no widgets on a screen.
|
||||||
|
public const string EmptyLayoutName = "Empty";
|
||||||
|
|
||||||
// Plugin management
|
// Plugin management
|
||||||
private readonly Dictionary<uint, InternalPluginData> _plugins = [];
|
private readonly Dictionary<uint, InternalPluginData> _plugins = [];
|
||||||
private readonly Dictionary<PluginMetadata, Type> _builtInPlugins = new()
|
private readonly Dictionary<PluginMetadata, Type> _builtInPlugins = new()
|
||||||
@@ -46,6 +50,10 @@ public sealed class Manager
|
|||||||
{new((string)App.LanguageDictionary["weather"], 5) { Author = "Stone_Red" }, typeof(WeatherPlugin)},
|
{new((string)App.LanguageDictionary["weather"], 5) { Author = "Stone_Red" }, typeof(WeatherPlugin)},
|
||||||
{new((string)App.LanguageDictionary["nextMeetingCountdown"], 6) { Author = "Stone_Red" }, typeof(NextMeetingCountdownPlugin)},
|
{new((string)App.LanguageDictionary["nextMeetingCountdown"], 6) { Author = "Stone_Red" }, typeof(NextMeetingCountdownPlugin)},
|
||||||
{new((string)App.LanguageDictionary["agenda"], 7) { Author = "Stone_Red" }, typeof(AgendaPlugin)},
|
{new((string)App.LanguageDictionary["agenda"], 7) { Author = "Stone_Red" }, typeof(AgendaPlugin)},
|
||||||
|
#if DEBUG
|
||||||
|
{new((string)App.LanguageDictionary["benchmark"], 8) { Author = "Stone_Red" }, typeof(BenchmarkPlugin)},
|
||||||
|
{new((string)App.LanguageDictionary["skiaBenchmark"], 9) { Author = "Stone_Red" }, typeof(SkiaBenchmarkPlugin)},
|
||||||
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
// Window management
|
// Window management
|
||||||
@@ -63,6 +71,13 @@ public sealed class Manager
|
|||||||
public DesktopMagicSettings Settings { get; set; } = new();
|
public DesktopMagicSettings Settings { get; set; } = new();
|
||||||
public bool IsLoaded { get; set; } = false;
|
public bool IsLoaded { get; set; } = false;
|
||||||
|
|
||||||
|
// Screen selection (which screen is currently edited in the UI)
|
||||||
|
public string? SelectedScreenDeviceName { get; set; }
|
||||||
|
|
||||||
|
public System.Windows.Forms.Screen SelectedScreen => ScreenUtilities.GetScreenByDeviceName(SelectedScreenDeviceName) ?? ScreenUtilities.GetPrimaryScreen();
|
||||||
|
|
||||||
|
public Layout SelectedLayout => GetLayoutForScreen(SelectedScreen);
|
||||||
|
|
||||||
private readonly JsonSerializerOptions _jsonSettingsOptions = new()
|
private readonly JsonSerializerOptions _jsonSettingsOptions = new()
|
||||||
{
|
{
|
||||||
Converters = { new ColorJsonConverter() }
|
Converters = { new ColorJsonConverter() }
|
||||||
@@ -135,20 +150,46 @@ public sealed class Manager
|
|||||||
App.Logger.LogInfo($"Loaded {_plugins.Count} plugins", source: "Manager");
|
App.Logger.LogInfo($"Loaded {_plugins.Count} plugins", source: "Manager");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadPlugin(uint pluginId, Action<InternalPluginData>? onPluginLoaded = null)
|
/// <summary>
|
||||||
|
/// Loads (or unloads) the given plugin on every screen that currently uses the given layout.
|
||||||
|
/// This keeps screens sharing a layout in sync when plugins are enabled or disabled.
|
||||||
|
/// </summary>
|
||||||
|
public void LoadPlugin(uint pluginId, Layout layout, Action<InternalPluginData>? onPluginLoaded = null)
|
||||||
{
|
{
|
||||||
if (!_plugins.TryGetValue(pluginId, out InternalPluginData? internalPluginData))
|
if (!_plugins.TryGetValue(pluginId, out InternalPluginData? internalPluginData))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
|
if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
|
||||||
{
|
{
|
||||||
pluginSettings = new PluginSettings();
|
pluginSettings = new PluginSettings();
|
||||||
Settings.CurrentLayout.Plugins.Add(pluginId, pluginSettings);
|
layout.Plugins.Add(pluginId, pluginSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
IPluginWindow? existingWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id);
|
pluginSettings.Metadata = internalPluginData.Metadata;
|
||||||
|
pluginSettings.Owner = layout;
|
||||||
|
|
||||||
|
foreach (System.Windows.Forms.Screen screen in ScreenUtilities.GetAllScreens())
|
||||||
|
{
|
||||||
|
if (GetLayoutForScreen(screen) == layout)
|
||||||
|
{
|
||||||
|
EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, onPluginLoaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.UpdatePlugins();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates or closes the plugin window for a single screen, based on the plugin settings.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsurePluginWindow(System.Windows.Forms.Screen screen, Layout layout, InternalPluginData internalPluginData, PluginSettings pluginSettings, Action<InternalPluginData>? onPluginLoaded)
|
||||||
|
{
|
||||||
|
string screenDeviceName = screen.DeviceName;
|
||||||
|
Rectangle screenBounds = screen.Bounds;
|
||||||
|
|
||||||
|
IPluginWindow? existingWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id && w.ScreenDeviceName == screenDeviceName);
|
||||||
|
|
||||||
if (existingWindow is not null || !pluginSettings.Enabled)
|
if (existingWindow is not null || !pluginSettings.Enabled)
|
||||||
{
|
{
|
||||||
@@ -174,21 +215,21 @@ public sealed class Manager
|
|||||||
|
|
||||||
if (_builtInPlugins.TryGetValue(internalPluginData.Metadata, out Type? pluginType))
|
if (_builtInPlugins.TryGetValue(internalPluginData.Metadata, out Type? pluginType))
|
||||||
{
|
{
|
||||||
window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings)
|
window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings, screenBounds, screenDeviceName)
|
||||||
{
|
{
|
||||||
Title = internalPluginData.Metadata.Id.ToString()
|
Title = internalPluginData.Metadata.Id.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if (internalPluginData.Type == PluginType.Web)
|
else if (internalPluginData.Type == PluginType.Web)
|
||||||
{
|
{
|
||||||
window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath)
|
window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName)
|
||||||
{
|
{
|
||||||
Title = internalPluginData.Metadata.Id.ToString()
|
Title = internalPluginData.Metadata.Id.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath)
|
window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName)
|
||||||
{
|
{
|
||||||
Title = internalPluginData.Metadata.Id.ToString()
|
Title = internalPluginData.Metadata.Id.ToString()
|
||||||
};
|
};
|
||||||
@@ -203,10 +244,21 @@ public sealed class Manager
|
|||||||
|
|
||||||
Action exitHandler = () =>
|
Action exitHandler = () =>
|
||||||
{
|
{
|
||||||
PluginWindows.Remove(window);
|
// Close the widget on every screen using this layout
|
||||||
BlockWindowsClosing = false;
|
foreach (System.Windows.Forms.Screen sharedScreen in ScreenUtilities.GetAllScreens())
|
||||||
window.Close();
|
{
|
||||||
BlockWindowsClosing = true;
|
if (GetLayoutForScreen(sharedScreen) == layout)
|
||||||
|
{
|
||||||
|
IPluginWindow? sharedWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id && w.ScreenDeviceName == sharedScreen.DeviceName);
|
||||||
|
if (sharedWindow is not null)
|
||||||
|
{
|
||||||
|
PluginWindows.Remove(sharedWindow);
|
||||||
|
BlockWindowsClosing = false;
|
||||||
|
sharedWindow.Close();
|
||||||
|
BlockWindowsClosing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pluginSettings.Enabled = false;
|
pluginSettings.Enabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,7 +276,7 @@ public sealed class Manager
|
|||||||
App.Logger.LogInfo("Reloading plugins", source: "PluginManager");
|
App.Logger.LogInfo("Reloading plugins", source: "PluginManager");
|
||||||
|
|
||||||
LoadPlugins();
|
LoadPlugins();
|
||||||
LoadLayout(false);
|
LoadLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetEditMode(bool editMode)
|
public void SetEditMode(bool editMode)
|
||||||
@@ -241,6 +293,32 @@ public sealed class Manager
|
|||||||
SaveSettings();
|
SaveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<(Layout, uint), SettingSynchronizer> settingSynchronizers = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets (or creates) the synchronizer that keeps the settings of all windows showing the
|
||||||
|
/// given plugin in the given layout in sync.
|
||||||
|
/// </summary>
|
||||||
|
internal SettingSynchronizer GetSettingSynchronizer(Layout layout, uint pluginId)
|
||||||
|
{
|
||||||
|
(Layout, uint) key = (layout, pluginId);
|
||||||
|
if (!settingSynchronizers.TryGetValue(key, out SettingSynchronizer? synchronizer))
|
||||||
|
{
|
||||||
|
synchronizer = new SettingSynchronizer();
|
||||||
|
settingSynchronizers.Add(key, synchronizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return synchronizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the synchronizer for the given plugin in the given layout once no windows use it anymore.
|
||||||
|
/// </summary>
|
||||||
|
internal void ReleaseSettingSynchronizer(Layout layout, uint pluginId)
|
||||||
|
{
|
||||||
|
_ = settingSynchronizers.Remove((layout, pluginId));
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Settings Management
|
#region Settings Management
|
||||||
@@ -253,7 +331,9 @@ public sealed class Manager
|
|||||||
{
|
{
|
||||||
Settings = new DesktopMagicSettings();
|
Settings = new DesktopMagicSettings();
|
||||||
Settings.Layouts.Add(new Layout("Default"));
|
Settings.Layouts.Add(new Layout("Default"));
|
||||||
|
Settings.Layouts.Add(new Layout(EmptyLayoutName));
|
||||||
Settings.Themes.Add(new Theme("Default"));
|
Settings.Themes.Add(new Theme("Default"));
|
||||||
|
Settings.SchemaVersion = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,9 +350,62 @@ public sealed class Manager
|
|||||||
Settings.Themes.Add(new Theme("Default"));
|
Settings.Themes.Add(new Theme("Default"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Settings.SchemaVersion < 1)
|
||||||
|
{
|
||||||
|
MigrateToScreenAwareSettings();
|
||||||
|
Settings.SchemaVersion = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Settings.Layouts.Any(layout => layout.Name == EmptyLayoutName))
|
||||||
|
{
|
||||||
|
Settings.Layouts.Add(new Layout(EmptyLayoutName));
|
||||||
|
}
|
||||||
|
|
||||||
SettingsChanged?.Invoke();
|
SettingsChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migrates legacy settings to screen-aware layouts:
|
||||||
|
/// records the primary screen aspect ratio on each layout and converts
|
||||||
|
/// absolute pixel positions/sizes to percentages of the primary screen bounds.
|
||||||
|
/// </summary>
|
||||||
|
private void MigrateToScreenAwareSettings()
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo("Migrating settings to screen-aware layouts", source: "Manager");
|
||||||
|
|
||||||
|
System.Windows.Forms.Screen primaryScreen = ScreenUtilities.GetPrimaryScreen();
|
||||||
|
double aspectRatio = ScreenUtilities.GetAspectRatio(primaryScreen);
|
||||||
|
Rectangle bounds = primaryScreen.Bounds;
|
||||||
|
|
||||||
|
foreach (Layout layout in Settings.Layouts)
|
||||||
|
{
|
||||||
|
if (layout.ScreenAspectRatio <= 0)
|
||||||
|
{
|
||||||
|
layout.ScreenAspectRatio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PluginSettings plugin in layout.Plugins.Values)
|
||||||
|
{
|
||||||
|
if (plugin.Position.X > 1 || plugin.Position.Y > 1 || plugin.Position.X < 0 || plugin.Position.Y < 0)
|
||||||
|
{
|
||||||
|
plugin.Position = ScreenUtilities.PositionToPercent(new System.Windows.Point(plugin.Position.X, plugin.Position.Y), bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.Size.X > 1 || plugin.Size.Y > 1)
|
||||||
|
{
|
||||||
|
plugin.Size = ScreenUtilities.SizeToPercent(new System.Windows.Point(plugin.Size.X, plugin.Size.Y), bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.ScreenLayouts.Count == 0)
|
||||||
|
{
|
||||||
|
Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(primaryScreen)] = Settings.CurrentLayoutName ?? "Default";
|
||||||
|
}
|
||||||
|
|
||||||
|
App.Logger.LogInfo("Settings migrated to screen-aware layouts", source: "Manager");
|
||||||
|
}
|
||||||
|
|
||||||
public void SaveSettings()
|
public void SaveSettings()
|
||||||
{
|
{
|
||||||
if (!IsLoaded)
|
if (!IsLoaded)
|
||||||
@@ -290,9 +423,81 @@ public sealed class Manager
|
|||||||
|
|
||||||
#region Layout Management
|
#region Layout Management
|
||||||
|
|
||||||
public void LoadLayout(bool minimize = true, Action? onComplete = null)
|
/// <summary>
|
||||||
|
/// Resolves the layout that should be applied to the given screen:
|
||||||
|
/// 1. the layout explicitly bound to this screen's device name,
|
||||||
|
/// 2. the layout with the closest matching aspect ratio,
|
||||||
|
/// 3. the first layout.
|
||||||
|
/// </summary>
|
||||||
|
public Layout GetLayoutForScreen(System.Windows.Forms.Screen screen)
|
||||||
{
|
{
|
||||||
App.Logger.LogInfo("Loading layout", source: "Manager");
|
if (Settings.ScreenLayouts.TryGetValue(ScreenUtilities.GetMonitorHardwareId(screen), out string? layoutName))
|
||||||
|
{
|
||||||
|
Layout? bound = Settings.Layouts.FirstOrDefault(layout => layout.Name == layoutName);
|
||||||
|
if (bound is not null)
|
||||||
|
{
|
||||||
|
return bound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double targetRatio = ScreenUtilities.GetAspectRatio(screen);
|
||||||
|
Layout? byAspectRatio = Settings.Layouts
|
||||||
|
.Where(layout => layout.ScreenAspectRatio > 0)
|
||||||
|
.OrderBy(layout => Math.Abs(layout.ScreenAspectRatio - targetRatio))
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (byAspectRatio is not null)
|
||||||
|
{
|
||||||
|
return byAspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Settings.Layouts.FirstOrDefault() ?? new Layout("ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the given layout to the given screen on this machine.
|
||||||
|
/// </summary>
|
||||||
|
public void BindLayoutToScreen(System.Windows.Forms.Screen screen, Layout layout)
|
||||||
|
{
|
||||||
|
Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(screen)] = layout.Name;
|
||||||
|
SaveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the layout, drops the screen bindings pointing to it and reloads the screens.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveLayout(Layout layout)
|
||||||
|
{
|
||||||
|
_ = Settings.Layouts.Remove(layout);
|
||||||
|
|
||||||
|
List<string> boundScreens = Settings.ScreenLayouts
|
||||||
|
.Where(kvp => kvp.Value == layout.Name)
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (string screenId in boundScreens)
|
||||||
|
{
|
||||||
|
_ = Settings.ScreenLayouts.Remove(screenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveSettings();
|
||||||
|
LoadLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all plugin windows currently shown on the given screen.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<IPluginWindow> GetWindowsForScreen(string screenDeviceName)
|
||||||
|
{
|
||||||
|
return PluginWindows.Where(window => window.ScreenDeviceName == screenDeviceName).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads all screens' layouts at once, opening the enabled widgets of every screen.
|
||||||
|
/// </summary>
|
||||||
|
public void LoadLayout(Action? onComplete = null)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo("Loading layouts", source: "Manager");
|
||||||
BlockWindowsClosing = false;
|
BlockWindowsClosing = false;
|
||||||
|
|
||||||
foreach (IPluginWindow window in PluginWindows)
|
foreach (IPluginWindow window in PluginWindows)
|
||||||
@@ -303,7 +508,47 @@ public sealed class Manager
|
|||||||
BlockWindowsClosing = true;
|
BlockWindowsClosing = true;
|
||||||
PluginWindows.Clear();
|
PluginWindows.Clear();
|
||||||
|
|
||||||
bool showWindow = true;
|
foreach (System.Windows.Forms.Screen screen in ScreenUtilities.GetAllScreens())
|
||||||
|
{
|
||||||
|
Layout layout = GetLayoutForScreen(screen);
|
||||||
|
LoadScreen(screen, layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete?.Invoke();
|
||||||
|
App.Logger.LogInfo("Layouts loaded", source: "Manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the widgets of a single screen using the layout currently bound to it.
|
||||||
|
/// </summary>
|
||||||
|
public void ReloadScreen(System.Windows.Forms.Screen screen)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"Reloading screen {screen.DeviceName}", source: "Manager");
|
||||||
|
|
||||||
|
List<IPluginWindow> windows = PluginWindows.Where(window => window.ScreenDeviceName == screen.DeviceName).ToList();
|
||||||
|
|
||||||
|
BlockWindowsClosing = false;
|
||||||
|
foreach (IPluginWindow window in windows)
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockWindowsClosing = true;
|
||||||
|
PluginWindows.RemoveAll(window => windows.Contains(window));
|
||||||
|
|
||||||
|
Layout layout = GetLayoutForScreen(screen);
|
||||||
|
LoadScreen(screen, layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadScreen(System.Windows.Forms.Screen screen, Layout layout)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"Loading layout \"{layout.Name}\" for screen {screen.DeviceName}", source: "Manager");
|
||||||
|
|
||||||
|
// The empty layout intentionally shows no widgets and is never populated.
|
||||||
|
if (layout.Name == EmptyLayoutName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Load plugins
|
// Load plugins
|
||||||
foreach (uint pluginId in _plugins.Keys)
|
foreach (uint pluginId in _plugins.Keys)
|
||||||
@@ -311,42 +556,29 @@ public sealed class Manager
|
|||||||
InternalPluginData internalPluginData = _plugins[pluginId];
|
InternalPluginData internalPluginData = _plugins[pluginId];
|
||||||
|
|
||||||
// Add plugin to layout if it doesn't exist
|
// Add plugin to layout if it doesn't exist
|
||||||
if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
|
if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings))
|
||||||
{
|
{
|
||||||
Settings.CurrentLayout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata });
|
layout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata, Owner = layout });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginSettings.Metadata = internalPluginData.Metadata;
|
pluginSettings.Metadata = internalPluginData.Metadata;
|
||||||
|
pluginSettings.Owner = layout;
|
||||||
|
|
||||||
if (pluginSettings.Enabled)
|
if (pluginSettings.Enabled)
|
||||||
{
|
{
|
||||||
LoadPlugin(pluginId);
|
EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, null);
|
||||||
}
|
|
||||||
|
|
||||||
if (showWindow && pluginSettings.Enabled)
|
|
||||||
{
|
|
||||||
showWindow = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove plugins that are not loaded anymore
|
// Remove plugins that are not loaded anymore
|
||||||
List<uint> pluginIdsToRemove = Settings.CurrentLayout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList();
|
List<uint> pluginIdsToRemove = layout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList();
|
||||||
foreach (uint pluginId in pluginIdsToRemove)
|
foreach (uint pluginId in pluginIdsToRemove)
|
||||||
{
|
{
|
||||||
Settings.CurrentLayout.Plugins.Remove(pluginId);
|
layout.Plugins.Remove(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings.CurrentLayout.UpdatePlugins();
|
layout.UpdatePlugins();
|
||||||
|
|
||||||
if (minimize && !showWindow)
|
|
||||||
{
|
|
||||||
Application.Current.MainWindow.WindowState = WindowState.Minimized;
|
|
||||||
Application.Current.MainWindow.ShowInTaskbar = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onComplete?.Invoke();
|
|
||||||
App.Logger.LogInfo("Layout loaded", source: "Manager");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:local="clr-namespace:DesktopMagic.Pages"
|
xmlns:local="clr-namespace:DesktopMagic.Pages"
|
||||||
@@ -12,76 +13,119 @@
|
|||||||
ScrollViewer.CanContentScroll="False"
|
ScrollViewer.CanContentScroll="False"
|
||||||
Title="MainPage">
|
Title="MainPage">
|
||||||
|
|
||||||
<Grid Margin="{StaticResource DefaultMargin}">
|
<busyIndicator:BusyMask IsBusy="{Binding IsSharing}" IndicatorType="ThreeDots" BusyContent="{DynamicResource uploadingToModIo}" BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
|
||||||
<Grid.RowDefinitions>
|
<Grid Margin="{StaticResource DefaultMargin}">
|
||||||
<RowDefinition Height="auto" />
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="auto" />
|
||||||
<RowDefinition Height="auto" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
<RowDefinition Height="auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<ui:Card Padding="5" Margin="0 0 0 5">
|
<ui:Card Padding="5" Margin="0 0 0 5">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition />
|
<ColumnDefinition />
|
||||||
<ColumnDefinition Width="10" />
|
<ColumnDefinition Width="10" />
|
||||||
<ColumnDefinition />
|
<ColumnDefinition />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<ui:ToggleSwitch x:Name="editCheckBox" Height="30" VerticalAlignment="Center" Margin="5 0 0 0" Content="{DynamicResource editLayout}" Click="EditCheckBox_Click" />
|
<ui:ToggleSwitch x:Name="editCheckBox" Height="30" VerticalAlignment="Center" Margin="5 0 0 0" Content="{DynamicResource editLayout}" Click="EditCheckBox_Click" />
|
||||||
<ui:TextBox Grid.Column="2" Text="{Binding PluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
|
<ui:TextBox Grid.Column="2" Text="{Binding PluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:Card>
|
</ui:Card>
|
||||||
|
|
||||||
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel" Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Top">
|
<ScrollViewer Background="#FFBBBBBB" PreviewMouseWheel="ScrollViewer_PreviewMouseWheel" Grid.Row="1" VerticalAlignment="Stretch" VerticalContentAlignment="Top">
|
||||||
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding Settings.CurrentLayout.Plugins}">
|
<ItemsControl x:Name="pluginsItemsControl" ItemsSource="{Binding SelectedLayout.Plugins}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<ui:CardExpander Tag="{Binding}" Margin="0 0 0 5" Expanded="OptionsCardExpander_Expanded">
|
<ui:CardExpander Tag="{Binding}" Margin="0 0 0 5" Expanded="OptionsCardExpander_Expanded">
|
||||||
<ui:CardExpander.Header>
|
<ui:CardExpander.Header>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<ui:TextBlock Grid.Row="0" Grid.Column="0" FontTypography="Body" Text="{Binding Value.Metadata.Name}" />
|
<ui:TextBlock Grid.Row="0" Grid.Column="0" FontTypography="Body" Text="{Binding Value.Metadata.Name}" />
|
||||||
<Label Grid.Row="1" Grid.Column="0" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontSize="12" ContentStringFormat="{DynamicResource authorFormat}">
|
<Label Grid.Row="1" Grid.Column="0" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontSize="12" ContentStringFormat="{DynamicResource authorFormat}">
|
||||||
<Label.Style>
|
<Label.Style>
|
||||||
<Style TargetType="Label" BasedOn="{StaticResource {x:Type Label}}">
|
<Style TargetType="Label" BasedOn="{StaticResource {x:Type Label}}">
|
||||||
<Setter Property="Content" Value="{Binding Value.Metadata.Author, Converter={StaticResource UnderscoreEscapingConverter}}" />
|
<Setter Property="Content" Value="{Binding Value.Metadata.Author, Converter={StaticResource UnderscoreEscapingConverter}}" />
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding Value.Metadata.Author}" Value="{x:Null}">
|
<DataTrigger Binding="{Binding Value.Metadata.Author}" Value="{x:Null}">
|
||||||
<Setter Property="Content" Value="{DynamicResource unknown}" />
|
<Setter Property="Content" Value="{DynamicResource unknown}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Label.Style>
|
</Label.Style>
|
||||||
</Label>
|
</Label>
|
||||||
<ui:ToggleSwitch Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Click="PluginCheckBox_Click" IsChecked="{Binding Value.Enabled}" Tag="{Binding Key}" Margin="0,0,16,0" VerticalAlignment="Center" />
|
<ui:ToggleSwitch Grid.Row="0" Grid.RowSpan="2" Grid.Column="1" Click="PluginCheckBox_Click" IsChecked="{Binding Value.Enabled}" Tag="{Binding Key}" Margin="0,0,16,0" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:CardExpander.Header>
|
</ui:CardExpander.Header>
|
||||||
</ui:CardExpander>
|
</ui:CardExpander>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<ui:Card Grid.Row="2" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
|
<ui:Card Grid.Row="2" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
|
||||||
<Grid >
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<ColumnDefinition Width="1*" />
|
<RowDefinition Height="auto" />
|
||||||
<ColumnDefinition Width="5" />
|
<RowDefinition Height="5" />
|
||||||
<ColumnDefinition Width="0.5*" />
|
<RowDefinition Height="auto" />
|
||||||
<ColumnDefinition Width="5" />
|
</Grid.RowDefinitions>
|
||||||
<ColumnDefinition Width="0.5*" />
|
<Grid.ColumnDefinitions>
|
||||||
</Grid.ColumnDefinitions>
|
<ColumnDefinition Width="auto" />
|
||||||
|
<ColumnDefinition Width="5" />
|
||||||
|
<ColumnDefinition Width="1*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<ComboBox x:Name="layoutsComboBox" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding Settings.CurrentLayoutName}" SelectedValuePath="Name" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Padding="4" SelectionChanged="LayoutsComboBox_SelectionChanged" />
|
<ui:Button x:Name="screenSelectorButton" VerticalAlignment="Stretch" Padding="10,4" Click="ScreenSelectorButton_Click">
|
||||||
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Grid.Column="2" Icon="{ui:SymbolIcon Add24}" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular"/>
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular"/>
|
<TextBlock x:Name="screenSelectorButtonText" VerticalAlignment="Center" Text="{Binding SelectedScreen.DisplayName}" />
|
||||||
</Grid>
|
<ui:SymbolIcon Symbol="ChevronDown24" Margin="8,0,0,0" FontSize="12" />
|
||||||
</ui:Card>
|
</StackPanel>
|
||||||
</Grid>
|
</ui:Button>
|
||||||
|
|
||||||
|
<ComboBox x:Name="layoutsComboBox" Grid.Column="2" ItemsSource="{Binding Settings.Layouts}" DisplayMemberPath="Name" SelectedValue="{Binding SelectedLayoutName}" SelectedValuePath="Name" VerticalAlignment="Center" SelectionChanged="LayoutsComboBox_SelectionChanged" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" Grid.ColumnSpan="3">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="1*" />
|
||||||
|
<ColumnDefinition Width="5" />
|
||||||
|
<ColumnDefinition Width="0.5*" />
|
||||||
|
<ColumnDefinition Width="5" />
|
||||||
|
<ColumnDefinition Width="0.5*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<ui:Button x:Name="shareLayoutButton" Icon="{ui:SymbolIcon Share24}" Grid.Column="0" HorizontalAlignment="Stretch" Click="ShareLayoutButton_Click" FontWeight="Regular">
|
||||||
|
<TextBlock>
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource shareOnModIo}" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="Modified">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource updateOnModIo}" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="UpToDate">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource sharedOnModIo}" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding SelectedLayout.ShareState}" Value="NotOwner">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource shareCopyOnModIo}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</ui:Button>
|
||||||
|
<ui:Button x:Name="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
|
||||||
|
<ui:Button x:Name="removeLayoutButton" Content="{DynamicResource deleteLayout}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ui:Card>
|
||||||
|
</Grid>
|
||||||
|
</busyIndicator:BusyMask>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public partial class MainPage : Page
|
|||||||
{
|
{
|
||||||
private readonly Manager _manager = Manager.Instance;
|
private readonly Manager _manager = Manager.Instance;
|
||||||
private readonly MainWindowDataContext _dataContext;
|
private readonly MainWindowDataContext _dataContext;
|
||||||
private bool _isLoadingLayout = false;
|
|
||||||
|
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
@@ -36,6 +35,7 @@ public partial class MainPage : Page
|
|||||||
// Subscribe to manager events
|
// Subscribe to manager events
|
||||||
_manager.PluginsChanged += OnPluginsChanged;
|
_manager.PluginsChanged += OnPluginsChanged;
|
||||||
_manager.EditModeChanged += OnEditModeChanged;
|
_manager.EditModeChanged += OnEditModeChanged;
|
||||||
|
_manager.SettingsChanged += OnSettingsChanged;
|
||||||
|
|
||||||
Loaded += MainPage_Loaded;
|
Loaded += MainPage_Loaded;
|
||||||
Unloaded += MainPage_Unloaded;
|
Unloaded += MainPage_Unloaded;
|
||||||
@@ -45,6 +45,9 @@ public partial class MainPage : Page
|
|||||||
{
|
{
|
||||||
// Initialize edit checkbox state
|
// Initialize edit checkbox state
|
||||||
editCheckBox.IsChecked = _manager.IsEditMode;
|
editCheckBox.IsChecked = _manager.IsEditMode;
|
||||||
|
|
||||||
|
_dataContext.RefreshScreens();
|
||||||
|
RefreshShareState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainPage_Unloaded(object sender, RoutedEventArgs e)
|
private void MainPage_Unloaded(object sender, RoutedEventArgs e)
|
||||||
@@ -52,6 +55,7 @@ public partial class MainPage : Page
|
|||||||
// Unsubscribe from events
|
// Unsubscribe from events
|
||||||
_manager.PluginsChanged -= OnPluginsChanged;
|
_manager.PluginsChanged -= OnPluginsChanged;
|
||||||
_manager.EditModeChanged -= OnEditModeChanged;
|
_manager.EditModeChanged -= OnEditModeChanged;
|
||||||
|
_manager.SettingsChanged -= OnSettingsChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPluginsChanged()
|
private void OnPluginsChanged()
|
||||||
@@ -60,9 +64,20 @@ public partial class MainPage : Page
|
|||||||
{
|
{
|
||||||
_dataContext.Settings = _manager.Settings;
|
_dataContext.Settings = _manager.Settings;
|
||||||
ApplyPluginsFilter();
|
ApplyPluginsFilter();
|
||||||
|
RefreshShareState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged()
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(RefreshShareState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshShareState()
|
||||||
|
{
|
||||||
|
ModIoService.RefreshShareState(_manager.SelectedLayout);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnEditModeChanged(bool editMode)
|
private void OnEditModeChanged(bool editMode)
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() =>
|
Dispatcher.Invoke(() =>
|
||||||
@@ -76,6 +91,16 @@ public partial class MainPage : Page
|
|||||||
_manager.SetEditMode(editCheckBox.IsChecked == true);
|
_manager.SetEditMode(editCheckBox.IsChecked == true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ScreenSelectorButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ScreenSelectorDialog dialog = new(_dataContext)
|
||||||
|
{
|
||||||
|
Owner = Window.GetWindow(this)
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = dialog.ShowDialog();
|
||||||
|
}
|
||||||
|
|
||||||
private void PluginCheckBox_Click(object sender, RoutedEventArgs e)
|
private void PluginCheckBox_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not Control checkBox)
|
if (sender is not Control checkBox)
|
||||||
@@ -85,7 +110,7 @@ public partial class MainPage : Page
|
|||||||
|
|
||||||
uint pluginId = uint.Parse(checkBox.Tag.ToString()!);
|
uint pluginId = uint.Parse(checkBox.Tag.ToString()!);
|
||||||
|
|
||||||
_manager.LoadPlugin(pluginId, (internalPluginData) =>
|
_manager.LoadPlugin(pluginId, _manager.SelectedLayout, (internalPluginData) =>
|
||||||
{
|
{
|
||||||
Dispatcher.Invoke(() =>
|
Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
@@ -145,32 +170,61 @@ public partial class MainPage : Page
|
|||||||
|
|
||||||
private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
// Prevent recursive calls and only process if fully loaded
|
ApplySelectedLayout();
|
||||||
if (_isLoadingLayout || !_manager.IsLoaded || !IsLoaded)
|
RefreshShareState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ShareLayoutButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
shareLayoutButton.IsEnabled = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ModIoService.ShareAsync(_manager.SelectedLayout, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
shareLayoutButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the currently selected layout to the currently selected screen and reloads it.
|
||||||
|
/// No-op when the layout is already the one bound to the screen (e.g. programmatic resets).
|
||||||
|
/// </summary>
|
||||||
|
private void ApplySelectedLayout()
|
||||||
|
{
|
||||||
|
if (_dataContext.SelectedScreenId is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is actually a user-initiated change
|
System.Windows.Forms.Screen? screen = ScreenUtilities.GetScreenByDeviceName(_dataContext.SelectedScreenId);
|
||||||
// by verifying that the removed and added items are different
|
if (screen is null)
|
||||||
if (e.RemovedItems.Count > 0 && e.AddedItems.Count > 0)
|
|
||||||
{
|
{
|
||||||
if (e.RemovedItems[0] == e.AddedItems[0])
|
return;
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
string? layoutName = _dataContext.SelectedLayoutName;
|
||||||
|
if (layoutName is null)
|
||||||
{
|
{
|
||||||
_isLoadingLayout = true;
|
return;
|
||||||
_manager.SaveSettings();
|
|
||||||
_manager.LoadLayout(false);
|
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
|
Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == layoutName);
|
||||||
|
if (layout is null)
|
||||||
{
|
{
|
||||||
_isLoadingLayout = false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_manager.GetLayoutForScreen(screen) == layout)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.BindLayoutToScreen(screen, layout);
|
||||||
|
_manager.ReloadScreen(screen);
|
||||||
|
_dataContext.RefreshSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void NewLayoutButton_Click(object sender, RoutedEventArgs e)
|
private async void NewLayoutButton_Click(object sender, RoutedEventArgs e)
|
||||||
@@ -194,18 +248,11 @@ public partial class MainPage : Page
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
_manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim()));
|
||||||
{
|
_manager.SaveSettings();
|
||||||
_isLoadingLayout = true;
|
|
||||||
_manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim()));
|
// Select the new layout so the user can apply it to the current screen
|
||||||
_manager.Settings.CurrentLayoutName = inputDialog.ResponseText.Trim();
|
_dataContext.SelectedLayoutName = inputDialog.ResponseText.Trim();
|
||||||
_manager.SaveSettings();
|
|
||||||
_manager.LoadLayout(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isLoadingLayout = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,17 +284,29 @@ public partial class MainPage : Page
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == _dataContext.SelectedLayoutName);
|
||||||
|
if (layout is null)
|
||||||
{
|
{
|
||||||
_isLoadingLayout = true;
|
return;
|
||||||
_ = _manager.Settings.Layouts.Remove(_manager.Settings.CurrentLayout);
|
|
||||||
_manager.SaveSettings();
|
|
||||||
_manager.LoadLayout(false);
|
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
|
if (layout.Name == Manager.EmptyLayoutName)
|
||||||
{
|
{
|
||||||
_isLoadingLayout = false;
|
Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox
|
||||||
|
{
|
||||||
|
Title = App.AppName,
|
||||||
|
Content = (string)FindResource("cannotDeleteEmptyLayout"),
|
||||||
|
CloseButtonText = "Ok"
|
||||||
|
};
|
||||||
|
_ = await cannotDeleteMessageBox.ShowDialogAsync();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A subscribed layout would be downloaded again on the next sync.
|
||||||
|
await ModIoService.UnsubscribeAsync(layout);
|
||||||
|
|
||||||
|
_manager.RemoveLayout(layout);
|
||||||
|
_dataContext.RefreshSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||||
|
xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:local="clr-namespace:DesktopMagic.Pages"
|
xmlns:local="clr-namespace:DesktopMagic.Pages"
|
||||||
@@ -9,44 +10,66 @@
|
|||||||
d:DesignHeight="450" d:DesignWidth="800"
|
d:DesignHeight="450" d:DesignWidth="800"
|
||||||
Title="ThemePage">
|
Title="ThemePage">
|
||||||
|
|
||||||
<Grid Margin="{StaticResource DefaultMargin}">
|
<busyIndicator:BusyMask IsBusy="{Binding IsSharing}" IndicatorType="ThreeDots" BusyContent="{DynamicResource uploadingToModIo}" BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
|
||||||
<Grid.RowDefinitions>
|
<Grid Margin="{StaticResource DefaultMargin}">
|
||||||
<RowDefinition Height="*" />
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="auto" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
<RowDefinition Height="auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<ui:ListView x:Name="themesListBox" Grid.Row="0" Grid.ColumnSpan="1" ItemsSource="{Binding Settings.Themes}" SelectedValue="{Binding Settings.CurrentLayout.CurrentThemeName}" SelectedValuePath="Name" SelectedIndex="2" SelectionMode="Single">
|
<ui:ListView x:Name="themesListBox" Grid.Row="0" Grid.ColumnSpan="1" ItemsSource="{Binding Settings.Themes}" SelectedValue="{Binding SelectedLayout.CurrentThemeName}" SelectedValuePath="Name" SelectedIndex="2" SelectionMode="Single">
|
||||||
<ui:ListView.ItemTemplate>
|
<ui:ListView.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Margin="8" VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Name, Mode=OneWay}" />
|
<TextBlock Margin="8" VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Name, Mode=OneWay}" />
|
||||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal" Margin="{StaticResource DefaultMargin}">
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal" Margin="{StaticResource DefaultMargin}">
|
||||||
<ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
|
<ui:Button x:Name="shareThemeButton" Icon="{ui:SymbolIcon Share24}" Margin="0 0 5 0" Click="ShareThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
|
||||||
|
<TextBlock>
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource shareOnModIo}" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding ShareState}" Value="Modified">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource updateOnModIo}" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding ShareState}" Value="UpToDate">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource sharedOnModIo}" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding ShareState}" Value="NotOwner">
|
||||||
|
<Setter Property="Text" Value="{DynamicResource shareCopyOnModIo}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</ui:Button>
|
||||||
|
<ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
|
||||||
|
|
||||||
</ui:Button>
|
</ui:Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ui:ListView.ItemTemplate>
|
</ui:ListView.ItemTemplate>
|
||||||
</ui:ListView>
|
</ui:ListView>
|
||||||
|
|
||||||
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
|
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="0 5 0 0" Padding="5">
|
||||||
<Grid >
|
<Grid >
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="1*" />
|
<ColumnDefinition Width="1*" />
|
||||||
<ColumnDefinition Width="5" />
|
<ColumnDefinition Width="5" />
|
||||||
<ColumnDefinition Width="0.5*" />
|
<ColumnDefinition Width="0.5*" />
|
||||||
<ColumnDefinition Width="5" />
|
<ColumnDefinition Width="5" />
|
||||||
<ColumnDefinition Width="0.5*" />
|
<ColumnDefinition Width="0.5*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<ui:Button x:Name="newThemeButton" Content="{DynamicResource newTheme}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="AddThemeButton_Click" FontWeight="Regular"/>
|
<ui:Button x:Name="newThemeButton" Content="{DynamicResource newTheme}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="AddThemeButton_Click" FontWeight="Regular"/>
|
||||||
<ui:Button x:Name="deleteThemeButton" Content="{DynamicResource deleteTheme}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular"/>
|
<ui:Button x:Name="deleteThemeButton" Content="{DynamicResource deleteTheme}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:Card>
|
</ui:Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</busyIndicator:BusyMask>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using DesktopMagic.DataContexts;
|
using DesktopMagic.DataContexts;
|
||||||
using DesktopMagic.Dialogs;
|
using DesktopMagic.Dialogs;
|
||||||
|
using DesktopMagic.Helpers;
|
||||||
using DesktopMagic.Plugins;
|
using DesktopMagic.Plugins;
|
||||||
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -30,9 +31,15 @@ public partial class ThemePage : Page
|
|||||||
// Subscribe to manager events
|
// Subscribe to manager events
|
||||||
_manager.SettingsChanged += OnSettingsChanged;
|
_manager.SettingsChanged += OnSettingsChanged;
|
||||||
|
|
||||||
|
Loaded += ThemePage_Loaded;
|
||||||
Unloaded += ThemePage_Unloaded;
|
Unloaded += ThemePage_Unloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ThemePage_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
RefreshShareStates();
|
||||||
|
}
|
||||||
|
|
||||||
private void ThemePage_Unloaded(object sender, RoutedEventArgs e)
|
private void ThemePage_Unloaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
// Unsubscribe from events
|
// Unsubscribe from events
|
||||||
@@ -44,9 +51,37 @@ public partial class ThemePage : Page
|
|||||||
Dispatcher.Invoke(() =>
|
Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
_dataContext.Settings = _manager.Settings;
|
_dataContext.Settings = _manager.Settings;
|
||||||
|
RefreshShareStates();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RefreshShareStates()
|
||||||
|
{
|
||||||
|
foreach (Theme theme in _manager.Settings.Themes)
|
||||||
|
{
|
||||||
|
ModIoService.RefreshShareState(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ShareThemeButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not System.Windows.Controls.Button button || button.Tag is not Theme theme)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.IsEnabled = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ModIoService.ShareAsync(theme, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
button.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async void AddThemeButton_Click(object sender, RoutedEventArgs e)
|
private async void AddThemeButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
InputDialog inputDialog = new((string)FindResource("enterThemeName"))
|
InputDialog inputDialog = new((string)FindResource("enterThemeName"))
|
||||||
@@ -69,7 +104,7 @@ public partial class ThemePage : Page
|
|||||||
}
|
}
|
||||||
|
|
||||||
_manager.Settings.Themes.Add(new Theme(inputDialog.ResponseText.Trim()));
|
_manager.Settings.Themes.Add(new Theme(inputDialog.ResponseText.Trim()));
|
||||||
_manager.Settings.CurrentLayout.CurrentThemeName = inputDialog.ResponseText.Trim();
|
_manager.SelectedLayout.CurrentThemeName = inputDialog.ResponseText.Trim();
|
||||||
_manager.SaveSettings();
|
_manager.SaveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +139,9 @@ public partial class ThemePage : Page
|
|||||||
|
|
||||||
if (themesListBox.SelectedItem is Theme theme)
|
if (themesListBox.SelectedItem is Theme theme)
|
||||||
{
|
{
|
||||||
|
// A subscribed theme would be downloaded again on the next sync.
|
||||||
|
await ModIoService.UnsubscribeAsync(theme);
|
||||||
|
|
||||||
_ = _manager.Settings.Themes.Remove(theme);
|
_ = _manager.Settings.Themes.Remove(theme);
|
||||||
_manager.SaveSettings();
|
_manager.SaveSettings();
|
||||||
}
|
}
|
||||||
@@ -122,5 +160,6 @@ public partial class ThemePage : Page
|
|||||||
};
|
};
|
||||||
|
|
||||||
_ = themeDialog.ShowDialog();
|
_ = themeDialog.ShowDialog();
|
||||||
|
ModIoService.RefreshShareState(theme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IPluginWindow
|
|||||||
PluginMetadata PluginMetadata { get; }
|
PluginMetadata PluginMetadata { get; }
|
||||||
string PluginFolderPath { get; }
|
string PluginFolderPath { get; }
|
||||||
string Title { get; set; }
|
string Title { get; set; }
|
||||||
|
string ScreenDeviceName { get; }
|
||||||
|
|
||||||
void Exit();
|
void Exit();
|
||||||
void SetEditMode(bool enabled);
|
void SetEditMode(bool enabled);
|
||||||
@@ -20,5 +21,13 @@ public interface IPluginWindow
|
|||||||
void Hide();
|
void Hide();
|
||||||
void Close();
|
void Close();
|
||||||
|
|
||||||
|
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;
|
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;
|
||||||
|
|||||||
@@ -31,19 +31,26 @@
|
|||||||
<ui:Card Grid.ColumnSpan="3" Padding="5" Margin="0 0 0 5">
|
<ui:Card Grid.ColumnSpan="3" Padding="5" Margin="0 0 0 5">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="auto" />
|
||||||
|
<ColumnDefinition Width="10" />
|
||||||
<ColumnDefinition />
|
<ColumnDefinition />
|
||||||
<ColumnDefinition Width="10" />
|
<ColumnDefinition Width="10" />
|
||||||
<ColumnDefinition />
|
<ColumnDefinition />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<ui:TextBox Grid.Column="0" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchAllPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
|
<ComboBox x:Name="categoryComboBox" Grid.Column="0" MinWidth="130" VerticalAlignment="Center" SelectedIndex="{Binding CategoryIndex, Mode=TwoWay}" SelectionChanged="CategoryComboBox_SelectionChanged">
|
||||||
<ui:TextBox Grid.Column="2" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
|
<ComboBoxItem Content="{DynamicResource plugins}" />
|
||||||
|
<ComboBoxItem Content="{DynamicResource layouts}" />
|
||||||
|
<ComboBoxItem Content="{DynamicResource themes}" />
|
||||||
|
</ComboBox>
|
||||||
|
<ui:TextBox Grid.Column="2" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchModIo}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
|
||||||
|
<ui:TextBox Grid.Column="4" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalled}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</ui:Card>
|
</ui:Card>
|
||||||
|
|
||||||
<busyIndicator:BusyMask Grid.Row="2" IsBusy="{Binding IsSearching}" IndicatorType="ThreeDots" BusyContent="Searching..." BusyContentMargin="0,50,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
|
<busyIndicator:BusyMask Grid.Row="2" IsBusy="{Binding IsSearching}" IndicatorType="ThreeDots" BusyContent="Searching..." BusyContentMargin="0,50,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top" Margin="0 0 2.5 0">
|
<ScrollViewer VerticalScrollBarVisibility="Auto" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top" Margin="0 0 2.5 0">
|
||||||
<ItemsControl ItemsSource="{Binding AllPlugins}">
|
<ItemsControl ItemsSource="{Binding ShownAll}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<local:ModEntry Margin="0 0 0 5" />
|
<local:ModEntry Margin="0 0 0 5" />
|
||||||
@@ -56,7 +63,7 @@
|
|||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate>
|
<ControlTemplate>
|
||||||
<TextBlock Text="No plugins found!" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
|
<TextBlock Text="{DynamicResource nothingFound}" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
@@ -69,7 +76,7 @@
|
|||||||
</busyIndicator:BusyMask>
|
</busyIndicator:BusyMask>
|
||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Grid.Row="2" Grid.Column="2" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top">
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Grid.Row="2" Grid.Column="2" VerticalAlignment="Stretch" Padding="0" VerticalContentAlignment="Top">
|
||||||
<ItemsControl ItemsSource="{Binding InstalledPlugins}" Margin="2.5 0 0 0">
|
<ItemsControl ItemsSource="{Binding ShownInstalled}" Margin="2.5 0 0 0">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<local:ModEntry Margin="0 0 0 5"/>
|
<local:ModEntry Margin="0 0 0 5"/>
|
||||||
@@ -82,7 +89,7 @@
|
|||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate>
|
<ControlTemplate>
|
||||||
<TextBlock Text="No plugins found!" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
|
<TextBlock Text="{DynamicResource nothingFound}" FontSize="20" VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using DesktopMagic.DataContexts;
|
using DesktopMagic.DataContexts;
|
||||||
using DesktopMagic.Dialogs;
|
using DesktopMagic.Dialogs;
|
||||||
using DesktopMagic.Helpers;
|
using DesktopMagic.Helpers;
|
||||||
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
using Modio;
|
using Modio;
|
||||||
using Modio.Filters;
|
using Modio.Filters;
|
||||||
@@ -32,40 +33,31 @@ namespace DesktopMagic.Plugins;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class PluginManager : Page
|
public partial class PluginManager : Page
|
||||||
{
|
{
|
||||||
private const int ModIoGameId = 5665;
|
private const uint ModIoGameId = ModIoService.GameId;
|
||||||
private const string ModIoApiKey = "88e6ea774c3a502b06114e7fee0829ac";
|
|
||||||
private readonly HttpClient httpClient = new();
|
private readonly HttpClient httpClient = new();
|
||||||
private readonly PluginManagerDataContext pluginManagerDataContext = new();
|
private readonly PluginManagerDataContext pluginManagerDataContext = new();
|
||||||
private readonly string pluginsPath = Path.Combine(App.ApplicationDataPath, "Plugins");
|
private readonly string pluginsPath = Path.Combine(App.ApplicationDataPath, "Plugins");
|
||||||
private readonly string pluginDevelopmentPath = Path.Combine(App.ApplicationDataPath, "PluginDevelopment");
|
private readonly string pluginDevelopmentPath = Path.Combine(App.ApplicationDataPath, "PluginDevelopment");
|
||||||
private readonly Manager _manager = Manager.Instance;
|
private readonly Manager _manager = Manager.Instance;
|
||||||
|
|
||||||
private bool changed = false;
|
|
||||||
|
|
||||||
private readonly DispatcherTimer searchTimer = new()
|
private readonly DispatcherTimer searchTimer = new()
|
||||||
{
|
{
|
||||||
Interval = TimeSpan.FromMilliseconds(300),
|
Interval = TimeSpan.FromMilliseconds(300),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private bool changed = false;
|
||||||
private Client modIoClient;
|
private Client modIoClient;
|
||||||
|
|
||||||
|
// mod.io data of the local layouts and themes linked to a mod, by mod ID.
|
||||||
|
private readonly Dictionary<uint, Mod> linkedMods = [];
|
||||||
|
|
||||||
public PluginManager()
|
public PluginManager()
|
||||||
{
|
{
|
||||||
Resources.MergedDictionaries.Add(App.LanguageDictionary);
|
Resources.MergedDictionaries.Add(App.LanguageDictionary);
|
||||||
|
|
||||||
string? modIoAccessToken = MainWindowDataContext.GetSettings().ModIoAccessToken;
|
modIoClient = ModIoService.CreateClient();
|
||||||
|
pluginManagerDataContext.IsAuthenticated = ModIoService.IsAuthenticated;
|
||||||
if (modIoAccessToken is null)
|
App.Logger.LogInfo($"Initialized mod.io client {(ModIoService.IsAuthenticated ? "with" : "without")} authentication", source: "PluginManager");
|
||||||
{
|
|
||||||
modIoClient = new Client(new Credentials(ModIoApiKey));
|
|
||||||
App.Logger.LogInfo("Initialized mod.io client without authentication", source: "PluginManager");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
modIoClient = new Client(new Credentials(ModIoApiKey, modIoAccessToken));
|
|
||||||
pluginManagerDataContext.IsAuthenticated = true;
|
|
||||||
App.Logger.LogInfo("Initialized mod.io client with authentication", source: "PluginManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
@@ -73,13 +65,85 @@ public partial class PluginManager : Page
|
|||||||
searchTimer.Tick += async (sender, e) =>
|
searchTimer.Tick += async (sender, e) =>
|
||||||
{
|
{
|
||||||
searchTimer.Stop();
|
searchTimer.Stop();
|
||||||
await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText);
|
|
||||||
|
if (pluginManagerDataContext.Category == ModCategory.Plugins)
|
||||||
|
{
|
||||||
|
await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SearchShared(pluginManagerDataContext.AllPluginsSearchText);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Loaded += PluginManager_Loaded;
|
Loaded += PluginManager_Loaded;
|
||||||
Unloaded += PluginManager_Unloaded;
|
Unloaded += PluginManager_Unloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task Remove(string pluginPath, uint id)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"Removing plugin with ID {id} from path: {pluginPath}", source: "PluginManager");
|
||||||
|
pluginManagerDataContext.IsLoading = true;
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
PluginEntryDataContext? pluginEntryDataContext = pluginManagerDataContext.InstalledPlugins.FirstOrDefault(p => p.Id == id);
|
||||||
|
|
||||||
|
if (Directory.Exists(pluginPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(pluginPath, true);
|
||||||
|
App.Logger.LogInfo($"Successfully deleted plugin directory: {pluginPath}", source: "PluginManager");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
|
||||||
|
{
|
||||||
|
Title = "Plugin Manager",
|
||||||
|
Content = ex.Message,
|
||||||
|
CloseButtonText = "Ok"
|
||||||
|
};
|
||||||
|
_ = await messageBox.ShowDialogAsync();
|
||||||
|
App.Logger.LogError(ex.Message, source: "PluginManager");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginEntryDataContext is not null)
|
||||||
|
{
|
||||||
|
_ = pluginManagerDataContext.InstalledPlugins.Remove(pluginEntryDataContext);
|
||||||
|
App.Logger.LogInfo($"Removed plugin {id} from installed plugins list", source: "PluginManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginManagerDataContext.IsAuthenticated)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await modIoClient.Games[ModIoGameId].Mods.Unsubscribe(id);
|
||||||
|
App.Logger.LogInfo($"Unsubscribed from plugin {id} on mod.io", source: "PluginManager");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to unsubscribe from plugin {id}: {ex.Message}", source: "PluginManager");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManagerDataContext.IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[^a-zA-Z0-9]")]
|
||||||
|
private static partial Regex IdentifierNameRegex();
|
||||||
|
|
||||||
|
private static string GetPluginSafeName(string pluginName)
|
||||||
|
{
|
||||||
|
string pluginSafeName = pluginName.ToLower().Replace("_", " ");
|
||||||
|
|
||||||
|
TextInfo info = CultureInfo.CurrentCulture.TextInfo;
|
||||||
|
pluginSafeName = info.ToTitleCase(pluginSafeName);
|
||||||
|
pluginSafeName = IdentifierNameRegex().Replace(pluginSafeName, "");
|
||||||
|
|
||||||
|
return pluginSafeName;
|
||||||
|
}
|
||||||
|
|
||||||
private async void PluginManager_Loaded(object sender, RoutedEventArgs e)
|
private async void PluginManager_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
await InitializePluginManager();
|
await InitializePluginManager();
|
||||||
@@ -159,7 +223,7 @@ public partial class PluginManager : Page
|
|||||||
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
|
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
|
||||||
await foreach (Mod mod in mods)
|
await foreach (Mod mod in mods)
|
||||||
{
|
{
|
||||||
if (pluginIds.Contains(mod.Id))
|
if (pluginIds.Contains(mod.Id) || !ModIoService.IsPluginMod(mod))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -174,64 +238,13 @@ public partial class PluginManager : Page
|
|||||||
}
|
}
|
||||||
|
|
||||||
await SyncPlugins();
|
await SyncPlugins();
|
||||||
|
await UpdateLinkedItems();
|
||||||
|
await PopulateShared();
|
||||||
|
|
||||||
pluginManagerDataContext.IsLoading = false;
|
pluginManagerDataContext.IsLoading = false;
|
||||||
App.Logger.LogInfo("Plugin Manager initialization complete", source: "PluginManager");
|
App.Logger.LogInfo("Plugin Manager initialization complete", source: "PluginManager");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Remove(string pluginPath, uint id)
|
|
||||||
{
|
|
||||||
App.Logger.LogInfo($"Removing plugin with ID {id} from path: {pluginPath}", source: "PluginManager");
|
|
||||||
pluginManagerDataContext.IsLoading = true;
|
|
||||||
changed = true;
|
|
||||||
|
|
||||||
PluginEntryDataContext? pluginEntryDataContext = pluginManagerDataContext.InstalledPlugins.FirstOrDefault(p => p.Id == id);
|
|
||||||
|
|
||||||
if (Directory.Exists(pluginPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.Delete(pluginPath, true);
|
|
||||||
App.Logger.LogInfo($"Successfully deleted plugin directory: {pluginPath}", source: "PluginManager");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
|
|
||||||
{
|
|
||||||
Title = "Plugin Manager",
|
|
||||||
Content = ex.Message,
|
|
||||||
CloseButtonText = "Ok"
|
|
||||||
};
|
|
||||||
_ = await messageBox.ShowDialogAsync();
|
|
||||||
App.Logger.LogError(ex.Message, source: "PluginManager");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pluginEntryDataContext is not null)
|
|
||||||
{
|
|
||||||
_ = pluginManagerDataContext.InstalledPlugins.Remove(pluginEntryDataContext);
|
|
||||||
App.Logger.LogInfo($"Removed plugin {id} from installed plugins list", source: "PluginManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pluginManagerDataContext.IsAuthenticated)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await modIoClient.Games[ModIoGameId].Mods.Unsubscribe(id);
|
|
||||||
App.Logger.LogInfo($"Unsubscribed from plugin {id} on mod.io", source: "PluginManager");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
App.Logger.LogError($"Failed to unsubscribe from plugin {id}: {ex.Message}", source: "PluginManager");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginManagerDataContext.IsLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"[^a-zA-Z0-9]")]
|
|
||||||
private static partial Regex IdentifierNameRegex();
|
|
||||||
|
|
||||||
private async Task Install(Mod mod)
|
private async Task Install(Mod mod)
|
||||||
{
|
{
|
||||||
App.Logger.LogInfo($"Installing plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager");
|
App.Logger.LogInfo($"Installing plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager");
|
||||||
@@ -343,20 +356,177 @@ public partial class PluginManager : Page
|
|||||||
|
|
||||||
private void InstalledPluginsSearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
private void InstalledPluginsSearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(pluginManagerDataContext.InstalledPluginsSearchText))
|
FilterInstalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FilterInstalled()
|
||||||
|
{
|
||||||
|
string searchText = pluginManagerDataContext.InstalledPluginsSearchText;
|
||||||
|
|
||||||
|
foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.ShownInstalled)
|
||||||
{
|
{
|
||||||
foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins)
|
pluginEntryDataContext.IsVisible = string.IsNullOrWhiteSpace(searchText) || pluginEntryDataContext.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void CategoryComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!IsLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManagerDataContext.CategoryIndex = categoryComboBox.SelectedIndex;
|
||||||
|
pluginManagerDataContext.IsLoading = true;
|
||||||
|
await PopulateShared();
|
||||||
|
FilterInstalled();
|
||||||
|
pluginManagerDataContext.IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the mods of all linked layouts and themes and applies newer versions.
|
||||||
|
/// Subscribed items are also updated by the subscription sync, this covers items installed while logged out.
|
||||||
|
/// </summary>
|
||||||
|
private async Task UpdateLinkedItems()
|
||||||
|
{
|
||||||
|
linkedMods.Clear();
|
||||||
|
|
||||||
|
foreach (IModIoShareable item in ModIoService.GetSharedItems().Where(item => item.ModIo is not null).ToList())
|
||||||
|
{
|
||||||
|
uint modId = item.ModIo!.ModId;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
pluginEntryDataContext.IsVisible = true;
|
Mod mod = await modIoClient.Games[ModIoGameId].Mods[modId].Get();
|
||||||
|
linkedMods[modId] = mod;
|
||||||
|
|
||||||
|
if (await ModIoService.UpdateAsync(item, mod, EnsurePluginInstalled))
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to check \"{item.Name}\" (mod {modId}) for updates: {ex.Message}", source: "PluginManager");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills the lists of the selected layout or theme category.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PopulateShared()
|
||||||
|
{
|
||||||
|
pluginManagerDataContext.InstalledShared.Clear();
|
||||||
|
pluginManagerDataContext.AllShared.Clear();
|
||||||
|
|
||||||
|
if (pluginManagerDataContext.Category == ModCategory.Plugins)
|
||||||
{
|
{
|
||||||
foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins)
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<IModIoShareable> items = pluginManagerDataContext.Category == ModCategory.Layouts
|
||||||
|
? _manager.Settings.Layouts
|
||||||
|
: _manager.Settings.Themes;
|
||||||
|
|
||||||
|
foreach (IModIoShareable item in items.Where(item => item.ModIo is not null).ToList())
|
||||||
|
{
|
||||||
|
uint modId = item.ModIo!.ModId;
|
||||||
|
|
||||||
|
if (!linkedMods.TryGetValue(modId, out Mod? mod))
|
||||||
{
|
{
|
||||||
pluginEntryDataContext.IsVisible = pluginEntryDataContext.Name.Contains(pluginManagerDataContext.InstalledPluginsSearchText, StringComparison.OrdinalIgnoreCase);
|
try
|
||||||
|
{
|
||||||
|
mod = await modIoClient.Games[ModIoGameId].Mods[modId].Get();
|
||||||
|
linkedMods[modId] = mod;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to fetch mod {modId} of \"{item.Name}\": {ex.Message}", source: "PluginManager");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the local name, it can differ from the mod name (e.g. "Default (2)").
|
||||||
|
PluginMetadata metadata = mod is null ? new PluginMetadata(item.Name, modId) : new PluginMetadata(mod) { Name = item.Name };
|
||||||
|
pluginManagerDataContext.InstalledShared.Add(new PluginEntryDataContext(metadata, new CommandHandler(async () => await RemoveShared(item)), PluginEntryDataContext.Mode.Uninstall));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SearchShared(pluginManagerDataContext.AllPluginsSearchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchShared(string searchString)
|
||||||
|
{
|
||||||
|
pluginManagerDataContext.AllShared.Clear();
|
||||||
|
|
||||||
|
if (pluginManagerDataContext.Category == ModCategory.Plugins)
|
||||||
|
{
|
||||||
|
pluginManagerDataContext.IsSearching = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string tag = pluginManagerDataContext.Category == ModCategory.Layouts ? SharePackage.LayoutTag : SharePackage.ThemeTag;
|
||||||
|
Filter filter = ModFilter.Tags.Eq(tag);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchString))
|
||||||
|
{
|
||||||
|
if (!searchString.Contains('*'))
|
||||||
|
{
|
||||||
|
searchString = $"*{searchString.Trim()}*";
|
||||||
|
}
|
||||||
|
|
||||||
|
filter = filter.And(ModFilter.Name.Like(searchString));
|
||||||
|
}
|
||||||
|
|
||||||
|
filter = filter.And(ModFilter.Popular.Desc()).Limit(100);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"Fetching shared {tag} mods from mod.io", source: "PluginManager");
|
||||||
|
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
|
||||||
|
await foreach (Mod mod in mods)
|
||||||
|
{
|
||||||
|
if (ModIoService.FindLinkedItem(mod.Id) is not null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManagerDataContext.AllShared.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await InstallShared(mod)), PluginEntryDataContext.Mode.Install));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"Failed to fetch shared {tag} mods from mod.io: {ex.Message}", source: "PluginManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManagerDataContext.IsSearching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InstallShared(Mod mod)
|
||||||
|
{
|
||||||
|
pluginManagerDataContext.IsLoading = true;
|
||||||
|
|
||||||
|
if (await ModIoService.InstallAsync(mod, EnsurePluginInstalled))
|
||||||
|
{
|
||||||
|
linkedMods[mod.Id] = mod;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PopulateShared();
|
||||||
|
FilterInstalled();
|
||||||
|
pluginManagerDataContext.IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveShared(IModIoShareable item)
|
||||||
|
{
|
||||||
|
if (!await ModIoService.RemoveAsync(item))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManagerDataContext.IsLoading = true;
|
||||||
|
await PopulateShared();
|
||||||
|
FilterInstalled();
|
||||||
|
pluginManagerDataContext.IsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SearchAllPlugins(string searchString)
|
private async Task SearchAllPlugins(string searchString)
|
||||||
@@ -376,7 +546,7 @@ public partial class PluginManager : Page
|
|||||||
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
|
IAsyncEnumerable<Mod> mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable();
|
||||||
await foreach (Mod mod in mods)
|
await foreach (Mod mod in mods)
|
||||||
{
|
{
|
||||||
if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id))
|
if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id) || !ModIoService.IsPluginMod(mod))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -664,52 +834,21 @@ public class {pluginSafeName}Plugin : Plugin
|
|||||||
{
|
{
|
||||||
if (pluginManagerDataContext.IsAuthenticated)
|
if (pluginManagerDataContext.IsAuthenticated)
|
||||||
{
|
{
|
||||||
App.Logger.LogInfo("Logging out from mod.io", source: "PluginManager");
|
ModIoService.LogOut();
|
||||||
MainWindowDataContext.GetSettings().ModIoAccessToken = null;
|
modIoClient = ModIoService.CreateClient();
|
||||||
pluginManagerDataContext.IsAuthenticated = false;
|
pluginManagerDataContext.IsAuthenticated = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
App.Logger.LogInfo("Starting mod.io authentication", source: "PluginManager");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
InputDialog inputDialog = new((string)FindResource("enterModIoEmail"), "Plugin Manager")
|
if (!await ModIoService.LogInAsync(Window.GetWindow(this), "Plugin Manager"))
|
||||||
{
|
{
|
||||||
Owner = Window.GetWindow(this),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (inputDialog.ShowDialog() != true)
|
|
||||||
{
|
|
||||||
App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
App.Logger.LogInfo($"Requesting authentication code for email: {inputDialog.ResponseText}", source: "PluginManager");
|
|
||||||
await modIoClient.Auth.RequestCode(ModIoApiKey, inputDialog.ResponseText);
|
|
||||||
|
|
||||||
inputDialog = new((string)FindResource("enterModIoAccessToken"), "Plugin Manager")
|
|
||||||
{
|
|
||||||
Owner = Window.GetWindow(this),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (inputDialog.ShowDialog() != true)
|
|
||||||
{
|
|
||||||
App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginManagerDataContext.IsLoading = true;
|
pluginManagerDataContext.IsLoading = true;
|
||||||
|
modIoClient = ModIoService.CreateClient();
|
||||||
AccessToken accessToken = await modIoClient.Auth.SecurityCode(ModIoApiKey, inputDialog.ResponseText);
|
|
||||||
|
|
||||||
if (accessToken.Value is not null)
|
|
||||||
{
|
|
||||||
modIoClient = new Client(new Credentials(ModIoApiKey, accessToken.Value));
|
|
||||||
App.Logger.LogInfo("Successfully authenticated with mod.io", source: "PluginManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
MainWindowDataContext.GetSettings().ModIoAccessToken = accessToken.Value;
|
|
||||||
pluginManagerDataContext.IsAuthenticated = true;
|
pluginManagerDataContext.IsAuthenticated = true;
|
||||||
|
|
||||||
App.Logger.LogInfo("Subscribing to installed plugins on mod.io", source: "PluginManager");
|
App.Logger.LogInfo("Subscribing to installed plugins on mod.io", source: "PluginManager");
|
||||||
@@ -729,7 +868,9 @@ public class {pluginSafeName}Plugin : Plugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ModIoService.SubscribeToLinkedItemsAsync();
|
||||||
await SyncPlugins();
|
await SyncPlugins();
|
||||||
|
await PopulateShared();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -757,7 +898,8 @@ public class {pluginSafeName}Plugin : Plugin
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
IReadOnlyList<Mod> mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
|
IReadOnlyList<Mod> subscriptions = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList();
|
||||||
|
List<Mod> mods = subscriptions.Where(ModIoService.IsPluginMod).ToList();
|
||||||
App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager");
|
App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager");
|
||||||
|
|
||||||
List<PluginEntryDataContext> pluginsToRemove = [];
|
List<PluginEntryDataContext> pluginsToRemove = [];
|
||||||
@@ -790,6 +932,12 @@ public class {pluginSafeName}Plugin : Plugin
|
|||||||
}
|
}
|
||||||
|
|
||||||
App.Logger.LogInfo("Plugin sync completed", source: "PluginManager");
|
App.Logger.LogInfo("Plugin sync completed", source: "PluginManager");
|
||||||
|
|
||||||
|
// Shared layouts and themes are synced after the plugins, so the plugins they use are installed first.
|
||||||
|
if (await ModIoService.SyncSubscriptionsAsync(subscriptions, EnsurePluginInstalled))
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -797,15 +945,17 @@ public class {pluginSafeName}Plugin : Plugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetPluginSafeName(string pluginName)
|
private async Task EnsurePluginInstalled(uint pluginId)
|
||||||
{
|
{
|
||||||
string pluginSafeName = pluginName.ToLower().Replace("_", " ");
|
if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == pluginId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
TextInfo info = CultureInfo.CurrentCulture.TextInfo;
|
App.Logger.LogInfo($"Installing plugin {pluginId} required by a shared layout", source: "PluginManager");
|
||||||
pluginSafeName = info.ToTitleCase(pluginSafeName);
|
Mod mod = await modIoClient.Games[ModIoGameId].Mods[pluginId].Get();
|
||||||
pluginSafeName = IdentifierNameRegex().Replace(pluginSafeName, "");
|
await Install(mod);
|
||||||
|
pluginManagerDataContext.IsLoading = true;
|
||||||
return pluginSafeName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetCsprojPath(string pluginName)
|
private string GetCsprojPath(string pluginName)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<RectangleGeometry x:Name="rectangleGeometry" RadiusX="{Binding ElementName=border, Path=CornerRadius.TopLeft}" RadiusY="{Binding ElementName=border, Path=CornerRadius.TopLeft}" />
|
<RectangleGeometry x:Name="rectangleGeometry" RadiusX="{Binding ElementName=border, Path=CornerRadius.TopLeft}" RadiusY="{Binding ElementName=border, Path=CornerRadius.TopLeft}" />
|
||||||
</Viewbox.Clip>
|
</Viewbox.Clip>
|
||||||
<Border x:Name="imageBorder" BorderBrush="#BAFFEF">
|
<Border x:Name="imageBorder" BorderBrush="#BAFFEF">
|
||||||
<Image x:Name="image" RenderOptions.EdgeMode="Aliased" Margin="0,0,0,0" VerticalAlignment="Top" RenderTransformOrigin="0.5,0.5" Stretch="Uniform" MouseDown="Window_MouseDown" MouseMove="Window_MouseMove" MouseWheel="Window_MouseWheel" />
|
<Image x:Name="image" RenderOptions.EdgeMode="Aliased" Margin="0,0,0,0" VerticalAlignment="Top" RenderTransformOrigin="0.5,0.5" Stretch="Uniform" Focusable="True" MouseDown="Image_MouseDown" MouseMove="Image_MouseMove" MouseWheel="Image_MouseWheel" KeyDown="Image_KeyDown" KeyUp="Image_KeyUp" />
|
||||||
</Border>
|
</Border>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ using DesktopMagic.Helpers;
|
|||||||
using DesktopMagic.Plugins;
|
using DesktopMagic.Plugins;
|
||||||
using DesktopMagic.Settings;
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Imaging;
|
using System.Drawing.Imaging;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -35,8 +40,15 @@ 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 string screenDeviceName;
|
||||||
|
private bool isUpdatingPosition = false;
|
||||||
|
private bool _editMode = false;
|
||||||
|
private volatile bool renderPaused = false;
|
||||||
|
|
||||||
private CancellationTokenSource? pluginCancellationTokenSource;
|
private CancellationTokenSource? pluginCancellationTokenSource;
|
||||||
private FileSystemWatcher? pluginFileWatcher;
|
private FileSystemWatcher? pluginFileWatcher;
|
||||||
private System.Timers.Timer? reloadDebounceTimer;
|
private System.Timers.Timer? reloadDebounceTimer;
|
||||||
@@ -45,11 +57,37 @@ 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.
|
||||||
|
private readonly PropertyChangedEventHandler settingsPropertyChangedHandler;
|
||||||
|
private readonly PropertyChangedEventHandler themePropertyChangedHandler;
|
||||||
|
private Theme? subscribedTheme;
|
||||||
|
private NotifyCollectionChangedEventHandler? themesCollectionChangedHandler;
|
||||||
|
private readonly List<(Setting Setting, Action Handler)> subscribedSettings = [];
|
||||||
|
private readonly List<(Setting Setting, Action Handler)> defaultSettingsSubscriptions = [];
|
||||||
|
private readonly List<(Button Button, Action Handler)> subscribedButtonClicks = [];
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, Setting> localSettings = [];
|
||||||
|
private SettingSynchronizer? synchronizer;
|
||||||
|
private bool suppressButtonSync = false;
|
||||||
|
|
||||||
public bool IsRunning { get; private set; } = true;
|
public bool IsRunning { get; private set; } = true;
|
||||||
public PluginMetadata PluginMetadata { get; private set; }
|
public PluginMetadata PluginMetadata { get; private set; }
|
||||||
public string PluginFolderPath { get; private set; }
|
public string PluginFolderPath { get; private set; }
|
||||||
|
public string ScreenDeviceName => screenDeviceName;
|
||||||
|
|
||||||
public PluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath)
|
public PluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, Rectangle screenBounds, string screenDeviceName)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
@@ -69,33 +107,47 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
Owner = w;
|
Owner = w;
|
||||||
|
|
||||||
settings.PropertyChanged += (e, s) =>
|
settingsPropertyChangedHandler = (_, s) =>
|
||||||
{
|
{
|
||||||
if (s.PropertyName == nameof(PluginSettings.CurrentThemeName))
|
if (s.PropertyName == nameof(PluginSettings.CurrentThemeName))
|
||||||
{
|
{
|
||||||
settings.Theme.PropertyChanged += (se, ev) =>
|
SubscribeToTheme(settings.Theme);
|
||||||
{
|
|
||||||
ThemeChanged();
|
|
||||||
};
|
|
||||||
ThemeChanged();
|
ThemeChanged();
|
||||||
}
|
}
|
||||||
|
else if (s.PropertyName == nameof(PluginSettings.Position))
|
||||||
|
{
|
||||||
|
UpdatePosition();
|
||||||
|
}
|
||||||
|
else if (s.PropertyName == nameof(PluginSettings.Size))
|
||||||
|
{
|
||||||
|
UpdateSize();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
settings.PropertyChanged += settingsPropertyChangedHandler;
|
||||||
|
|
||||||
settings.Theme.PropertyChanged += (se, ev) =>
|
themePropertyChangedHandler = (_, _) => ThemeChanged();
|
||||||
{
|
SubscribeToTheme(settings.Theme);
|
||||||
ThemeChanged();
|
|
||||||
};
|
|
||||||
|
|
||||||
PluginMetadata = pluginMetadata;
|
PluginMetadata = pluginMetadata;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
this.screenBounds = screenBounds;
|
||||||
|
this.screenDeviceName = screenDeviceName;
|
||||||
|
|
||||||
Left = settings.Position.X;
|
System.Windows.Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
|
||||||
Top = settings.Position.Y;
|
System.Windows.Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
|
||||||
Width = settings.Size.X;
|
Left = position.X;
|
||||||
Height = settings.Size.Y;
|
Top = position.Y;
|
||||||
|
Width = size.X;
|
||||||
|
Height = size.Y;
|
||||||
|
|
||||||
PluginFolderPath = pluginFolderPath;
|
PluginFolderPath = pluginFolderPath;
|
||||||
|
|
||||||
|
if (settings.Owner is not null)
|
||||||
|
{
|
||||||
|
synchronizer = Manager.Instance.GetSettingSynchronizer(settings.Owner, PluginMetadata.Id);
|
||||||
|
synchronizer.Register(this);
|
||||||
|
}
|
||||||
|
|
||||||
assemblyLoadContext = CreateAssemblyLoadContext();
|
assemblyLoadContext = CreateAssemblyLoadContext();
|
||||||
|
|
||||||
// Initialize hot reload watcher if plugin supports unloading and is external
|
// Initialize hot reload watcher if plugin supports unloading and is external
|
||||||
@@ -105,11 +157,27 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings) : this(pluginMetadata, settings, string.Empty)
|
public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings, Rectangle screenBounds, string screenDeviceName) : this(pluginMetadata, settings, string.Empty, screenBounds, screenDeviceName)
|
||||||
{
|
{
|
||||||
this.pluginClassInstance = pluginClassInstance;
|
this.pluginClassInstance = pluginClassInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SubscribeToTheme(Theme theme)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(subscribedTheme, theme))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscribedTheme is not null)
|
||||||
|
{
|
||||||
|
subscribedTheme.PropertyChanged -= themePropertyChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribedTheme = theme;
|
||||||
|
subscribedTheme.PropertyChanged += themePropertyChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
private AssemblyLoadContext CreateAssemblyLoadContext()
|
private AssemblyLoadContext CreateAssemblyLoadContext()
|
||||||
{
|
{
|
||||||
AssemblyLoadContext context = new(PluginMetadata.Name, isCollectible: true);
|
AssemblyLoadContext context = new(PluginMetadata.Name, isCollectible: true);
|
||||||
@@ -118,11 +186,10 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
string assemblyPath = Path.Combine(PluginFolderPath, assemblyName.Name + ".dll");
|
string assemblyPath = Path.Combine(PluginFolderPath, assemblyName.Name + ".dll");
|
||||||
if (File.Exists(assemblyPath))
|
if (File.Exists(assemblyPath))
|
||||||
{
|
{
|
||||||
return ctx.LoadFromAssemblyPath(assemblyPath);
|
byte[] assemblyData = File.ReadAllBytes(assemblyPath);
|
||||||
}
|
using MemoryStream assemblyStream = new(assemblyData);
|
||||||
else
|
|
||||||
{
|
return ctx.LoadFromStream(assemblyStream);
|
||||||
_ = ctx.LoadFromAssemblyName(assemblyName);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -281,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);
|
||||||
@@ -306,6 +388,8 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
public void SetEditMode(bool enabled)
|
public void SetEditMode(bool enabled)
|
||||||
{
|
{
|
||||||
|
_editMode = enabled;
|
||||||
|
|
||||||
if (enabled)
|
if (enabled)
|
||||||
{
|
{
|
||||||
Topmost = true;
|
Topmost = true;
|
||||||
@@ -337,6 +421,9 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
WindowInteropHelper helper = new(this);
|
WindowInteropHelper helper = new(this);
|
||||||
_ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE,
|
_ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE,
|
||||||
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
|
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
|
||||||
|
|
||||||
|
HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr _, IntPtr lParam, ref bool _) =>
|
||||||
|
ScreenUtilities.ClampToScreenHook(hwnd, msg, lParam, screenBounds));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateImageFromBitmap(Bitmap bitmap, BitmapScalingMode scalingMode)
|
private void UpdateImageFromBitmap(Bitmap bitmap, BitmapScalingMode scalingMode)
|
||||||
@@ -345,8 +432,28 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
try
|
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<byte>(scan0, byteCount)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
if (lastBitmapScalingMode != scalingMode)
|
if (lastBitmapScalingMode != scalingMode)
|
||||||
{
|
{
|
||||||
RenderOptions.SetBitmapScalingMode(image, scalingMode);
|
RenderOptions.SetBitmapScalingMode(image, scalingMode);
|
||||||
@@ -355,9 +462,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;
|
||||||
}
|
}
|
||||||
@@ -379,7 +496,17 @@ 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
|
||||||
{
|
{
|
||||||
@@ -481,7 +608,7 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
dll = Assembly.LoadFrom($"{PluginFolderPath}\\main.dll");
|
dll = Assembly.LoadFrom($"{PluginFolderPath}\\main.dll");
|
||||||
}
|
}
|
||||||
|
|
||||||
Type? instanceType = Array.Find(dll.GetTypes(), type => type.GetTypeInfo().BaseType == typeof(Plugin));
|
Type? instanceType = Array.Find(dll.GetTypes(), type => type.IsAssignableTo(typeof(Plugin)));
|
||||||
|
|
||||||
if (instanceType is null)
|
if (instanceType is null)
|
||||||
{
|
{
|
||||||
@@ -505,7 +632,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
|
||||||
{
|
{
|
||||||
@@ -534,6 +662,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)
|
||||||
{
|
{
|
||||||
@@ -544,7 +674,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();
|
||||||
@@ -562,14 +692,18 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
SetThemeOverride();
|
SetThemeOverride();
|
||||||
SetThemeOverrideItems();
|
SetThemeOverrideItems();
|
||||||
|
|
||||||
pluginClassInstance.horizontalAlignment.OnValueChanged += SetHorizontalAlignment;
|
SubscribeToDefaultSetting(pluginClassInstance.horizontalAlignment, SetHorizontalAlignment);
|
||||||
pluginClassInstance.verticalAlignment.OnValueChanged += SetVerticalAlignment;
|
SubscribeToDefaultSetting(pluginClassInstance.verticalAlignment, SetVerticalAlignment);
|
||||||
pluginClassInstance.windowLayer.OnValueChanged += SetWindowLayer;
|
SubscribeToDefaultSetting(pluginClassInstance.windowLayer, SetWindowLayer);
|
||||||
pluginClassInstance.rotation.OnValueChanged += SetRotation;
|
SubscribeToDefaultSetting(pluginClassInstance.rotation, SetRotation);
|
||||||
pluginClassInstance.themeOverride.OnValueChanged += SetThemeOverride;
|
SubscribeToDefaultSetting(pluginClassInstance.themeOverride, SetThemeOverride);
|
||||||
|
|
||||||
DesktopMagicSettings desktopMagicSettings = MainWindowDataContext.GetSettings();
|
if (themesCollectionChangedHandler is null)
|
||||||
desktopMagicSettings.Themes.CollectionChanged += (s, e) => SetThemeOverrideItems();
|
{
|
||||||
|
themesCollectionChangedHandler = (_, _) => SetThemeOverrideItems();
|
||||||
|
DesktopMagicSettings desktopMagicSettings = MainWindowDataContext.GetSettings();
|
||||||
|
desktopMagicSettings.Themes.CollectionChanged += themesCollectionChangedHandler;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
void SetVerticalAlignment()
|
void SetVerticalAlignment()
|
||||||
@@ -602,7 +736,14 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
void SetWindowLayer()
|
void SetWindowLayer()
|
||||||
{
|
{
|
||||||
WindowPos.SetWindowLayer(this, pluginClassInstance.windowLayer.Value);
|
if (_editMode)
|
||||||
|
{
|
||||||
|
Topmost = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WindowPos.SetWindowLayer(this, pluginClassInstance.windowLayer.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetRotation()
|
void SetRotation()
|
||||||
@@ -636,6 +777,50 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SubscribeToDefaultSetting(Setting setting, Action handler)
|
||||||
|
{
|
||||||
|
setting.OnValueChanged += handler;
|
||||||
|
defaultSettingsSubscriptions.Add((setting, handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPluginSettingValueChanged(Setting setting, string id)
|
||||||
|
{
|
||||||
|
pluginClassInstance?.OnSettingsChanged();
|
||||||
|
|
||||||
|
if (pluginClassInstance?.UpdateInterval is 0 or > 500)
|
||||||
|
{
|
||||||
|
pluginClassInstance.Application.UpdateWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronizer?.SettingChanged(this, id, setting.GetJsonValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplySettingValue(string id, string value)
|
||||||
|
{
|
||||||
|
if (localSettings.TryGetValue(id, out Setting? setting) && setting.GetJsonValue() != value)
|
||||||
|
{
|
||||||
|
setting.SetJsonValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyButtonClick(string id)
|
||||||
|
{
|
||||||
|
if (!localSettings.TryGetValue(id, out Setting? setting) || setting is not Button button)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressButtonSync = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
button.Click();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
suppressButtonSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadOptions(object instance)
|
private async Task LoadOptions(object instance)
|
||||||
{
|
{
|
||||||
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Loading plugin options", source: "Plugin");
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Loading plugin options", source: "Plugin");
|
||||||
@@ -653,23 +838,36 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
{
|
{
|
||||||
if (attribute is SettingAttribute elementAttribute)
|
if (attribute is SettingAttribute elementAttribute)
|
||||||
{
|
{
|
||||||
|
localSettings[elementAttribute.Id] = element;
|
||||||
|
|
||||||
SettingElement settingElement = new SettingElement(element, elementAttribute.Id, elementAttribute.Name, elementAttribute.OrderIndex);
|
SettingElement settingElement = new SettingElement(element, elementAttribute.Id, elementAttribute.Name, elementAttribute.OrderIndex);
|
||||||
|
|
||||||
if (settings.Settings.Exists(e => e.Id == elementAttribute.Id))
|
if (settings.Settings.Exists(e => e.Id == elementAttribute.Id))
|
||||||
{
|
{
|
||||||
SettingElement settingsSettingElement = settings.Settings.First(e => e.Id == elementAttribute.Id);
|
SettingElement settingsSettingElement = settings.Settings.First(e => e.Id == elementAttribute.Id);
|
||||||
settingElement.JsonValue = settingsSettingElement.JsonValue;
|
string savedValue = settingsSettingElement.JsonValue;
|
||||||
|
if (!string.IsNullOrEmpty(savedValue) || element is not Label and not Button)
|
||||||
|
{
|
||||||
|
settingElement.JsonValue = savedValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
element.OnValueChanged += () =>
|
Action valueChangedHandler = () => OnPluginSettingValueChanged(element, elementAttribute.Id);
|
||||||
{
|
element.OnValueChanged += valueChangedHandler;
|
||||||
pluginClassInstance?.OnSettingsChanged();
|
subscribedSettings.Add((element, valueChangedHandler));
|
||||||
|
|
||||||
if (pluginClassInstance?.UpdateInterval is 0 or > 500)
|
if (element is Button button)
|
||||||
|
{
|
||||||
|
Action clickHandler = () =>
|
||||||
{
|
{
|
||||||
pluginClassInstance.Application.UpdateWindow();
|
if (!suppressButtonSync)
|
||||||
}
|
{
|
||||||
};
|
synchronizer?.ButtonClicked(this, elementAttribute.Id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
button.OnClick += clickHandler;
|
||||||
|
subscribedButtonClicks.Add((button, clickHandler));
|
||||||
|
}
|
||||||
|
|
||||||
settingElements.Add(settingElement);
|
settingElements.Add(settingElement);
|
||||||
break;
|
break;
|
||||||
@@ -830,34 +1028,46 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
private async void UpdateTimer_Elapsed(object? sender, ElapsedEventArgs? e)
|
private async void UpdateTimer_Elapsed(object? sender, ElapsedEventArgs? e)
|
||||||
{
|
{
|
||||||
|
// Capture the fields into locals so an in-flight tick keeps the plugin assembly
|
||||||
|
// alive and stays safe when the window is being closed or the plugin reloaded.
|
||||||
|
Plugin? plugin = pluginClassInstance;
|
||||||
|
System.Timers.Timer? timer = updateTimer;
|
||||||
|
|
||||||
|
if (!IsRunning || plugin is null)
|
||||||
|
{
|
||||||
|
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 (IsRunning && pluginClassInstance is not null)
|
if (plugin is SkiaPlugin or SkiaAsyncPlugin)
|
||||||
|
{
|
||||||
|
await RenderSkiaFrame();
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
Bitmap? result;
|
Bitmap? result;
|
||||||
|
|
||||||
if (pluginClassInstance is AsyncPlugin asyncPlugin)
|
if (plugin is AsyncPlugin asyncPlugin)
|
||||||
{
|
{
|
||||||
CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None;
|
CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
result = await asyncPlugin.MainAsync(token);
|
result = await asyncPlugin.MainAsync(token);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result = pluginClassInstance.Main();
|
result = plugin.Main();
|
||||||
}
|
|
||||||
|
|
||||||
if (pluginClassInstance.UpdateInterval > 0)
|
|
||||||
{
|
|
||||||
updateTimer!.Interval = pluginClassInstance.UpdateInterval;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
updateTimer!.Stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result is not null)
|
if (result is not null)
|
||||||
{
|
{
|
||||||
BitmapScalingMode renderOptions = pluginClassInstance.RenderQuality switch
|
BitmapScalingMode renderOptions = plugin.RenderQuality switch
|
||||||
{
|
{
|
||||||
RenderQuality.High => BitmapScalingMode.HighQuality,
|
RenderQuality.High => BitmapScalingMode.HighQuality,
|
||||||
RenderQuality.Low => BitmapScalingMode.LowQuality,
|
RenderQuality.Low => BitmapScalingMode.LowQuality,
|
||||||
@@ -868,9 +1078,30 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
UpdateImageFromBitmap(result, renderOptions);
|
UpdateImageFromBitmap(result, renderOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (timer is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.UpdateInterval > 0)
|
||||||
|
{
|
||||||
|
timer.Interval = plugin.UpdateInterval;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
timer.Stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
// If the window is closing, the plugin state is being torn down and the
|
||||||
|
// exception is a teardown race - ignore it instead of showing an error.
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin");
|
App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin");
|
||||||
_ = await Dispatcher.InvokeAsync(async () =>
|
_ = await Dispatcher.InvokeAsync(async () =>
|
||||||
@@ -884,12 +1115,143 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
_ = await messageBox.ShowDialogAsync();
|
_ = await messageBox.ShowDialogAsync();
|
||||||
});
|
});
|
||||||
Exit();
|
Exit();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref renderInProgress, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RenderSkiaFrame()
|
||||||
|
{
|
||||||
|
Plugin? plugin = pluginClassInstance;
|
||||||
|
|
||||||
|
if (plugin is null)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsRunning)
|
SKImage? skImage;
|
||||||
|
|
||||||
|
if (plugin is SkiaAsyncPlugin skiaAsyncPlugin)
|
||||||
{
|
{
|
||||||
updateTimer!.Stop();
|
CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
skImage = await skiaAsyncPlugin.MainAsync(token);
|
||||||
|
}
|
||||||
|
else if (plugin is SkiaPlugin skiaPlugin)
|
||||||
|
{
|
||||||
|
skImage = skiaPlugin.Main();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
lastFramePixels = new byte[byteCount];
|
||||||
|
}
|
||||||
|
lastFrameWidth = width;
|
||||||
|
lastFrameHeight = height;
|
||||||
|
lastFrameStride = pixmap.RowBytes;
|
||||||
|
pixmap.GetPixelSpan().CopyTo(lastFramePixels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,30 +1283,156 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
reloadDebounceTimer?.Dispose();
|
reloadDebounceTimer?.Dispose();
|
||||||
reloadDebounceTimer = null;
|
reloadDebounceTimer = null;
|
||||||
|
|
||||||
|
updateTimer?.Stop();
|
||||||
|
updateTimer?.Dispose();
|
||||||
|
updateTimer = null;
|
||||||
|
|
||||||
// Stop plugin using the shared method (no need to await in synchronous event handler)
|
// Stop plugin using the shared method (no need to await in synchronous event handler)
|
||||||
_ = StopPlugin(unloadAssembly: true);
|
_ = StopPlugin(unloadAssembly: true);
|
||||||
|
|
||||||
|
UnsubscribeEvents();
|
||||||
|
DetachSettings();
|
||||||
|
|
||||||
|
if (synchronizer is not null && settings.Owner is not null)
|
||||||
|
{
|
||||||
|
if (synchronizer.Unregister(this))
|
||||||
|
{
|
||||||
|
Manager.Instance.ReleaseSettingSynchronizer(settings.Owner, PluginMetadata.Id);
|
||||||
|
}
|
||||||
|
synchronizer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DetachSettings()
|
||||||
|
{
|
||||||
|
foreach (SettingElement settingElement in settings.Settings)
|
||||||
|
{
|
||||||
|
string value = settingElement.JsonValue;
|
||||||
|
settingElement.Input = null;
|
||||||
|
settingElement.JsonValue = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeEvents()
|
||||||
|
{
|
||||||
|
settings.PropertyChanged -= settingsPropertyChangedHandler;
|
||||||
|
|
||||||
|
if (subscribedTheme is not null)
|
||||||
|
{
|
||||||
|
subscribedTheme.PropertyChanged -= themePropertyChangedHandler;
|
||||||
|
subscribedTheme = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themesCollectionChangedHandler is not null)
|
||||||
|
{
|
||||||
|
MainWindowDataContext.GetSettings().Themes.CollectionChanged -= themesCollectionChangedHandler;
|
||||||
|
themesCollectionChangedHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((Setting Setting, Action Handler) subscription in subscribedSettings)
|
||||||
|
{
|
||||||
|
subscription.Setting.OnValueChanged -= subscription.Handler;
|
||||||
|
}
|
||||||
|
subscribedSettings.Clear();
|
||||||
|
|
||||||
|
foreach ((Setting Setting, Action Handler) subscription in defaultSettingsSubscriptions)
|
||||||
|
{
|
||||||
|
subscription.Setting.OnValueChanged -= subscription.Handler;
|
||||||
|
}
|
||||||
|
defaultSettingsSubscriptions.Clear();
|
||||||
|
|
||||||
|
foreach ((Button Button, Action Handler) subscription in subscribedButtonClicks)
|
||||||
|
{
|
||||||
|
subscription.Button.OnClick -= subscription.Handler;
|
||||||
|
}
|
||||||
|
subscribedButtonClicks.Clear();
|
||||||
|
|
||||||
|
localSettings.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePosition()
|
||||||
|
{
|
||||||
|
if (isUpdatingPosition)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Windows.Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
|
||||||
|
if (position == new System.Windows.Point(Left, Top))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPosition = true;
|
||||||
|
Left = position.X;
|
||||||
|
Top = position.Y;
|
||||||
|
isUpdatingPosition = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSize()
|
||||||
|
{
|
||||||
|
if (isUpdatingPosition)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Windows.Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
|
||||||
|
if (size == new System.Windows.Point(Width, Height))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPosition = true;
|
||||||
|
Width = size.X;
|
||||||
|
Height = size.Y;
|
||||||
|
isUpdatingPosition = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Window Events
|
#region Window Events
|
||||||
|
|
||||||
private void Window_LocationChanged(object sender, EventArgs e)
|
private void Window_LocationChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
settings.Position = new System.Windows.Point(Left, Top);
|
if (isUpdatingPosition)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
settings.Size = new System.Windows.Point(Width, Height);
|
if (!isUpdatingPosition)
|
||||||
|
{
|
||||||
|
settings.Size = ScreenUtilities.SizeToPercent(new System.Windows.Point(Width, Height), screenBounds);
|
||||||
|
}
|
||||||
|
|
||||||
tileBar.CaptionHeight = ActualHeight - 10;
|
tileBar.CaptionHeight = ActualHeight - 10;
|
||||||
|
RefreshWindowPixelSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Window_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
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)
|
||||||
|
{
|
||||||
|
_ = image.Focus();
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -970,27 +1458,75 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
pluginClassInstance?.OnMouseClick(point, mouseButton);
|
pluginClassInstance?.OnMouseClick(point, mouseButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Window_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
private void Image_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Window_MouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
|
private void Image_MouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
|
||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Image_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (pluginClassInstance is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys key = (Keys)e.Key;
|
||||||
|
System.Windows.Input.ModifierKeys modifiers = e.KeyboardDevice.Modifiers;
|
||||||
|
bool alt = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt);
|
||||||
|
bool control = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control);
|
||||||
|
bool shift = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift);
|
||||||
|
bool windows = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Windows);
|
||||||
|
|
||||||
|
pluginClassInstance.OnKeyDown(new KeyEventArgs(key, alt, control, shift, windows));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Image_KeyUp(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (pluginClassInstance is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys key = (Keys)e.Key;
|
||||||
|
System.Windows.Input.ModifierKeys modifiers = e.KeyboardDevice.Modifiers;
|
||||||
|
bool alt = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt);
|
||||||
|
bool control = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control);
|
||||||
|
bool shift = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift);
|
||||||
|
bool windows = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Windows);
|
||||||
|
|
||||||
|
pluginClassInstance.OnKeyUp(new KeyEventArgs(key, alt, control, shift, windows));
|
||||||
|
}
|
||||||
|
|
||||||
#endregion Window Events
|
#endregion Window Events
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,27 @@ namespace DesktopMagic.Plugins;
|
|||||||
public class SettingElement
|
public class SettingElement
|
||||||
{
|
{
|
||||||
private string jsonValue = string.Empty;
|
private string jsonValue = string.Empty;
|
||||||
|
private bool shareable;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Setting Input { get; set; }
|
public Setting? Input { get; set; }
|
||||||
|
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public int OrderIndex { get; set; }
|
public int OrderIndex { get; set; }
|
||||||
|
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the value may be included when sharing a layout on mod.io.
|
||||||
|
/// Free-text values (URLs, locations, file paths, ...) can contain personal data and are never shared.
|
||||||
|
/// Persisted so the decision stays stable while the plugin is not loaded.
|
||||||
|
/// </summary>
|
||||||
|
public bool Shareable
|
||||||
|
{
|
||||||
|
get => Input is null ? shareable : IsShareableInput(Input);
|
||||||
|
set => shareable = value;
|
||||||
|
}
|
||||||
|
|
||||||
public string JsonValue
|
public string JsonValue
|
||||||
{
|
{
|
||||||
get => Input?.GetJsonValue() ?? jsonValue;
|
get => Input?.GetJsonValue() ?? jsonValue;
|
||||||
@@ -40,6 +52,11 @@ public class SettingElement
|
|||||||
OrderIndex = orderIndex;
|
OrderIndex = orderIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsShareableInput(Setting input)
|
||||||
|
{
|
||||||
|
return input is CheckBox or ColorPicker or ComboBox or IntegerUpDown or Slider;
|
||||||
|
}
|
||||||
|
|
||||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
|
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
|
||||||
|
|
||||||
[JsonConstructor]
|
[JsonConstructor]
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the per-instance plugin settings of every window sharing the same layout in sync.
|
||||||
|
/// Each window owns its own <see cref="DesktopMagic.Api.Settings.Setting"/> objects (plugins
|
||||||
|
/// hold them in readonly fields and subscribe to them in Start), so a value change on one
|
||||||
|
/// window has to be mirrored onto the sibling windows.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SettingSynchronizer
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly List<IPluginWindow> windows = [];
|
||||||
|
private readonly HashSet<(IPluginWindow Window, string Id)> inFlight = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a window so it receives mirrored settings from the other windows of this layout.
|
||||||
|
/// </summary>
|
||||||
|
public void Register(IPluginWindow window)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!windows.Contains(window))
|
||||||
|
{
|
||||||
|
windows.Add(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unregisters a window and returns whether no windows remain.
|
||||||
|
/// </summary>
|
||||||
|
public bool Unregister(IPluginWindow window)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
windows.Remove(window);
|
||||||
|
return windows.Count == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors a setting value change onto every other registered window.
|
||||||
|
/// </summary>
|
||||||
|
public void SettingChanged(IPluginWindow origin, string id, string value)
|
||||||
|
{
|
||||||
|
Mirror(origin, id, window => window.ApplySettingValue(id, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors a button click onto every other registered window.
|
||||||
|
/// </summary>
|
||||||
|
public void ButtonClicked(IPluginWindow origin, string id)
|
||||||
|
{
|
||||||
|
Mirror(origin, id, window => window.ApplyButtonClick(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Mirror(IPluginWindow origin, string id, Action<IPluginWindow> apply)
|
||||||
|
{
|
||||||
|
IPluginWindow[] snapshot;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
snapshot = windows.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (IPluginWindow window in snapshot)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(window, origin) || !window.IsRunning)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!inFlight.Add((window, id)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window is not DispatcherObject dispatcherObject)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
inFlight.Remove((window, id));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = dispatcherObject.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
apply(window);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
inFlight.Remove((window, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using DesktopMagic.Api;
|
using DesktopMagic.Api;
|
||||||
using DesktopMagic.Helpers;
|
using DesktopMagic.Helpers;
|
||||||
|
using DesktopMagic.Settings;
|
||||||
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
@@ -11,7 +12,7 @@ using Color = System.Drawing.Color;
|
|||||||
|
|
||||||
namespace DesktopMagic.Plugins;
|
namespace DesktopMagic.Plugins;
|
||||||
|
|
||||||
public class Theme(string name) : ITheme, INotifyPropertyChanged
|
public class Theme(string name) : ITheme, INotifyPropertyChanged, IModIoShareable
|
||||||
{
|
{
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
@@ -21,6 +22,23 @@ public class Theme(string name) : ITheme, INotifyPropertyChanged
|
|||||||
private string font = "Segoe UI";
|
private string font = "Segoe UI";
|
||||||
private int cornerRadius;
|
private int cornerRadius;
|
||||||
private int margin;
|
private int margin;
|
||||||
|
private ModIoShareState shareState;
|
||||||
|
|
||||||
|
public ModIoLink? ModIo { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public ModIoShareState ShareState
|
||||||
|
{
|
||||||
|
get => shareState;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (shareState != value)
|
||||||
|
{
|
||||||
|
shareState = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string Name
|
public string Name
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ using DesktopMagic.Settings;
|
|||||||
using Microsoft.Web.WebView2.Core;
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -29,11 +31,27 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
private System.Timers.Timer? reloadDebounceTimer;
|
private System.Timers.Timer? reloadDebounceTimer;
|
||||||
private bool isReloading = false;
|
private bool isReloading = false;
|
||||||
|
|
||||||
|
private readonly System.Drawing.Rectangle screenBounds;
|
||||||
|
private readonly string screenDeviceName;
|
||||||
|
private bool isUpdatingPosition = false;
|
||||||
|
|
||||||
|
// Event handlers on long-lived settings objects, tracked so they can be unsubscribed on close.
|
||||||
|
private readonly PropertyChangedEventHandler settingsPropertyChangedHandler;
|
||||||
|
private readonly PropertyChangedEventHandler themePropertyChangedHandler;
|
||||||
|
private Theme? subscribedTheme;
|
||||||
|
private readonly List<(Setting Setting, Action Handler)> subscribedSettings = [];
|
||||||
|
private readonly List<(Button Button, Action Handler)> subscribedButtonClicks = [];
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, Setting> localSettings = [];
|
||||||
|
private SettingSynchronizer? synchronizer;
|
||||||
|
private bool suppressButtonSync = false;
|
||||||
|
|
||||||
public bool IsRunning { get; private set; } = true;
|
public bool IsRunning { get; private set; } = true;
|
||||||
public PluginMetadata PluginMetadata { get; private set; }
|
public PluginMetadata PluginMetadata { get; private set; }
|
||||||
public string PluginFolderPath { get; private set; }
|
public string PluginFolderPath { get; private set; }
|
||||||
|
public string ScreenDeviceName => screenDeviceName;
|
||||||
|
|
||||||
public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath)
|
public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, System.Drawing.Rectangle screenBounds, string screenDeviceName)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
@@ -53,39 +71,69 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
Owner = w;
|
Owner = w;
|
||||||
|
|
||||||
settings.PropertyChanged += (e, s) =>
|
settingsPropertyChangedHandler = (_, s) =>
|
||||||
{
|
{
|
||||||
if (s.PropertyName == nameof(PluginSettings.CurrentThemeName))
|
if (s.PropertyName == nameof(PluginSettings.CurrentThemeName))
|
||||||
{
|
{
|
||||||
settings.Theme.PropertyChanged += (se, ev) =>
|
SubscribeToTheme(settings.Theme);
|
||||||
{
|
|
||||||
ThemeChanged();
|
|
||||||
};
|
|
||||||
ThemeChanged();
|
ThemeChanged();
|
||||||
}
|
}
|
||||||
|
else if (s.PropertyName == nameof(PluginSettings.Position))
|
||||||
|
{
|
||||||
|
UpdatePosition();
|
||||||
|
}
|
||||||
|
else if (s.PropertyName == nameof(PluginSettings.Size))
|
||||||
|
{
|
||||||
|
UpdateSize();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
settings.PropertyChanged += settingsPropertyChangedHandler;
|
||||||
|
|
||||||
settings.Theme.PropertyChanged += (se, ev) =>
|
themePropertyChangedHandler = (_, _) => ThemeChanged();
|
||||||
{
|
SubscribeToTheme(settings.Theme);
|
||||||
ThemeChanged();
|
|
||||||
};
|
|
||||||
|
|
||||||
PluginMetadata = pluginMetadata;
|
PluginMetadata = pluginMetadata;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
this.screenBounds = screenBounds;
|
||||||
|
this.screenDeviceName = screenDeviceName;
|
||||||
|
|
||||||
Left = settings.Position.X;
|
Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
|
||||||
Top = settings.Position.Y;
|
Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
|
||||||
Width = settings.Size.X;
|
Left = position.X;
|
||||||
Height = settings.Size.Y;
|
Top = position.Y;
|
||||||
|
Width = size.X;
|
||||||
|
Height = size.Y;
|
||||||
|
|
||||||
PluginFolderPath = pluginFolderPath;
|
PluginFolderPath = pluginFolderPath;
|
||||||
|
|
||||||
|
if (settings.Owner is not null)
|
||||||
|
{
|
||||||
|
synchronizer = Manager.Instance.GetSettingSynchronizer(settings.Owner, PluginMetadata.Id);
|
||||||
|
synchronizer.Register(this);
|
||||||
|
}
|
||||||
|
|
||||||
if (pluginMetadata.SupportsUnloading && !string.IsNullOrEmpty(pluginFolderPath))
|
if (pluginMetadata.SupportsUnloading && !string.IsNullOrEmpty(pluginFolderPath))
|
||||||
{
|
{
|
||||||
InitializeHotReload();
|
InitializeHotReload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SubscribeToTheme(Theme theme)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(subscribedTheme, theme))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscribedTheme is not null)
|
||||||
|
{
|
||||||
|
subscribedTheme.PropertyChanged -= themePropertyChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribedTheme = theme;
|
||||||
|
subscribedTheme.PropertyChanged += themePropertyChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
public void Exit()
|
public void Exit()
|
||||||
{
|
{
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
@@ -120,6 +168,49 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ApplySettingValue(string id, string value)
|
||||||
|
{
|
||||||
|
if (localSettings.TryGetValue(id, out Setting? setting) && setting.GetJsonValue() != value)
|
||||||
|
{
|
||||||
|
setting.SetJsonValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyButtonClick(string id)
|
||||||
|
{
|
||||||
|
if (!localSettings.TryGetValue(id, out Setting? setting) || setting is not Button button)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressButtonSync = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
button.Click();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
suppressButtonSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -127,6 +218,9 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
WindowInteropHelper helper = new(this);
|
WindowInteropHelper helper = new(this);
|
||||||
_ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE,
|
_ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE,
|
||||||
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
|
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
|
||||||
|
|
||||||
|
HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr _, IntPtr lParam, ref bool _) =>
|
||||||
|
ScreenUtilities.ClampToScreenHook(hwnd, msg, lParam, screenBounds));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Window_ContentRendered(object? sender, EventArgs e)
|
private async void Window_ContentRendered(object? sender, EventArgs e)
|
||||||
@@ -249,6 +343,18 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
reloadDebounceTimer?.Stop();
|
reloadDebounceTimer?.Stop();
|
||||||
reloadDebounceTimer?.Dispose();
|
reloadDebounceTimer?.Dispose();
|
||||||
|
reloadDebounceTimer = null;
|
||||||
|
|
||||||
|
UnsubscribeEvents();
|
||||||
|
|
||||||
|
if (synchronizer is not null && settings.Owner is not null)
|
||||||
|
{
|
||||||
|
if (synchronizer.Unregister(this))
|
||||||
|
{
|
||||||
|
Manager.Instance.ReleaseSettingSynchronizer(settings.Owner, PluginMetadata.Id);
|
||||||
|
}
|
||||||
|
synchronizer = null;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -263,14 +369,85 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeEvents()
|
||||||
|
{
|
||||||
|
settings.PropertyChanged -= settingsPropertyChangedHandler;
|
||||||
|
|
||||||
|
if (subscribedTheme is not null)
|
||||||
|
{
|
||||||
|
subscribedTheme.PropertyChanged -= themePropertyChangedHandler;
|
||||||
|
subscribedTheme = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((Setting setting, Action handler) in subscribedSettings)
|
||||||
|
{
|
||||||
|
setting.OnValueChanged -= handler;
|
||||||
|
}
|
||||||
|
subscribedSettings.Clear();
|
||||||
|
|
||||||
|
foreach ((Button button, Action handler) in subscribedButtonClicks)
|
||||||
|
{
|
||||||
|
button.OnClick -= handler;
|
||||||
|
}
|
||||||
|
subscribedButtonClicks.Clear();
|
||||||
|
|
||||||
|
localSettings.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePosition()
|
||||||
|
{
|
||||||
|
if (isUpdatingPosition)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds);
|
||||||
|
if (position == new Point(Left, Top))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPosition = true;
|
||||||
|
Left = position.X;
|
||||||
|
Top = position.Y;
|
||||||
|
isUpdatingPosition = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSize()
|
||||||
|
{
|
||||||
|
if (isUpdatingPosition)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds);
|
||||||
|
if (size == new Point(Width, Height))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPosition = true;
|
||||||
|
Width = size.X;
|
||||||
|
Height = size.Y;
|
||||||
|
isUpdatingPosition = false;
|
||||||
|
}
|
||||||
|
|
||||||
private void Window_LocationChanged(object sender, EventArgs e)
|
private void Window_LocationChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
settings.Position = new System.Windows.Point(Left, Top);
|
if (isUpdatingPosition)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Position = ScreenUtilities.PositionToPercent(new Point(Left, Top), screenBounds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
|
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||||
{
|
{
|
||||||
settings.Size = new System.Windows.Point(Width, Height);
|
if (!isUpdatingPosition)
|
||||||
|
{
|
||||||
|
settings.Size = ScreenUtilities.SizeToPercent(new Point(Width, Height), screenBounds);
|
||||||
|
}
|
||||||
|
|
||||||
tileBar.CaptionHeight = ActualHeight - 10;
|
tileBar.CaptionHeight = ActualHeight - 10;
|
||||||
}
|
}
|
||||||
@@ -330,19 +507,30 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localSettings[id] = setting;
|
||||||
|
|
||||||
SettingElement settingElement = new(setting, id, name, orderIndex);
|
SettingElement settingElement = new(setting, id, name, orderIndex);
|
||||||
|
|
||||||
if (settings.Settings.Exists(e => e.Id == id))
|
if (settings.Settings.Exists(e => e.Id == id))
|
||||||
{
|
{
|
||||||
SettingElement saved = settings.Settings.First(e => e.Id == id);
|
SettingElement saved = settings.Settings.First(e => e.Id == id);
|
||||||
settingElement.JsonValue = saved.JsonValue;
|
string savedValue = saved.JsonValue;
|
||||||
|
if (!string.IsNullOrEmpty(savedValue) || setting is not Label and not Button)
|
||||||
|
{
|
||||||
|
settingElement.JsonValue = savedValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string capturedId = id;
|
string capturedId = id;
|
||||||
if (setting is Button button)
|
if (setting is Button button)
|
||||||
{
|
{
|
||||||
button.OnClick += () =>
|
Action clickHandler = () =>
|
||||||
{
|
{
|
||||||
|
if (!suppressButtonSync)
|
||||||
|
{
|
||||||
|
synchronizer?.ButtonClicked(this, capturedId);
|
||||||
|
}
|
||||||
|
|
||||||
_ = webView.Dispatcher.InvokeAsync(async () =>
|
_ = webView.Dispatcher.InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -355,10 +543,14 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
button.OnClick += clickHandler;
|
||||||
|
subscribedButtonClicks.Add((button, clickHandler));
|
||||||
}
|
}
|
||||||
|
|
||||||
setting.OnValueChanged += () =>
|
Action valueChangedHandler = () =>
|
||||||
{
|
{
|
||||||
|
synchronizer?.SettingChanged(this, capturedId, setting.GetJsonValue());
|
||||||
|
|
||||||
_ = webView.Dispatcher.InvokeAsync(async () =>
|
_ = webView.Dispatcher.InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -371,6 +563,8 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
setting.OnValueChanged += valueChangedHandler;
|
||||||
|
subscribedSettings.Add((setting, valueChangedHandler));
|
||||||
|
|
||||||
settingElements.Add(settingElement);
|
settingElements.Add(settingElement);
|
||||||
orderIndex++;
|
orderIndex++;
|
||||||
@@ -540,7 +734,7 @@ public partial class WebPluginWindow : Window, IPluginWindow
|
|||||||
Dictionary<string, object?> dict = [];
|
Dictionary<string, object?> dict = [];
|
||||||
foreach (SettingElement element in settings.Settings)
|
foreach (SettingElement element in settings.Settings)
|
||||||
{
|
{
|
||||||
dict[element.Id] = GetSettingValue(element.Input);
|
dict[element.Id] = GetSettingValue(element.Input!);
|
||||||
}
|
}
|
||||||
return JsonSerializer.Serialize(dict);
|
return JsonSerializer.Serialize(dict);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
<system:String x:Key="weather">Wetter</system:String>
|
<system:String x:Key="weather">Wetter</system:String>
|
||||||
<system:String x:Key="nextMeetingCountdown">Nächstes Meeting Countdown</system:String>
|
<system:String x:Key="nextMeetingCountdown">Nächstes Meeting Countdown</system:String>
|
||||||
<system:String x:Key="agenda">Agenda</system:String>
|
<system:String x:Key="agenda">Agenda</system:String>
|
||||||
|
<system:String x:Key="benchmark">Performance Benchmark</system:String>
|
||||||
|
<system:String x:Key="skiaBenchmark">SkiaSharp Benchmark</system:String>
|
||||||
<system:String x:Key="amplifier">Signalverstärkung:</system:String>
|
<system:String x:Key="amplifier">Signalverstärkung:</system:String>
|
||||||
<system:String x:Key="newLayout">Neues Layout</system:String>
|
<system:String x:Key="newLayout">Neues Layout</system:String>
|
||||||
<system:String x:Key="deleteLayout">Layout Löschen</system:String>
|
<system:String x:Key="deleteLayout">Layout Löschen</system:String>
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
<system:String x:Key="confirmDeleteTheme">Möchten Sie dieses Theme wirklich löschen?</system:String>
|
<system:String x:Key="confirmDeleteTheme">Möchten Sie dieses Theme wirklich löschen?</system:String>
|
||||||
<system:String x:Key="enterLayoutName">Layoutnamen eingeben:</system:String>
|
<system:String x:Key="enterLayoutName">Layoutnamen eingeben:</system:String>
|
||||||
<system:String x:Key="layoutAlreadyExists">Layout existiert bereits!</system:String>
|
<system:String x:Key="layoutAlreadyExists">Layout existiert bereits!</system:String>
|
||||||
|
<system:String x:Key="cannotDeleteEmptyLayout">Das leere Layout kann nicht gelöscht werden!</system:String>
|
||||||
<system:String x:Key="enterThemeName">Themenamen eingeben:</system:String>
|
<system:String x:Key="enterThemeName">Themenamen eingeben:</system:String>
|
||||||
<system:String x:Key="themeAlreadyExists">Theme existiert bereits!</system:String>
|
<system:String x:Key="themeAlreadyExists">Theme existiert bereits!</system:String>
|
||||||
<system:String x:Key="install">Installieren</system:String>
|
<system:String x:Key="install">Installieren</system:String>
|
||||||
@@ -67,6 +70,30 @@
|
|||||||
<system:String x:Key="releaseInfoPublishedFormat">Veröffentlicht: {0}</system:String>
|
<system:String x:Key="releaseInfoPublishedFormat">Veröffentlicht: {0}</system:String>
|
||||||
<system:String x:Key="releaseInfoBodyLabel">Release Notes:</system:String>
|
<system:String x:Key="releaseInfoBodyLabel">Release Notes:</system:String>
|
||||||
<system:String x:Key="openReleasePage">Release-Seite öffnen</system:String>
|
<system:String x:Key="openReleasePage">Release-Seite öffnen</system:String>
|
||||||
|
<system:String x:Key="close">Schließen</system:String>
|
||||||
|
<system:String x:Key="overwrite">Überschreiben</system:String>
|
||||||
|
<system:String x:Key="keepMine">Meine behalten</system:String>
|
||||||
|
<system:String x:Key="shareOnModIo">Auf mod.io teilen</system:String>
|
||||||
|
<system:String x:Key="updateOnModIo">Auf mod.io aktualisieren</system:String>
|
||||||
|
<system:String x:Key="sharedOnModIo">Auf mod.io ansehen</system:String>
|
||||||
|
<system:String x:Key="shareCopyOnModIo">Als neu auf mod.io teilen</system:String>
|
||||||
|
<system:String x:Key="enterModIoSummary">Kurze Zusammenfassung für mod.io eingeben:</system:String>
|
||||||
|
<system:String x:Key="enterModIoChangelog">Was hat sich geändert? (optional)</system:String>
|
||||||
|
<system:String x:Key="modIoShareLayoutWarning">Jeder auf mod.io kann dieses Layout herunterladen. Es enthält die aktivierten Widgets, ihre Positionen, die verwendeten Themes und ihre Nicht-Text-Einstellungen. Text-Einstellungen (z. B. URLs, Orte oder Dateipfade) und gespeicherte Widget-Daten werden nicht geteilt.</system:String>
|
||||||
|
<system:String x:Key="modIoShareThemeWarning">Jeder auf mod.io kann dieses Theme herunterladen.</system:String>
|
||||||
|
<system:String x:Key="modIoLocalPluginsWarning">Diese Widgets sind lokale Plugins, die andere nicht installieren können: {0}</system:String>
|
||||||
|
<system:String x:Key="modIoShareSuccess">"{0}" wurde auf mod.io veröffentlicht.</system:String>
|
||||||
|
<system:String x:Key="modIoUpdateSuccess">Das Update von "{0}" wurde auf mod.io veröffentlicht.</system:String>
|
||||||
|
<system:String x:Key="modIoShareFailed">Teilen auf mod.io fehlgeschlagen: {0}</system:String>
|
||||||
|
<system:String x:Key="modIoSessionExpired">Ihre mod.io-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.</system:String>
|
||||||
|
<system:String x:Key="modIoUpdateConflict">"{0}" wurde auf mod.io aktualisiert, aber Sie haben es auch lokal geändert. Ihre Änderungen mit der neuen Version überschreiben?</system:String>
|
||||||
|
<system:String x:Key="cannotShareEmptyLayout">Das leere Layout kann nicht geteilt werden!</system:String>
|
||||||
|
<system:String x:Key="layouts">Layouts</system:String>
|
||||||
|
<system:String x:Key="searchModIo">mod.io durchsuchen...</system:String>
|
||||||
|
<system:String x:Key="searchInstalled">Installierte durchsuchen...</system:String>
|
||||||
|
<system:String x:Key="nothingFound">Nichts gefunden!</system:String>
|
||||||
|
<system:String x:Key="modIoInstallFailed">Installation von "{0}" fehlgeschlagen: {1}</system:String>
|
||||||
|
<system:String x:Key="uploadingToModIo">Wird auf mod.io hochgeladen...</system:String>
|
||||||
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
|
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
|
||||||
<system:String>Unten</system:String>
|
<system:String>Unten</system:String>
|
||||||
<system:String>Mitte</system:String>
|
<system:String>Mitte</system:String>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
<system:String x:Key="weather">Weather</system:String>
|
<system:String x:Key="weather">Weather</system:String>
|
||||||
<system:String x:Key="nextMeetingCountdown">Next Meeting Countdown</system:String>
|
<system:String x:Key="nextMeetingCountdown">Next Meeting Countdown</system:String>
|
||||||
<system:String x:Key="agenda">Agenda</system:String>
|
<system:String x:Key="agenda">Agenda</system:String>
|
||||||
|
<system:String x:Key="benchmark">Performance Benchmark</system:String>
|
||||||
|
<system:String x:Key="skiaBenchmark">SkiaSharp Benchmark</system:String>
|
||||||
<system:String x:Key="amplifier">Signal Amplification:</system:String>
|
<system:String x:Key="amplifier">Signal Amplification:</system:String>
|
||||||
<system:String x:Key="newLayout">New Layout</system:String>
|
<system:String x:Key="newLayout">New Layout</system:String>
|
||||||
<system:String x:Key="deleteLayout">Delete Layout</system:String>
|
<system:String x:Key="deleteLayout">Delete Layout</system:String>
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<system:String x:Key="enterLayoutName">Enter layout name</system:String>
|
<system:String x:Key="enterLayoutName">Enter layout name</system:String>
|
||||||
<system:String x:Key="layoutAlreadyExists">Layout already exists!</system:String>
|
<system:String x:Key="layoutAlreadyExists">Layout already exists!</system:String>
|
||||||
<system:String x:Key="cannotDeleteLastLayout">Can't delete last layout!</system:String>
|
<system:String x:Key="cannotDeleteLastLayout">Can't delete last layout!</system:String>
|
||||||
|
<system:String x:Key="cannotDeleteEmptyLayout">Can't delete the empty layout!</system:String>
|
||||||
<system:String x:Key="enterThemeName">Enter theme name</system:String>
|
<system:String x:Key="enterThemeName">Enter theme name</system:String>
|
||||||
<system:String x:Key="themeAlreadyExists">Theme already exists!</system:String>
|
<system:String x:Key="themeAlreadyExists">Theme already exists!</system:String>
|
||||||
<system:String x:Key="cannotDeleteLastTheme">Can't delete last theme!</system:String>
|
<system:String x:Key="cannotDeleteLastTheme">Can't delete last theme!</system:String>
|
||||||
@@ -69,6 +72,30 @@
|
|||||||
<system:String x:Key="releaseInfoPublishedFormat">Published: {0}</system:String>
|
<system:String x:Key="releaseInfoPublishedFormat">Published: {0}</system:String>
|
||||||
<system:String x:Key="releaseInfoBodyLabel">Release notes:</system:String>
|
<system:String x:Key="releaseInfoBodyLabel">Release notes:</system:String>
|
||||||
<system:String x:Key="openReleasePage">Open release page</system:String>
|
<system:String x:Key="openReleasePage">Open release page</system:String>
|
||||||
|
<system:String x:Key="close">Close</system:String>
|
||||||
|
<system:String x:Key="overwrite">Overwrite</system:String>
|
||||||
|
<system:String x:Key="keepMine">Keep mine</system:String>
|
||||||
|
<system:String x:Key="shareOnModIo">Share on mod.io</system:String>
|
||||||
|
<system:String x:Key="updateOnModIo">Update on mod.io</system:String>
|
||||||
|
<system:String x:Key="sharedOnModIo">View on mod.io</system:String>
|
||||||
|
<system:String x:Key="shareCopyOnModIo">Share as new on mod.io</system:String>
|
||||||
|
<system:String x:Key="enterModIoSummary">Enter a short summary shown on mod.io:</system:String>
|
||||||
|
<system:String x:Key="enterModIoChangelog">What changed? (optional)</system:String>
|
||||||
|
<system:String x:Key="modIoShareLayoutWarning">Everyone on mod.io will be able to download this layout. It includes the enabled widgets, their positions, the used themes and their non-text settings. Text settings (e.g. URLs, locations or file paths) and saved widget data are not shared.</system:String>
|
||||||
|
<system:String x:Key="modIoShareThemeWarning">Everyone on mod.io will be able to download this theme.</system:String>
|
||||||
|
<system:String x:Key="modIoLocalPluginsWarning">These widgets are local plugins that others cannot install: {0}</system:String>
|
||||||
|
<system:String x:Key="modIoShareSuccess">"{0}" was published on mod.io.</system:String>
|
||||||
|
<system:String x:Key="modIoUpdateSuccess">The update of "{0}" was published on mod.io.</system:String>
|
||||||
|
<system:String x:Key="modIoShareFailed">Sharing on mod.io failed: {0}</system:String>
|
||||||
|
<system:String x:Key="modIoSessionExpired">Your mod.io session has expired. Please log in again.</system:String>
|
||||||
|
<system:String x:Key="modIoUpdateConflict">"{0}" was updated on mod.io, but you also changed it locally. Overwrite your changes with the new version?</system:String>
|
||||||
|
<system:String x:Key="cannotShareEmptyLayout">The empty layout cannot be shared!</system:String>
|
||||||
|
<system:String x:Key="layouts">Layouts</system:String>
|
||||||
|
<system:String x:Key="searchModIo">Search mod.io...</system:String>
|
||||||
|
<system:String x:Key="searchInstalled">Search installed...</system:String>
|
||||||
|
<system:String x:Key="nothingFound">Nothing found!</system:String>
|
||||||
|
<system:String x:Key="modIoInstallFailed">Installing "{0}" failed: {1}</system:String>
|
||||||
|
<system:String x:Key="uploadingToModIo">Uploading to mod.io...</system:String>
|
||||||
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
|
<col:ArrayList x:Key="musicVisualizerOptionsComboboxItems">
|
||||||
<system:String>Bottom</system:String>
|
<system:String>Bottom</system:String>
|
||||||
<system:String>Middle</system:String>
|
<system:String>Middle</system:String>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using DesktopMagic.Plugins;
|
using DesktopMagic.Plugins;
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -22,7 +23,13 @@ public class DesktopMagicSettings : INotifyPropertyChanged
|
|||||||
init
|
init
|
||||||
{
|
{
|
||||||
themes = value;
|
themes = value;
|
||||||
themes.CollectionChanged += (s, e) => CurrentLayout.UpdateTheme();
|
themes.CollectionChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
foreach (Layout layout in layouts)
|
||||||
|
{
|
||||||
|
layout.UpdateTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
OnPropertyChanged();
|
OnPropertyChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,13 +69,33 @@ public class DesktopMagicSettings : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps screen device names (e.g. "\\.\DISPLAY1") to the layout applied on that screen.
|
||||||
|
/// The layouts themselves are portable and store their target aspect ratio.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> ScreenLayouts { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version of the settings schema, used to migrate older settings files.
|
||||||
|
/// 0 = legacy (layouts not screen aware, pixel based positions).
|
||||||
|
/// </summary>
|
||||||
|
public int SchemaVersion { get; set; }
|
||||||
|
|
||||||
public string? ModIoAccessToken { get; set; }
|
public string? ModIoAccessToken { get; set; }
|
||||||
|
|
||||||
public string? ReleaseInfoLastAppVersion { get; set; }
|
public string? ReleaseInfoLastAppVersion { get; set; }
|
||||||
|
|
||||||
|
public bool IsFirstRun { get; set; } = true;
|
||||||
|
|
||||||
public DesktopMagicSettings()
|
public DesktopMagicSettings()
|
||||||
{
|
{
|
||||||
themes.CollectionChanged += (s, e) => CurrentLayout.UpdateTheme();
|
themes.CollectionChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
foreach (Layout layout in layouts)
|
||||||
|
{
|
||||||
|
layout.UpdateTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayout));
|
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayout));
|
||||||
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayoutName));
|
layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayoutName));
|
||||||
|
|||||||
@@ -9,13 +9,31 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace DesktopMagic.Settings;
|
namespace DesktopMagic.Settings;
|
||||||
|
|
||||||
public class Layout(string name) : INotifyPropertyChanged
|
public class Layout(string name) : INotifyPropertyChanged, IModIoShareable
|
||||||
{
|
{
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
private string name = name;
|
private string name = name;
|
||||||
private string? currentThemeName = null;
|
private string? currentThemeName = null;
|
||||||
private Dictionary<uint, PluginSettings> plugins = [];
|
private Dictionary<uint, PluginSettings> plugins = [];
|
||||||
|
private double screenAspectRatio = 0;
|
||||||
|
private ModIoShareState shareState;
|
||||||
|
|
||||||
|
public ModIoLink? ModIo { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public ModIoShareState ShareState
|
||||||
|
{
|
||||||
|
get => shareState;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (shareState != value)
|
||||||
|
{
|
||||||
|
shareState = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Theme Theme
|
public Theme Theme
|
||||||
@@ -60,6 +78,23 @@ public class Layout(string name) : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aspect ratio (Width / Height) of the screen this layout was designed for.
|
||||||
|
/// Used to match layouts to screens and to make layouts portable across screens.
|
||||||
|
/// </summary>
|
||||||
|
public double ScreenAspectRatio
|
||||||
|
{
|
||||||
|
get => screenAspectRatio;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (screenAspectRatio != value)
|
||||||
|
{
|
||||||
|
screenAspectRatio = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdatePlugins()
|
public void UpdatePlugins()
|
||||||
{
|
{
|
||||||
plugins = plugins.ToDictionary();
|
plugins = plugins.ToDictionary();
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects a local layout or theme to the mod.io mod it was published as or downloaded from.
|
||||||
|
/// </summary>
|
||||||
|
public class ModIoLink
|
||||||
|
{
|
||||||
|
public uint ModId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The modfile (version) the local copy is based on.
|
||||||
|
/// </summary>
|
||||||
|
public uint ModfileId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hash of the shareable content at the last publish or download, used to detect local changes.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublishedHash { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the logged in mod.io user submitted the mod and is therefore allowed to upload new versions.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsOwner { get; set; }
|
||||||
|
|
||||||
|
public Uri? ProfileUri { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ModIoShareState
|
||||||
|
{
|
||||||
|
NotShared,
|
||||||
|
UpToDate,
|
||||||
|
Modified,
|
||||||
|
NotOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local item (layout or theme) that can be shared on mod.io.
|
||||||
|
/// </summary>
|
||||||
|
public interface IModIoShareable
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
ModIoLink? ModIo { get; set; }
|
||||||
|
|
||||||
|
ModIoShareState ShareState { get; set; }
|
||||||
|
}
|
||||||
@@ -19,20 +19,24 @@ public class PluginSettings : INotifyPropertyChanged
|
|||||||
private List<SettingElement> settings = [];
|
private List<SettingElement> settings = [];
|
||||||
private Dictionary<string, JsonElement> state = [];
|
private Dictionary<string, JsonElement> state = [];
|
||||||
private bool enabled = false;
|
private bool enabled = false;
|
||||||
private Point position = new Point(100, 100);
|
private Point position = new Point(0.05, 0.05);
|
||||||
private Point size = new Point(300, 300);
|
private Point size = new Point(0.3, 0.3);
|
||||||
|
|
||||||
// Only for internal use to show the name of the plugin in the main window
|
// Only for internal use to show the name of the plugin in the main window
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public PluginMetadata Metadata { get; set; } = new();
|
public PluginMetadata Metadata { get; set; } = new();
|
||||||
|
|
||||||
|
// The layout this plugin belongs to, used to resolve the fallback theme
|
||||||
|
[JsonIgnore]
|
||||||
|
public Layout? Owner { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Theme Theme
|
public Theme Theme
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
DesktopMagicSettings settings = MainWindowDataContext.GetSettings();
|
DesktopMagicSettings settings = MainWindowDataContext.GetSettings();
|
||||||
return settings.Themes.FirstOrDefault(t => t.Name == currentThemeName) ?? settings.CurrentLayout.Theme;
|
return settings.Themes.FirstOrDefault(t => t.Name == currentThemeName) ?? Owner?.Theme ?? settings.CurrentLayout.Theme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +97,9 @@ public class PluginSettings : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Position of the plugin window as percentages (0..1) of the owning screen's bounds.
|
||||||
|
/// </summary>
|
||||||
public Point Position
|
public Point Position
|
||||||
{
|
{
|
||||||
get => position;
|
get => position;
|
||||||
@@ -106,6 +113,9 @@ public class PluginSettings : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Size of the plugin window as percentages (0..1) of the owning screen's bounds.
|
||||||
|
/// </summary>
|
||||||
public Point Size
|
public Point Size
|
||||||
{
|
{
|
||||||
get => size;
|
get => size;
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
<Company>Stone_Red</Company>
|
<Company>Stone_Red</Company>
|
||||||
<Product>Stone_Red</Product>
|
<Product>Stone_Red</Product>
|
||||||
<Version>1.0.0.0</Version>
|
<Version>1.1.0.0</Version>
|
||||||
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
|
<RepositoryUrl>https://github.com/Stone-Red-Code/DesktopMagic</RepositoryUrl>
|
||||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||||
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<PackageProjectUrl></PackageProjectUrl>
|
<PackageProjectUrl></PackageProjectUrl>
|
||||||
<FileVersion></FileVersion>
|
<FileVersion></FileVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SkiaSharp" Version="4.148.0" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides data for keyboard events.
|
||||||
|
/// </summary>
|
||||||
|
public class KeyEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The key that was pressed or released.
|
||||||
|
/// </summary>
|
||||||
|
public Keys Key { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the Alt modifier was pressed.
|
||||||
|
/// </summary>
|
||||||
|
public bool Alt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the Control modifier was pressed.
|
||||||
|
/// </summary>
|
||||||
|
public bool Control { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the Shift modifier was pressed.
|
||||||
|
/// </summary>
|
||||||
|
public bool Shift { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the Windows logo key was pressed.
|
||||||
|
/// </summary>
|
||||||
|
public bool Windows { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="KeyEventArgs"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public KeyEventArgs(Keys key, bool alt, bool control, bool shift, bool windows)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Alt = alt;
|
||||||
|
Control = control;
|
||||||
|
Shift = shift;
|
||||||
|
Windows = windows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
namespace DesktopMagic.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines keyboard keys available for plugin input handling.
|
||||||
|
/// Values match <c>System.Windows.Input.Key</c> for direct casting.
|
||||||
|
/// </summary>
|
||||||
|
public enum Keys
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Cancel = 1,
|
||||||
|
Back = 2,
|
||||||
|
Tab = 3,
|
||||||
|
LineFeed = 4,
|
||||||
|
Clear = 5,
|
||||||
|
Return = 6,
|
||||||
|
Enter = 6,
|
||||||
|
ShiftKey = 16,
|
||||||
|
ControlKey = 17,
|
||||||
|
AltKey = 18,
|
||||||
|
Pause = 19,
|
||||||
|
CapsLock = 20,
|
||||||
|
Escape = 27,
|
||||||
|
Space = 32,
|
||||||
|
PageUp = 33,
|
||||||
|
PageDown = 34,
|
||||||
|
End = 35,
|
||||||
|
Home = 36,
|
||||||
|
Left = 37,
|
||||||
|
Up = 38,
|
||||||
|
Right = 39,
|
||||||
|
Down = 40,
|
||||||
|
Select = 41,
|
||||||
|
Print = 42,
|
||||||
|
Execute = 43,
|
||||||
|
PrintScreen = 44,
|
||||||
|
Insert = 45,
|
||||||
|
Delete = 46,
|
||||||
|
Help = 47,
|
||||||
|
D0 = 48,
|
||||||
|
D1 = 49,
|
||||||
|
D2 = 50,
|
||||||
|
D3 = 51,
|
||||||
|
D4 = 52,
|
||||||
|
D5 = 53,
|
||||||
|
D6 = 54,
|
||||||
|
D7 = 55,
|
||||||
|
D8 = 56,
|
||||||
|
D9 = 57,
|
||||||
|
A = 65,
|
||||||
|
B = 66,
|
||||||
|
C = 67,
|
||||||
|
D = 68,
|
||||||
|
E = 69,
|
||||||
|
F = 70,
|
||||||
|
G = 71,
|
||||||
|
H = 72,
|
||||||
|
I = 73,
|
||||||
|
J = 74,
|
||||||
|
K = 75,
|
||||||
|
L = 76,
|
||||||
|
M = 77,
|
||||||
|
N = 78,
|
||||||
|
O = 79,
|
||||||
|
P = 80,
|
||||||
|
Q = 81,
|
||||||
|
R = 82,
|
||||||
|
S = 83,
|
||||||
|
T = 84,
|
||||||
|
U = 85,
|
||||||
|
V = 86,
|
||||||
|
W = 87,
|
||||||
|
X = 88,
|
||||||
|
Y = 89,
|
||||||
|
Z = 90,
|
||||||
|
LWin = 91,
|
||||||
|
RWin = 92,
|
||||||
|
Apps = 93,
|
||||||
|
Sleep = 95,
|
||||||
|
NumPad0 = 96,
|
||||||
|
NumPad1 = 97,
|
||||||
|
NumPad2 = 98,
|
||||||
|
NumPad3 = 99,
|
||||||
|
NumPad4 = 100,
|
||||||
|
NumPad5 = 101,
|
||||||
|
NumPad6 = 102,
|
||||||
|
NumPad7 = 103,
|
||||||
|
NumPad8 = 104,
|
||||||
|
NumPad9 = 105,
|
||||||
|
Multiply = 106,
|
||||||
|
Add = 107,
|
||||||
|
Separator = 108,
|
||||||
|
Subtract = 109,
|
||||||
|
Decimal = 110,
|
||||||
|
Divide = 111,
|
||||||
|
F1 = 112,
|
||||||
|
F2 = 113,
|
||||||
|
F3 = 114,
|
||||||
|
F4 = 115,
|
||||||
|
F5 = 116,
|
||||||
|
F6 = 117,
|
||||||
|
F7 = 118,
|
||||||
|
F8 = 119,
|
||||||
|
F9 = 120,
|
||||||
|
F10 = 121,
|
||||||
|
F11 = 122,
|
||||||
|
F12 = 123,
|
||||||
|
F13 = 124,
|
||||||
|
F14 = 125,
|
||||||
|
F15 = 126,
|
||||||
|
F16 = 127,
|
||||||
|
F17 = 128,
|
||||||
|
F18 = 129,
|
||||||
|
F19 = 130,
|
||||||
|
F20 = 131,
|
||||||
|
F21 = 132,
|
||||||
|
F22 = 133,
|
||||||
|
F23 = 134,
|
||||||
|
F24 = 135,
|
||||||
|
NumLock = 144,
|
||||||
|
Scroll = 145,
|
||||||
|
LeftShift = 160,
|
||||||
|
RightShift = 161,
|
||||||
|
LeftCtrl = 162,
|
||||||
|
RightCtrl = 163,
|
||||||
|
LeftAlt = 164,
|
||||||
|
RightAlt = 165,
|
||||||
|
BrowserBack = 166,
|
||||||
|
BrowserForward = 167,
|
||||||
|
BrowserRefresh = 168,
|
||||||
|
BrowserStop = 169,
|
||||||
|
BrowserSearch = 170,
|
||||||
|
BrowserFavorites = 171,
|
||||||
|
BrowserHome = 172,
|
||||||
|
VolumeMute = 173,
|
||||||
|
VolumeDown = 174,
|
||||||
|
VolumeUp = 175,
|
||||||
|
MediaNextTrack = 176,
|
||||||
|
MediaPreviousTrack = 177,
|
||||||
|
MediaStop = 178,
|
||||||
|
MediaPlayPause = 179,
|
||||||
|
LaunchMail = 180,
|
||||||
|
SelectMedia = 181,
|
||||||
|
LaunchApplication1 = 182,
|
||||||
|
LaunchApplication2 = 183,
|
||||||
|
OemSemicolon = 186,
|
||||||
|
OemPlus = 187,
|
||||||
|
OemComma = 188,
|
||||||
|
OemMinus = 189,
|
||||||
|
OemPeriod = 190,
|
||||||
|
OemQuestion = 191,
|
||||||
|
OemTilde = 192,
|
||||||
|
OemOpenBrackets = 219,
|
||||||
|
OemPipe = 220,
|
||||||
|
OemCloseBrackets = 221,
|
||||||
|
OemQuotes = 222,
|
||||||
|
Oem8 = 223,
|
||||||
|
OemBackslash = 226,
|
||||||
|
}
|
||||||
@@ -99,6 +99,22 @@ public abstract class Plugin
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when a key is pressed while the plugin window has keyboard focus.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The key event data.</param>
|
||||||
|
public virtual void OnKeyDown(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when a key is released while the plugin window has keyboard focus.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The key event data.</param>
|
||||||
|
public virtual void OnKeyUp(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Occurs when the application's theme has changed.
|
/// Occurs when the application's theme has changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -46,4 +46,14 @@ public class Button : Setting
|
|||||||
{
|
{
|
||||||
OnClick?.Invoke();
|
OnClick?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal override string GetJsonValue()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override void SetJsonValue(string value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace DesktopMagic.Api.Settings;
|
namespace DesktopMagic.Api.Settings;
|
||||||
|
|
||||||
@@ -64,12 +65,17 @@ public class IntegerUpDown : Setting
|
|||||||
|
|
||||||
internal override string GetJsonValue()
|
internal override string GetJsonValue()
|
||||||
{
|
{
|
||||||
return Value.ToString();
|
return Value.ToString(CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal override void SetJsonValue(string value)
|
internal override void SetJsonValue(string value)
|
||||||
{
|
{
|
||||||
_ = int.TryParse(value, out int result);
|
// Values saved by older versions use the current culture (e.g. a culture specific minus sign), so fall back to it.
|
||||||
|
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result))
|
||||||
|
{
|
||||||
|
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.CurrentCulture, out result);
|
||||||
|
}
|
||||||
|
|
||||||
Value = result;
|
Value = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,4 +38,14 @@ public class Label : Setting
|
|||||||
Value = value;
|
Value = value;
|
||||||
Bold = bold;
|
Bold = bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal override string GetJsonValue()
|
||||||
|
{
|
||||||
|
return Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal override void SetJsonValue(string value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace DesktopMagic.Api.Settings;
|
namespace DesktopMagic.Api.Settings;
|
||||||
|
|
||||||
@@ -65,12 +66,18 @@ public sealed class Slider : Setting
|
|||||||
|
|
||||||
internal override string GetJsonValue()
|
internal override string GetJsonValue()
|
||||||
{
|
{
|
||||||
return Value.ToString();
|
return Value.ToString(CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal override void SetJsonValue(string value)
|
internal override void SetJsonValue(string value)
|
||||||
{
|
{
|
||||||
_ = double.TryParse(value, out double result);
|
// Values saved by older versions use the current culture (e.g. "30,5"), so fall back to it.
|
||||||
|
// Thousands separators are not allowed, otherwise "30,5" would be read as 305 by the invariant culture.
|
||||||
|
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double result))
|
||||||
|
{
|
||||||
|
_ = double.TryParse(value, NumberStyles.Float, CultureInfo.CurrentCulture, out result);
|
||||||
|
}
|
||||||
|
|
||||||
Value = result;
|
Value = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infrastructure base class of <see cref="SkiaAsyncPlugin"/>. Derive from <see cref="SkiaAsyncPlugin"/> instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This class only exists to seal the <see cref="Bitmap"/> based <see cref="AsyncPlugin.MainAsync"/> method,
|
||||||
|
/// so that <see cref="SkiaAsyncPlugin"/> can declare its own <see cref="SkiaAsyncPlugin.MainAsync"/> method returning an <see cref="SKImage"/>.</remarks>
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public abstract class SkiaAsyncPluginBase : AsyncPlugin
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This method should not be called directly! Override and use <see cref="SkiaAsyncPlugin.MainAsync"/> for asynchronous Skia plugin rendering.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This method only exists to fulfill base class requirements.</remarks>
|
||||||
|
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</param>
|
||||||
|
/// <returns>A task that always resolves to <see langword="null"/>.</returns>
|
||||||
|
public sealed override Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<Bitmap?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides an abstract base class for creating asynchronous plugins that render with SkiaSharp and can be integrated into the application,
|
||||||
|
/// supporting periodic updates, rendering, and user interaction.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Derive from this class to implement custom plugin functionality. The class defines lifecycle methods
|
||||||
|
/// such as <see cref="AsyncPlugin.StartAsync"/>, <see cref="AsyncPlugin.StopAsync"/>, and <see cref="MainAsync"/> for activation, deactivation, and periodic
|
||||||
|
/// execution. It also provides event handlers for mouse and theme interactions, as well as access to application data
|
||||||
|
/// and rendering configuration. Implementations should override relevant methods to respond to user input, update
|
||||||
|
/// intervals, and configuration changes as needed. The image returned by <see cref="MainAsync"/> is scaled to fit the
|
||||||
|
/// plugin window while preserving its aspect ratio.</remarks>
|
||||||
|
public abstract class SkiaAsyncPlugin : SkiaAsyncPluginBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when the <see cref="Plugin.UpdateInterval"/> elapses.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</param>
|
||||||
|
/// <returns>The rendered image, or <see langword="null"/> to keep the previous frame.</returns>
|
||||||
|
public new abstract Task<SKImage?> MainAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace DesktopMagic.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infrastructure base class of <see cref="SkiaPlugin"/>. Derive from <see cref="SkiaPlugin"/> instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This class only exists to seal the <see cref="Bitmap"/> based <see cref="Plugin.Main"/> method,
|
||||||
|
/// so that <see cref="SkiaPlugin"/> can declare its own <see cref="SkiaPlugin.Main"/> method returning an <see cref="SKImage"/>.</remarks>
|
||||||
|
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||||
|
public abstract class SkiaPluginBase : Plugin
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// This method should not be called directly! Override and use <see cref="SkiaPlugin.Main"/> for Skia plugin rendering.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This method only exists to fulfill base class requirements.</remarks>
|
||||||
|
/// <returns>Always <see langword="null"/>.</returns>
|
||||||
|
public sealed override Bitmap? Main()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides an abstract base class for creating plugins that render with SkiaSharp and can be integrated into the application,
|
||||||
|
/// supporting periodic updates, rendering, and user interaction.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Derive from this class to implement custom plugin functionality. The class defines lifecycle methods
|
||||||
|
/// such as <see cref="Plugin.Start"/>, <see cref="Plugin.Stop"/>, and <see cref="Main"/> for activation, deactivation, and periodic
|
||||||
|
/// execution. It also provides event handlers for mouse and theme interactions, as well as access to application data
|
||||||
|
/// and rendering configuration. Implementations should override relevant methods to respond to user input, update
|
||||||
|
/// intervals, and configuration changes as needed. The image returned by <see cref="Main"/> is scaled to fit the
|
||||||
|
/// plugin window while preserving its aspect ratio.</remarks>
|
||||||
|
public abstract class SkiaPlugin : SkiaPluginBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when the <see cref="Plugin.UpdateInterval"/> elapses.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The rendered image, or <see langword="null"/> to keep the previous frame.</returns>
|
||||||
|
public new abstract SKImage? Main();
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="StoneRed.DesktopMagic"
|
Name="StoneRed.DesktopMagic"
|
||||||
Publisher="CN=6D1FD759-1951-428A-9B03-964461C7DBF5"
|
Publisher="CN=6D1FD759-1951-428A-9B03-964461C7DBF5"
|
||||||
Version="1.3.3.0" />
|
Version="1.4.0.0" />
|
||||||
|
|
||||||
<Properties>
|
<Properties>
|
||||||
<DisplayName>Desktop Magic</DisplayName>
|
<DisplayName>Desktop Magic</DisplayName>
|
||||||
|
|||||||
Reference in New Issue
Block a user