40 Commits
Author SHA1 Message Date
Stone_Red 4ba39c67ce Improve screen selector and make screen IDs stable 2026-08-15 16:02:26 +02:00
Stone_Red 507a0e9458 Code cleanup 2026-08-07 23:34:56 +02:00
Stone_Red af2666f9a6 Fix plugin settings sync when multiple monitors use same layout 2026-08-03 00:56:04 +02:00
Stone_Red c48eb532f2 Fix memory leak caused by plugins not properly unloading 2026-08-02 12:47:30 +02:00
Stone_Red a6696a87d8 Add per-screen layouts with DPI-aware widget positioning and an empty layout option 2026-08-02 01:01:59 +02:00
Stone_Red df650afd00 Only show main window on first run, regardless of if plugins are enabled 2026-07-02 15:48:15 +02:00
Stone_Red 393ccb0ba6 Improve DLL dependency loading and fix plugin type check when loading plugin 2026-07-02 15:09:26 +02:00
Stone_Red bd29bcdb37 Add SkiaPlugin support and benchmark plugins for SkiaSharp and GDI+ 2026-07-01 14:35:47 +02:00
Stone_Red 2e219e66c9 Add keyboard input support for plugins with key event handling 2026-06-30 20:03:30 +02:00
Stone_Red f4ac2c65f3 Update version and installer 2026-06-27 20:00:14 +02:00
Stone_Red 5fcbe2af79 Add plugins search box to main page 2026-06-27 19:52:02 +02:00
Stone_Red 33962cd68b Move SetIsLocked call from WindowPos to PluginWindow initialization 2026-06-27 19:17:49 +02:00
Stone_Red c6b973b111 Rename local variables for consistency and clarity 2026-06-27 19:11:44 +02:00
Stone_Red d43f1ed949 Add color conversion methods with defaults and refactor ColorPicker parsing for web plugins 2026-06-27 19:08:36 +02:00
Stone_Red 4999ae6e88 Add hot reload support for web plugins with file watcher and debounce 2026-06-27 19:07:53 +02:00
Stone_Red 6061efba02 Add web plugin creation flow and settings bridge to WebPluginWindow 2026-06-27 15:53:27 +02:00
Stone_Red 8e333df71b Add window layer setting with Always on Bottom and Always on Top options 2026-06-27 14:32:56 +02:00
Stone_Red 2c82c9373f Update version in manifest and update installer 2026-04-21 16:43:46 +02:00
Stone_Red 51b4f15cf1 Set TextWrapping to NoWrap and enforce MaxWidth of 200 on controls 2026-04-21 16:41:33 +02:00
Stone_Red 2916f0983a Add meeting countdown and agenda plugins 2026-04-21 16:07:47 +02:00
Stone_Red 3683e75a17 Add update dialog showing latest release info after app update 2026-04-21 01:49:47 +02:00
Stone_Red 3d98c8b196 Set MainWindow.Topmost based on editMode in SetEditMode method 2026-04-21 01:26:30 +02:00
Stone_Red 8c97f6b56a Update version and installer 2026-02-07 00:09:04 +01:00
Stone_Red 01a1d109ff Add weekend color override settings to DatePlugin 2026-02-07 00:00:44 +01:00
Stone_Red d2f123e0f5 Make sure underscore is properly escaped 2026-02-06 23:45:34 +01:00
Stone_Red f217574022 Add FPS limit setting and throttle Main() calls in MusicVisualizerPlugin 2026-01-23 21:53:54 +01:00
Stone_Red 1e9c74af52 Optimize bitmap rendering in PluginWindow 2026-01-23 21:13:40 +01:00
Stone_Red 2853d49dd9 Add rotation setting to plugins 2026-01-23 20:35:15 +01:00
Stone_Red 4133f61a5c Add customizable date format options 2026-01-23 00:33:46 +01:00
Stone_Red e3056a398a Update default and post-load info messages for GIF plugin 2026-01-22 17:54:23 +01:00
Stone_Red cd0bcad181 Add submenu options for bug reporting and feature requests via GitHub or email 2026-01-22 17:53:20 +01:00
Stone_Red fec26069be Make sure date plugin updates when settings change 2026-01-22 15:29:35 +01:00
Stone_Red f84dd20eb9 Update NuGet packages 2026-01-22 15:29:35 +01:00
Stone_Red 9581cc185c Replace all Buttons with icons with ui:Button 2026-01-22 15:29:35 +01:00
Stone_Red a438ab5475 Add Documentation navigation item with click handler linking to GitHub 2026-01-22 15:29:35 +01:00
Stone_Red 1b496db094 Replace TextBox with FileSelector for gif-path plugin setting 2026-01-22 15:29:35 +01:00
Stone_Red 3dce9ab59a Add CreatePluginDialog for plugin creation and update search boxes with icons 2026-01-22 15:29:35 +01:00
Stone_Red a85e57ad58 Fix titles of dialogs 2026-01-22 15:29:34 +01:00
Stone_Red d319b32263 Update third party licenses in README.md 2026-01-22 00:09:26 +01:00
Stone_Red ca83e5585f Replace old screenshots with new ones
Updated preview images in README with new screenshots.
2026-01-22 00:08:01 +01:00
65 changed files with 4511 additions and 372 deletions
+3 -3
View File
@@ -23,12 +23,12 @@
## Preview ## Preview
![Screenshot 2023-12-31 164947](https://github.com/Stone-Red-Code/DesktopMagic/assets/56473591/191ac76f-264d-4e9f-8979-7a13b2a1cd12) ![Screenshot 2026-01-22 000348](https://github.com/user-attachments/assets/4617330d-4c4b-4509-80d7-9f6db413e84f)
![Screenshot 2023-12-31 164853](https://github.com/Stone-Red-Code/DesktopMagic/assets/56473591/22de55d2-dde3-43a5-b77b-c773a7697e8c) ![Screenshot 2026-01-22 000434](https://github.com/user-attachments/assets/aebeca87-b305-40a4-a942-d78fce0e9e97)
## Third party licenses ## Third party licenses
- [BusyIndicator](https://github.com/moh3ngolshani/BusyIndicator) - [MIT](https://github.com/Moh3nGolshani/BusyIndicator/blob/master/LICENSE) - [BusyIndicator](https://github.com/moh3ngolshani/BusyIndicator) - [MIT](https://github.com/Moh3nGolshani/BusyIndicator/blob/master/LICENSE)
- [Material Design In XAML Toolkit](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit) - [MIT](https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/blob/master/LICENSE) - [WPF UI](https://github.com/lepoco/wpfui) - [MIT](https://github.com/lepoco/wpfui/blob/main/LICENSE.md)
- [Modio.NET](https://github.com/nickelc/modio.net) - [Apache-2.0](https://github.com/nickelc/modio.net/blob/master/LICENSE-APACHE), [MIT](https://github.com/nickelc/modio.net/blob/master/LICENSE-MIT) - [Modio.NET](https://github.com/nickelc/modio.net) - [Apache-2.0](https://github.com/nickelc/modio.net/blob/master/LICENSE-APACHE), [MIT](https://github.com/nickelc/modio.net/blob/master/LICENSE-MIT)
- [NAudio](https://github.com/naudio/NAudio) - [MIT](https://github.com/naudio/NAudio/blob/master/license.txt) - [NAudio](https://github.com/naudio/NAudio) - [MIT](https://github.com/naudio/NAudio/blob/master/license.txt)
Binary file not shown.
+4 -1
View File
@@ -1,5 +1,6 @@
<Application x:Class="DesktopMagic.App" <Application x:Class="DesktopMagic.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:md="http://schemas.lepo.co/wpfui/2022/xaml/markdown"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:helpers="clr-namespace:DesktopMagic.Helpers" xmlns:helpers="clr-namespace:DesktopMagic.Helpers"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
@@ -10,6 +11,8 @@
<ResourceDictionary Source="Resources\Strings\StringResources.en.xaml" /> <ResourceDictionary Source="Resources\Strings\StringResources.en.xaml" />
<ui:ThemesDictionary Theme="Light" /> <ui:ThemesDictionary Theme="Light" />
<ui:ControlsDictionary /> <ui:ControlsDictionary />
<md:ThemesDictionary Theme="Light" />
<md:ControlsDictionary />
<ResourceDictionary Source="pack://application:,,,/BusyIndicator;component/Theme/Default.xaml" /> <ResourceDictionary Source="pack://application:,,,/BusyIndicator;component/Theme/Default.xaml" />
<ResourceDictionary Source="Resources\Images\ImageResources.xaml" /> <ResourceDictionary Source="Resources\Images\ImageResources.xaml" />
<ResourceDictionary Source="Resources\Styles\ToggleSwitchContentLeftStyle.xaml" /> <ResourceDictionary Source="Resources\Styles\ToggleSwitchContentLeftStyle.xaml" />
@@ -0,0 +1,279 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using DesktopMagic.Helpers;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Text;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopMagic.BuiltInPlugins;
internal class AgendaPlugin : AsyncPlugin
{
[Setting("agenda-calendar-url", "iCal URL")]
private readonly TextBox calendarUrl = new TextBox(string.Empty);
[Setting("agenda-refresh-button", "Refresh Calendar")]
private readonly Button refreshButton = new Button("Refresh");
[Setting("agenda-refresh-interval", "Refresh Every (minutes)")]
private readonly IntegerUpDown refreshMinutes = new IntegerUpDown(1, 120, 10);
[Setting("agenda-lookahead-hours", "Look Ahead (hours)")]
private readonly IntegerUpDown lookAheadHours = new IntegerUpDown(1, 336, 24);
[Setting("agenda-max-events", "Maximum Events")]
private readonly IntegerUpDown maxEvents = new IntegerUpDown(1, 15, 5);
[Setting("agenda-include-all-day", "Include All-Day Events")]
private readonly CheckBox includeAllDayEvents = new CheckBox(true);
[Setting("agenda-show-date", "Show Date")]
private readonly CheckBox showDate = new CheckBox(true);
public override int UpdateInterval { get; set; } = 30000;
private List<CalendarEventEntry> upcomingEvents = [];
private DateTime nextRefreshTime = DateTime.MinValue;
private bool forceRefresh = true;
private bool themeChanged = true;
private string? errorText;
public override Task StartAsync(CancellationToken cancellationToken)
{
refreshButton.OnClick += () =>
{
forceRefresh = true;
Application.UpdateWindow();
};
calendarUrl.OnValueChanged += MarkForRefresh;
refreshMinutes.OnValueChanged += MarkForRefresh;
lookAheadHours.OnValueChanged += MarkForRefresh;
maxEvents.OnValueChanged += MarkForRefresh;
includeAllDayEvents.OnValueChanged += MarkForRefresh;
showDate.OnValueChanged += Application.UpdateWindow;
return Task.CompletedTask;
}
public override async Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
{
if (themeChanged || forceRefresh || DateTime.Now >= nextRefreshTime)
{
await RefreshEventsAsync(cancellationToken);
themeChanged = false;
}
return RenderAgendaBitmap();
}
public override void OnThemeChanged()
{
themeChanged = true;
}
public override void OnSettingsChanged()
{
Application.UpdateWindow();
}
private void MarkForRefresh()
{
forceRefresh = true;
Application.UpdateWindow();
}
private async Task RefreshEventsAsync(CancellationToken cancellationToken)
{
forceRefresh = false;
nextRefreshTime = DateTime.Now.AddMinutes(Math.Max(1, refreshMinutes.Value));
if (string.IsNullOrWhiteSpace(calendarUrl.Value))
{
upcomingEvents = [];
errorText = "Set iCal URL in settings";
return;
}
try
{
DateTime now = DateTime.Now;
DateTime windowEnd = now.AddHours(Math.Max(1, lookAheadHours.Value));
upcomingEvents = await IcsCalendarHelper.GetUpcomingEventsAsync(
calendarUrl.Value.Trim(),
now,
windowEnd,
maxEvents.Value,
includeAllDayEvents.Value,
cancellationToken);
errorText = null;
}
catch (HttpRequestException ex)
{
upcomingEvents = [];
errorText = $"Calendar unavailable: {ex.Message}";
}
catch (TaskCanceledException ex)
{
upcomingEvents = [];
errorText = $"Calendar unavailable: {ex.Message}";
}
catch (InvalidOperationException ex)
{
upcomingEvents = [];
errorText = $"Calendar unavailable: {ex.Message}";
}
}
private Bitmap RenderAgendaBitmap()
{
List<(string Day, string Date, string Time, string Title, bool IsMessage)> rows = [];
if (!string.IsNullOrWhiteSpace(errorText))
{
rows.Add((string.Empty, string.Empty, string.Empty, errorText, true));
}
else if (upcomingEvents.Count == 0)
{
rows.Add((string.Empty, string.Empty, string.Empty, "No events in selected window", true));
}
else
{
foreach (CalendarEventEntry eventEntry in upcomingEvents)
{
string dayPart = showDate.Value ? eventEntry.StartLocal.ToString("ddd") : string.Empty;
string datePart = showDate.Value ? eventEntry.StartLocal.ToString("dd MMM yyyy") : string.Empty;
string timePart = eventEntry.IsAllDay ? "All day" : eventEntry.StartLocal.ToShortTimeString();
rows.Add((dayPart, datePart, timePart, eventEntry.Title, false));
}
}
using Font font = new Font(Application.Theme.Font, 70);
using Bitmap measureBitmap = new Bitmap(1, 1);
using Graphics measureGraphics = Graphics.FromImage(measureBitmap);
measureGraphics.TextRenderingHint = TextRenderingHint.AntiAlias;
bool hasDayColumn = rows.Any(row => !row.IsMessage && !string.IsNullOrWhiteSpace(row.Day));
bool hasDateColumn = rows.Any(row => !row.IsMessage && !string.IsNullOrWhiteSpace(row.Date));
bool hasTimeColumn = rows.Any(row => !row.IsMessage && !string.IsNullOrWhiteSpace(row.Time));
float maxDayWidth = 0;
float maxDateWidth = 0;
float maxTimeWidth = 0;
float maxTitleWidth = 0;
float lineHeight = 0;
foreach ((string day, string date, string time, string title, bool isMessage) in rows)
{
if (isMessage)
{
SizeF messageSize = measureGraphics.MeasureString(title, font);
maxTitleWidth = Math.Max(maxTitleWidth, messageSize.Width);
lineHeight = Math.Max(lineHeight, messageSize.Height);
continue;
}
if (hasDayColumn)
{
SizeF daySize = measureGraphics.MeasureString(day, font);
maxDayWidth = Math.Max(maxDayWidth, daySize.Width);
lineHeight = Math.Max(lineHeight, daySize.Height);
}
if (hasDateColumn)
{
SizeF dateSize = measureGraphics.MeasureString(date, font);
maxDateWidth = Math.Max(maxDateWidth, dateSize.Width);
lineHeight = Math.Max(lineHeight, dateSize.Height);
}
if (hasTimeColumn)
{
SizeF timeSize = measureGraphics.MeasureString(time, font);
maxTimeWidth = Math.Max(maxTimeWidth, timeSize.Width);
lineHeight = Math.Max(lineHeight, timeSize.Height);
}
SizeF titleSize = measureGraphics.MeasureString(title, font);
maxTitleWidth = Math.Max(maxTitleWidth, titleSize.Width);
lineHeight = Math.Max(lineHeight, titleSize.Height);
}
float columnSpacing = measureGraphics.MeasureString(" ", font).Width;
float prefixWidth = 0;
bool hasAnyPrefix = false;
if (hasDayColumn)
{
prefixWidth += maxDayWidth;
hasAnyPrefix = true;
}
if (hasDateColumn)
{
prefixWidth += (hasAnyPrefix ? columnSpacing : 0) + maxDateWidth;
hasAnyPrefix = true;
}
if (hasTimeColumn)
{
prefixWidth += (hasAnyPrefix ? columnSpacing : 0) + maxTimeWidth;
hasAnyPrefix = true;
}
float totalWidth = (hasAnyPrefix ? prefixWidth + columnSpacing : 0) + maxTitleWidth;
int width = Math.Max(1, (int)Math.Ceiling(totalWidth));
int height = Math.Max(1, (int)Math.Ceiling(lineHeight * rows.Count));
Bitmap bitmap = new Bitmap(width, height);
bitmap.SetResolution(100, 100);
using Graphics graphics = Graphics.FromImage(bitmap);
using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor);
graphics.TextRenderingHint = TextRenderingHint.AntiAlias;
graphics.Clear(Color.Transparent);
float dayColumnX = 0;
float dateColumnX = hasDayColumn ? dayColumnX + maxDayWidth + columnSpacing : dayColumnX;
float timeColumnX = hasDateColumn ? dateColumnX + maxDateWidth + columnSpacing : dateColumnX;
float titleColumnX = hasAnyPrefix ? timeColumnX + (hasTimeColumn ? maxTimeWidth : 0) + columnSpacing : 0;
for (int index = 0; index < rows.Count; index++)
{
(string day, string date, string time, string title, bool isMessage) = rows[index];
float y = index * lineHeight;
if (isMessage)
{
graphics.DrawString(title, font, brush, 0, y);
continue;
}
if (hasDayColumn && !string.IsNullOrWhiteSpace(day))
{
graphics.DrawString(day, font, brush, dayColumnX, y);
}
if (hasDateColumn && !string.IsNullOrWhiteSpace(date))
{
graphics.DrawString(date, font, brush, dateColumnX, y);
}
if (hasTimeColumn && !string.IsNullOrWhiteSpace(time))
{
graphics.DrawString(time, font, brush, timeColumnX, y);
}
graphics.DrawString(title, font, brush, titleColumnX, y);
}
return bitmap;
}
}
@@ -0,0 +1,128 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace DesktopMagic.BuiltInPlugins;
internal class BenchmarkPlugin : Plugin
{
private const int BitmapWidth = 400;
private const int BitmapHeight = 300;
private readonly Random rng = new(42);
private readonly object rngLock = new();
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 => 16;
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;
int w = BitmapWidth;
int h = BitmapHeight;
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;
}
}
+115 -5
View File
@@ -4,13 +4,34 @@ using DesktopMagic.Api.Settings;
using System; using System;
using System.Drawing; using System.Drawing;
using System.Drawing.Text; using System.Drawing.Text;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace DesktopMagic.BuiltInPlugins; namespace DesktopMagic.BuiltInPlugins;
internal class DatePlugin : Plugin internal partial class DatePlugin : Plugin
{ {
[Setting("short-date", "Short date")] [Setting("week-day-style", "Week Day Style")]
private readonly CheckBox shortDateCheckBox = new CheckBox(true); public ComboBox weekDayStyleComboBox = new ComboBox([.. Enum.GetValues<WeekdayStyle>().Select(e => e.ToString())]);
[Setting("day-style", "Day Style")]
public ComboBox dayStyleComboBox = new ComboBox([.. Enum.GetValues<DayStyle>().Select(e => e.ToString())]);
[Setting("month-style", "Month Style")]
public ComboBox monthStyleComboBox = new ComboBox([.. Enum.GetValues<MonthStyle>().Select(e => e.ToString())]);
[Setting("year-style", "Year Style")]
public ComboBox yearStyleComboBox = new ComboBox([.. Enum.GetValues<YearStyle>().Select(e => e.ToString())]);
[Setting("use-date-separators", "Use Date Separators")]
public CheckBox useDateSeparators = new CheckBox(true);
[Setting("use-color-override-for-weekend", "Use Color Override For Weekend")]
public CheckBox useColorOverrideForWeekend = new CheckBox(false);
[Setting("weekend-color-override", "Weekend Color Override")]
public ColorPicker weekendColorOverride = new ColorPicker(Color.Red);
private DateTime oldDateTime = DateTime.MinValue; private DateTime oldDateTime = DateTime.MinValue;
private bool themeChanged = false; private bool themeChanged = false;
@@ -27,7 +48,68 @@ internal class DatePlugin : Plugin
oldDateTime = DateTime.Now; oldDateTime = DateTime.Now;
themeChanged = false; themeChanged = false;
string date = shortDateCheckBox.Value ? DateTime.Now.ToShortDateString() : DateTime.Now.ToLongDateString(); CultureInfo culture = CultureInfo.CurrentCulture;
DateTime dateTime = DateTime.Now;
DateTimeFormatInfo dtf = culture.DateTimeFormat;
string pattern = dtf.LongDatePattern;
WeekdayStyle weekdayStyle = Enum.Parse<WeekdayStyle>(weekDayStyleComboBox.Value);
DayStyle dayStyle = Enum.Parse<DayStyle>(dayStyleComboBox.Value);
MonthStyle monthStyle = Enum.Parse<MonthStyle>(monthStyleComboBox.Value);
YearStyle yearStyle = Enum.Parse<YearStyle>(yearStyleComboBox.Value);
// Weekday: ddd, dddd
pattern = WeekdayRegex().Replace(pattern, weekdayStyle switch
{
WeekdayStyle.Long => "dddd",
WeekdayStyle.Short => "ddd",
_ => ""
});
// Day: d, dd
pattern = DayRegex().Replace(pattern, dayStyle switch
{
DayStyle.Numeric => "dd",
_ => ""
});
// Month: M, MM, MMM, MMMM
pattern = MonthRegex().Replace(pattern, monthStyle switch
{
MonthStyle.Long => "MMMM",
MonthStyle.Short => "MMM",
MonthStyle.Numeric => "MM",
_ => ""
});
// Year: yy, yyyy
pattern = YearRegex().Replace(pattern, yearStyle switch
{
YearStyle.FourDigit => "yyyy",
YearStyle.TwoDigit => "yy",
_ => ""
});
// Clean up leftover punctuation/spacing
pattern = CleanupRegex().Replace(pattern, " ").Trim();
if (string.IsNullOrWhiteSpace(pattern))
{
pattern = dtf.LongDatePattern;
}
string date = dateTime.ToString(pattern, culture);
if (useDateSeparators.Value)
{
date = DateSeparatorsRegex().Replace(date, dtf.DateSeparator);
}
Color color = useColorOverrideForWeekend.Value && (dateTime.DayOfWeek == DayOfWeek.Saturday || dateTime.DayOfWeek == DayOfWeek.Sunday)
? weekendColorOverride.Value
: Application.Theme.PrimaryColor;
using Font font = new Font(Application.Theme.Font, 200); using Font font = new Font(Application.Theme.Font, 200);
@@ -42,7 +124,7 @@ internal class DatePlugin : Plugin
bmp.SetResolution(100, 100); bmp.SetResolution(100, 100);
using Graphics gr = Graphics.FromImage(bmp); using Graphics gr = Graphics.FromImage(bmp);
using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor); using SolidBrush brush = new SolidBrush(color);
gr.TextRenderingHint = TextRenderingHint.AntiAlias; gr.TextRenderingHint = TextRenderingHint.AntiAlias;
gr.DrawString(date, font, brush, 0, 0); gr.DrawString(date, font, brush, 0, 0);
@@ -54,4 +136,32 @@ internal class DatePlugin : Plugin
{ {
themeChanged = true; themeChanged = true;
} }
public override void OnSettingsChanged()
{
themeChanged = true;
}
[GeneratedRegex(@"d{3,4}")]
private static partial Regex WeekdayRegex();
[GeneratedRegex(@"\b(?<!d)d{1,2}\b")]
private static partial Regex DayRegex();
[GeneratedRegex(@"M{1,4}")]
private static partial Regex MonthRegex();
[GeneratedRegex(@"y{2,4}")]
private static partial Regex YearRegex();
[GeneratedRegex(@"[\s,./\-]+")]
private static partial Regex CleanupRegex();
[GeneratedRegex(@"(?<=\d)\s+(?=\d)")]
private static partial Regex DateSeparatorsRegex();
public enum WeekdayStyle { None, Short, Long }
public enum DayStyle { None, Numeric }
public enum MonthStyle { None, Numeric, Short, Long }
public enum YearStyle { None, TwoDigit, FourDigit }
} }
@@ -20,6 +20,11 @@ internal class MusicVisualizerPlugin : Plugin
private readonly Bitmap output = new Bitmap(880, 300); private readonly Bitmap output = new Bitmap(880, 300);
private readonly System.Diagnostics.Stopwatch frameStopwatch = System.Diagnostics.Stopwatch.StartNew();
[Setting("fps-limit", "FPS Limit")]
private readonly IntegerUpDown fpsLimit = new IntegerUpDown(1, 144, 60);
[Setting("mirror", "Mirror")] [Setting("mirror", "Mirror")]
private readonly CheckBox mirrorMode = new CheckBox(false); private readonly CheckBox mirrorMode = new CheckBox(false);
@@ -51,8 +56,16 @@ internal class MusicVisualizerPlugin : Plugin
waveIn.StartRecording(); waveIn.StartRecording();
} }
public override Bitmap Main() public override Bitmap? Main()
{ {
double msPerFrame = 1000.0 / Math.Max(1, fpsLimit.Value);
if (frameStopwatch.Elapsed.TotalMilliseconds < msPerFrame)
{
return null; // Exit early if we haven't reached the time threshold
}
frameStopwatch.Restart(); // Reset timer for the next frame
return output; return output;
} }
@@ -0,0 +1,233 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using DesktopMagic.Helpers;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Text;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopMagic.BuiltInPlugins;
internal class NextMeetingCountdownPlugin : AsyncPlugin
{
[Setting("meeting-calendar-url", "iCal URL")]
private readonly TextBox calendarUrl = new TextBox(string.Empty);
[Setting("meeting-refresh-button", "Refresh Calendar")]
private readonly Button refreshButton = new Button("Refresh");
[Setting("meeting-refresh-interval", "Refresh Every (minutes)")]
private readonly IntegerUpDown refreshMinutes = new IntegerUpDown(1, 120, 10);
[Setting("meeting-lookahead-hours", "Look Ahead (hours)")]
private readonly IntegerUpDown lookAheadHours = new IntegerUpDown(1, 336, 48);
[Setting("meeting-include-all-day", "Include All-Day Events")]
private readonly CheckBox includeAllDayEvents = new CheckBox(false);
[Setting("meeting-time-display", "Start Time Format")]
private readonly ComboBox startTimeDisplay = new ComboBox("None", "Date and time", "Time only");
[Setting("meeting-show-location", "Show Location")]
private readonly CheckBox showLocation = new CheckBox(false);
[Setting("meeting-center-lines", "Center Lines")]
private readonly CheckBox centerLines = new CheckBox(false);
public override int UpdateInterval { get; set; } = 1000;
private List<CalendarEventEntry> upcomingEvents = [];
private DateTime nextRefreshTime = DateTime.MinValue;
private bool forceRefresh = true;
private bool themeChanged = true;
private string? errorText;
public override Task StartAsync(CancellationToken cancellationToken)
{
refreshButton.OnClick += () =>
{
forceRefresh = true;
Application.UpdateWindow();
};
calendarUrl.OnValueChanged += MarkForRefresh;
refreshMinutes.OnValueChanged += MarkForRefresh;
lookAheadHours.OnValueChanged += MarkForRefresh;
includeAllDayEvents.OnValueChanged += MarkForRefresh;
startTimeDisplay.OnValueChanged += Application.UpdateWindow;
showLocation.OnValueChanged += Application.UpdateWindow;
centerLines.OnValueChanged += Application.UpdateWindow;
return Task.CompletedTask;
}
public override async Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
{
if (themeChanged || forceRefresh || DateTime.Now >= nextRefreshTime)
{
await RefreshEventsAsync(cancellationToken);
themeChanged = false;
}
return RenderCountdownBitmap();
}
public override void OnThemeChanged()
{
themeChanged = true;
}
public override void OnSettingsChanged()
{
Application.UpdateWindow();
}
private void MarkForRefresh()
{
forceRefresh = true;
Application.UpdateWindow();
}
private async Task RefreshEventsAsync(CancellationToken cancellationToken)
{
forceRefresh = false;
nextRefreshTime = DateTime.Now.AddMinutes(Math.Max(1, refreshMinutes.Value));
if (string.IsNullOrWhiteSpace(calendarUrl.Value))
{
upcomingEvents = [];
errorText = "Set iCal URL in settings";
return;
}
try
{
DateTime now = DateTime.Now;
DateTime windowEnd = now.AddHours(Math.Max(1, lookAheadHours.Value));
upcomingEvents = await IcsCalendarHelper.GetUpcomingEventsAsync(
calendarUrl.Value.Trim(),
now,
windowEnd,
20,
includeAllDayEvents.Value,
cancellationToken);
errorText = null;
}
catch (HttpRequestException ex)
{
upcomingEvents = [];
errorText = $"Calendar unavailable: {ex.Message}";
}
catch (TaskCanceledException ex)
{
upcomingEvents = [];
errorText = $"Calendar unavailable: {ex.Message}";
}
catch (InvalidOperationException ex)
{
upcomingEvents = [];
errorText = $"Calendar unavailable: {ex.Message}";
}
}
private Bitmap RenderCountdownBitmap()
{
List<string> lines = [];
if (!string.IsNullOrWhiteSpace(errorText))
{
lines.Add(errorText);
}
else
{
DateTime now = DateTime.Now;
CalendarEventEntry? nextEvent = upcomingEvents.FirstOrDefault(eventEntry => eventEntry.EndLocal > now);
if (nextEvent is null)
{
lines.Add("No upcoming meetings");
}
else
{
lines.Add(nextEvent.Title);
if (startTimeDisplay.Value != "None")
{
string timeText = nextEvent.IsAllDay
? "All day"
: startTimeDisplay.Value == "Time only"
? nextEvent.StartLocal.ToString("t")
: $"{nextEvent.StartLocal:D} {nextEvent.StartLocal:t}";
lines.Add(timeText);
}
if (showLocation.Value && !string.IsNullOrWhiteSpace(nextEvent.Location))
{
lines.Add(nextEvent.Location!);
}
if (now < nextEvent.StartLocal)
{
lines.Add($"Starts in {FormatCountdown(nextEvent.StartLocal - now)}");
}
else
{
lines.Add("In progress");
}
}
}
using Font font = new Font(Application.Theme.Font, 90);
using Bitmap measureBitmap = new Bitmap(1, 1);
using Graphics measureGraphics = Graphics.FromImage(measureBitmap);
measureGraphics.TextRenderingHint = TextRenderingHint.AntiAlias;
float maxWidth = 0;
float lineHeight = 0;
foreach (string line in lines)
{
SizeF size = measureGraphics.MeasureString(line, font);
maxWidth = Math.Max(maxWidth, size.Width);
lineHeight = Math.Max(lineHeight, size.Height);
}
int width = Math.Max(1, (int)Math.Ceiling(maxWidth));
int height = Math.Max(1, (int)Math.Ceiling(lineHeight * lines.Count));
Bitmap bitmap = new Bitmap(width, height);
bitmap.SetResolution(100, 100);
using Graphics graphics = Graphics.FromImage(bitmap);
using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor);
graphics.TextRenderingHint = TextRenderingHint.AntiAlias;
graphics.Clear(Color.Transparent);
for (int index = 0; index < lines.Count; index++)
{
string line = lines[index];
float x = 0;
if (centerLines.Value)
{
SizeF lineSize = graphics.MeasureString(line, font);
x = Math.Max(0, (width - lineSize.Width) / 2f);
}
graphics.DrawString(line, font, brush, x, index * lineHeight);
}
return bitmap;
}
private static string FormatCountdown(TimeSpan timeSpan)
{
int totalHours = (int)timeSpan.TotalHours;
return $"{totalHours:00}:{timeSpan.Minutes:00}:{timeSpan.Seconds:00}";
}
}
@@ -0,0 +1,123 @@
using DesktopMagic.Api;
using DesktopMagic.Api.Settings;
using SkiaSharp;
using System;
namespace DesktopMagic.BuiltInPlugins;
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");
public override int UpdateInterval => 16;
public override void Main(SKCanvas canvas)
{
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;
int w = (int)canvas.DeviceClipBounds.Width;
int h = (int)canvas.DeviceClipBounds.Height;
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 var 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 (var 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 var textFont2 = new SKFont(SKTypeface.Default, 16);
canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont2, paint);
}
break;
}
}
using var fpsFont = new SKFont(SKTypeface.Default, 14);
using var fpsPaint = new SKPaint
{
Color = color,
IsAntialias = false
};
canvas.DrawText($"FPS: {fps:F1} | Shapes: {count} | Type: {type}", 8, 18, SKTextAlign.Left, fpsFont, fpsPaint);
}
}
@@ -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 string? pluginsSearchText;
public string Title => public string Title =>
#if DEBUG #if DEBUG
@@ -33,6 +39,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;
@@ -43,6 +86,16 @@ internal class MainWindowDataContext : INotifyPropertyChanged
} }
} }
public string? PluginsSearchText
{
get => pluginsSearchText;
set
{
pluginsSearchText = value;
OnPropertyChanged();
}
}
public bool IsAutoStartEnabled public bool IsAutoStartEnabled
{ {
get => StartupManager.IsAutoStartEnabled(); get => StartupManager.IsAutoStartEnabled();
@@ -65,8 +118,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));
}
}
@@ -9,6 +9,8 @@ using System.Runtime.CompilerServices;
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using Wpf.Ui.Controls;
namespace DesktopMagic.DataContexts; namespace DesktopMagic.DataContexts;
internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null, string? csprojPath = null) : INotifyPropertyChanged internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null, string? csprojPath = null) : INotifyPropertyChanged
@@ -38,7 +40,20 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
public ICommand Command => command; public ICommand Command => command;
public ButtonData InstallUninstallButtonData => new(mode == Mode.Install ? "Download24" : "Delete24", GetInstallUninstallButtonText(), true, Command); public ButtonData InstallUninstallButtonData
{
get
{
if (mode == Mode.Install)
{
return new(new SymbolIcon(SymbolRegular.ArrowDownload24), GetInstallUninstallButtonText(), true, Command);
}
else
{
return new(new SymbolIcon(SymbolRegular.Delete24), GetInstallUninstallButtonText(), true, Command);
}
}
}
public ButtonData OpenButtonData public ButtonData OpenButtonData
{ {
@@ -46,15 +61,15 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
{ {
if (File.Exists(csprojPath)) if (File.Exists(csprojPath))
{ {
return new("Code24", "IDE", true, new CommandHandler(OpenCsprojInIDE)); return new(new SymbolIcon(SymbolRegular.Code24), "IDE", true, new CommandHandler(OpenCsprojInIDE));
} }
else if (string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString())) else if (string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString()))
{ {
return new("Folder24", (string)App.LanguageDictionary["folder"], path is not null, new CommandHandler(() => Process.Start("explorer.exe", path!))); return new(new SymbolIcon(SymbolRegular.Folder24), (string)App.LanguageDictionary["folder"], path is not null, new CommandHandler(() => Process.Start("explorer.exe", path!)));
} }
else else
{ {
return new("Open24", "mod.io", true, new CommandHandler(OpenModIoPage)); return new(new SymbolIcon(SymbolRegular.Open24), "mod.io", true, new CommandHandler(OpenModIoPage));
} }
} }
} }
@@ -126,7 +141,7 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
} }
public record ButtonData(string Icon, string Text, bool IsEnabled, ICommand Command); public record ButtonData(SymbolIcon Icon, string Text, bool IsEnabled, ICommand Command);
public enum Mode public enum Mode
{ {
+11 -8
View File
@@ -7,14 +7,15 @@
<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.0.0</Version> <Version>1.3.3.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.0.0</AssemblyVersion> <AssemblyVersion>1.3.3.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion> <FileVersion>1.3.3.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>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<Platforms>AnyCPU;x64</Platforms> <Platforms>AnyCPU;x64</Platforms>
</PropertyGroup> </PropertyGroup>
@@ -43,16 +44,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BusyIndicators" Version="2.1.2" /> <PackageReference Include="BusyIndicators" Version="2.1.3" />
<PackageReference Include="CuteUtils" Version="1.0.0" /> <PackageReference Include="CuteUtils" Version="1.0.0" />
<PackageReference Include="Interop.IWshRuntimeLibrary" Version="1.0.1" /> <PackageReference Include="Interop.IWshRuntimeLibrary" Version="1.0.1" />
<PackageReference Include="MaterialDesignThemes" Version="5.1.0" /> <PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2592.51" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" />
<PackageReference Include="Modio" Version="1.0.0" /> <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.1.0" /> <PackageReference Include="WPF-UI" Version="4.2.0" />
<PackageReference Include="WPF-UI.Tray" Version="4.1.0" /> <PackageReference Include="WPF-UI.Markdown" Version="4.0.2" />
<PackageReference Include="WPF-UI.Tray" Version="4.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+1 -1
View File
@@ -28,7 +28,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ui:TitleBar ShowMinimize="False" ShowMaximize="False"> <ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
<ui:TitleBar.Icon> <ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" /> <ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon> </ui:TitleBar.Icon>
@@ -24,6 +24,7 @@ public partial class ColorDialog : FluentWindow
Resources.MergedDictionaries.Add(App.LanguageDictionary); Resources.MergedDictionaries.Add(App.LanguageDictionary);
label.Content = content; label.Content = content;
titleBar.Title = title;
Title = title; Title = title;
alphaSlider.Value = defaultColor.A; alphaSlider.Value = defaultColor.A;
@@ -0,0 +1,56 @@
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.CreatePluginDialog"
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:tray="http://schemas.lepo.co/wpfui/2022/xaml/tray"
xmlns:pages="clr-namespace:DesktopMagic.Pages"
xmlns:plugins="clr-namespace:DesktopMagic.Plugins"
mc:Ignorable="d"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts"
d:DataContext="{d:DesignInstance Type=dataContext:MainWindowDataContext}"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
WindowCornerPreference="Round"
WindowBackdropType="Tabbed"
Height="230"
Width="320"
MinHeight="160"
MinWidth="320"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar Title="Create New Plugin" ShowMinimize="False" ShowMaximize="False">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<StackPanel Grid.Row="1" Margin="{StaticResource DefaultMargin}" Orientation="Horizontal" HorizontalAlignment="Center">
<ui:TextBlock TextWrapping="Wrap" VerticalAlignment="Center" Foreground="{DynamicResource TextFillColorSecondaryBrush}">
Please check out the
</ui:TextBlock>
<ui:HyperlinkButton Padding="0" Content=" Plugin Creation Guide" VerticalAlignment="Center" VerticalContentAlignment="Center" NavigateUri="https://mod.io/g/desktopmagic/r/plugin-creation-guide/" />
</StackPanel>
<StackPanel Grid.Row="2" VerticalAlignment="Center" HorizontalAlignment="Stretch" Margin="15">
<Label Content="{DynamicResource enterPluginName}" HorizontalAlignment="Left" VerticalAlignment="Top" />
<ui:TextBox x:Name="textBox" HorizontalAlignment="Stretch" Margin="5" TextWrapping="Wrap" VerticalAlignment="Top" VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="5">
<Button x:Name="okButton" Content=".NET Plugin" HorizontalAlignment="Left" Margin="0 0 5 0" VerticalAlignment="Top" Width="100" Click="DotNetButton_Click" Cursor="Hand" />
<Button x:Name="webPluginButton" Content="Web Plugin" HorizontalAlignment="Left" Margin="0 0 5 0" VerticalAlignment="Top" Width="100" Click="WebPluginButton_Click" Cursor="Hand" />
<Button x:Name="cancelButton" Content="{DynamicResource cancel }" HorizontalAlignment="Left" Margin="0 0 0 0" VerticalAlignment="Top" Width="100" Click="CancelButton_Click" />
</StackPanel>
</Grid>
</ui:FluentWindow>
@@ -0,0 +1,38 @@
using System.Windows;
namespace DesktopMagic.Dialogs;
public partial class CreatePluginDialog : Wpf.Ui.Controls.FluentWindow
{
public string ResponseText
{
get => textBox.Text;
set => textBox.Text = value;
}
public bool IsWebPlugin { get; private set; }
public CreatePluginDialog()
{
InitializeComponent();
Resources.MergedDictionaries.Add(App.LanguageDictionary);
}
private void DotNetButton_Click(object sender, RoutedEventArgs e)
{
IsWebPlugin = false;
DialogResult = true;
}
private void WebPluginButton_Click(object sender, RoutedEventArgs e)
{
IsWebPlugin = true;
DialogResult = true;
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ui:TitleBar ShowMinimize="False" ShowMaximize="False"> <ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
<ui:TitleBar.Icon> <ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" /> <ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon> </ui:TitleBar.Icon>
@@ -17,6 +17,7 @@ public partial class InputDialog : Wpf.Ui.Controls.FluentWindow
Resources.MergedDictionaries.Add(App.LanguageDictionary); Resources.MergedDictionaries.Add(App.LanguageDictionary);
label.Content = content; label.Content = content;
titleBar.Title = title;
Title = title; Title = title;
} }
@@ -0,0 +1,48 @@
<ui:FluentWindow x:Class="DesktopMagic.Dialogs.ReleaseInfoDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:md="http://schemas.lepo.co/wpfui/2022/xaml/markdown"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
WindowCornerPreference="Round"
WindowBackdropType="Tabbed"
Height="700"
Width="900"
MinHeight="500"
MinWidth="700"
WindowStartupLocation="CenterOwner"
ExtendsContentIntoTitleBar="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<StackPanel Grid.Row="1" Margin="14,10,14,0">
<TextBlock x:Name="releaseNameTextBlock" FontSize="20" FontWeight="SemiBold" />
<TextBlock x:Name="publishedAtTextBlock" Foreground="{DynamicResource TextFillColorSecondaryBrush}" Margin="0,4,0,0" />
</StackPanel>
<Border Grid.Row="2" Margin="14,12,14,12" BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="8">
<md:MarkdownViewer x:Name="releaseMarkdownViewer" Margin="8" />
</Border>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="14,0,14,12">
<Button x:Name="openButton" Width="150" Margin="0,0,8,0" Click="OpenButton_Click" />
<Button x:Name="okButton" Width="100" Click="OkButton_Click" />
</StackPanel>
</Grid>
</ui:FluentWindow>
@@ -0,0 +1,40 @@
using System.Windows;
namespace DesktopMagic.Dialogs;
public partial class ReleaseInfoDialog : Wpf.Ui.Controls.FluentWindow
{
public bool OpenReleaseRequested { get; private set; }
public ReleaseInfoDialog(
string title,
string releaseName,
string publishedAt,
string markdown,
string openButtonText,
string okButtonText)
{
InitializeComponent();
Resources.MergedDictionaries.Add(App.LanguageDictionary);
titleBar.Title = title;
Title = title;
releaseNameTextBlock.Text = releaseName;
publishedAtTextBlock.Text = publishedAt;
releaseMarkdownViewer.Markdown = string.IsNullOrWhiteSpace(markdown) ? "-" : markdown;
openButton.Content = openButtonText;
okButton.Content = okButtonText;
}
private void OpenButton_Click(object sender, RoutedEventArgs e)
{
OpenReleaseRequested = true;
DialogResult = true;
}
private void OkButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}
@@ -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;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ui:TitleBar ShowMinimize="False" ShowMaximize="False"> <ui:TitleBar x:Name="titleBar" ShowMinimize="False" ShowMaximize="False">
<ui:TitleBar.Icon> <ui:TitleBar.Icon>
<ui:ImageIcon Source="{StaticResource Icon}" /> <ui:ImageIcon Source="{StaticResource Icon}" />
</ui:TitleBar.Icon> </ui:TitleBar.Icon>
@@ -29,6 +29,7 @@ public partial class ThemeDialog : Wpf.Ui.Controls.FluentWindow
backgroundColorRechtangle.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor)); backgroundColorRechtangle.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor));
label.Content = content; label.Content = content;
titleBar.Title = title;
Title = title; Title = title;
} }
@@ -89,11 +89,35 @@ internal static partial class MultiColorConverter
return System.Windows.Media.Color.FromArgb(color.A, color.R, color.G, color.B); return System.Windows.Media.Color.FromArgb(color.A, color.R, color.G, color.B);
} }
public static System.Windows.Media.Color ConvertToMediaColor(string hex, System.Windows.Media.Color defaultColor)
{
if (TryConvertToMediaColor(hex, out System.Windows.Media.Color color))
{
return color;
}
else
{
return defaultColor;
}
}
public static System.Drawing.Color ConvertToSystemColor(System.Windows.Media.Color color) public static System.Drawing.Color ConvertToSystemColor(System.Windows.Media.Color color)
{ {
return System.Drawing.Color.FromArgb(color.A, color.R, color.G, color.B); return System.Drawing.Color.FromArgb(color.A, color.R, color.G, color.B);
} }
public static System.Drawing.Color ConvertToSystemColor(string hex, System.Drawing.Color defaultColor)
{
if (TryConvertToSystemColor(hex, out System.Drawing.Color color))
{
return color;
}
else
{
return defaultColor;
}
}
[GeneratedRegex("(?:[0-9a-fA-F]{8})")] [GeneratedRegex("(?:[0-9a-fA-F]{8})")]
private static partial Regex Hex8(); private static partial Regex Hex8();
@@ -0,0 +1,51 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace DesktopMagic.Helpers;
public sealed class GitHubLatestRelease
{
[JsonPropertyName("tag_name")]
public string TagName { get; init; } = "Unknown";
[JsonPropertyName("name")]
public string Name { get; init; } = "Unknown";
[JsonPropertyName("body")]
public string Body { get; init; } = string.Empty;
[JsonPropertyName("published_at")]
public DateTimeOffset? PublishedAt { get; init; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; init; } = "https://github.com/Stone-Red-Code/DesktopMagic/releases/latest";
}
public static class GitHubReleaseService
{
private const string LatestReleaseApiUrl = "https://api.github.com/repos/Stone-Red-Code/DesktopMagic/releases/latest";
private static readonly HttpClient _httpClient = CreateReleaseHttpClient();
private static HttpClient CreateReleaseHttpClient()
{
HttpClient client = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("DesktopMagic");
return client;
}
public static async Task<GitHubLatestRelease?> GetLatestReleaseInfoAsync()
{
using HttpResponseMessage response = await _httpClient.GetAsync(LatestReleaseApiUrl);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<GitHubLatestRelease>(responseStream);
}
}
@@ -0,0 +1,89 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopMagic.Helpers;
public sealed class CalendarEventEntry
{
public string Title { get; init; } = "Untitled";
public string? Location { get; init; }
public DateTime StartLocal { get; init; }
public DateTime EndLocal { get; init; }
public bool IsAllDay { get; init; }
}
public static class IcsCalendarHelper
{
private static readonly HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(12)
};
static IcsCalendarHelper()
{
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("DesktopMagic");
httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/calendar");
}
public static async Task<List<CalendarEventEntry>> GetUpcomingEventsAsync(
string calendarUrl,
DateTime windowStartLocal,
DateTime windowEndLocal,
int maxEvents,
bool includeAllDayEvents,
CancellationToken cancellationToken)
{
using HttpResponseMessage response = await httpClient.GetAsync(calendarUrl, cancellationToken);
response.EnsureSuccessStatusCode();
string icsContent = await response.Content.ReadAsStringAsync(cancellationToken);
Calendar calendar = Calendar.Load(icsContent);
DateTime windowStartUtc = windowStartLocal.ToUniversalTime();
DateTime windowEndUtc = windowEndLocal.ToUniversalTime();
List<CalendarEventEntry> results = [];
foreach (var occurrence in calendar.GetOccurrences(windowStartUtc, windowEndUtc))
{
if (occurrence.Source is not CalendarEvent calendarEvent)
{
continue;
}
DateTime startLocal = occurrence.Period.StartTime.AsSystemLocal;
DateTime endLocal = occurrence.Period.EndTime?.AsSystemLocal ?? startLocal.AddHours(1);
bool isAllDay = !occurrence.Period.StartTime.HasTime;
if (!includeAllDayEvents && isAllDay)
{
continue;
}
if (endLocal <= windowStartLocal)
{
continue;
}
results.Add(new CalendarEventEntry
{
Title = string.IsNullOrWhiteSpace(calendarEvent.Summary) ? "Untitled" : calendarEvent.Summary,
Location = string.IsNullOrWhiteSpace(calendarEvent.Location) ? null : calendarEvent.Location,
StartLocal = startLocal,
EndLocal = endLocal,
IsAllDay = isAllDay
});
}
return results
.OrderBy(eventEntry => eventEntry.StartLocal)
.Take(Math.Max(1, maxEvents))
.ToList();
}
}
+382
View File
@@ -0,0 +1,382 @@
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>
/// Clamps an absolute WPF position (DIPs) so that the window (of the given size)
/// stays within the screen bounds. This prevents widgets from being moved to another screen.
/// </summary>
public static Point ClampToScreenBounds(Point topLeft, Size size, System.Drawing.Rectangle bounds)
{
Rect dips = GetScreenDips(bounds);
double x = topLeft.X;
double y = topLeft.Y;
if (size.Width <= dips.Width)
{
x = Math.Clamp(x, dips.Left, dips.Right - size.Width);
}
else
{
x = dips.Left;
}
if (size.Height <= dips.Height)
{
y = Math.Clamp(y, dips.Top, dips.Bottom - size.Height);
}
else
{
y = dips.Top;
}
return new Point(x, y);
}
/// <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;
}
}
@@ -100,7 +100,7 @@ internal class SettingElementGenerator(uint pluginId)
Wpf.Ui.Controls.TextBox textBox = new() Wpf.Ui.Controls.TextBox textBox = new()
{ {
Text = eTextBox.Value, Text = eTextBox.Value,
TextWrapping = TextWrapping.Wrap, TextWrapping = TextWrapping.NoWrap,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Stretch HorizontalAlignment = HorizontalAlignment.Stretch
}; };
+15
View File
@@ -76,6 +76,21 @@ namespace DesktopMagic
DependencyProperty.RegisterAttached("IsHooked", typeof(WindowLockHook), typeof(WindowPos), DependencyProperty.RegisterAttached("IsHooked", typeof(WindowLockHook), typeof(WindowPos),
new PropertyMetadata(null)); new PropertyMetadata(null));
public static void SetWindowLayer(Window window, string layer)
{
switch (layer)
{
case "Always on Bottom":
window.Topmost = false;
SendWpfWindowBack(window);
SendWpfWindowBack(window);
break;
case "Always on Top":
window.Topmost = true;
break;
}
}
private class WindowLockHook private class WindowLockHook
{ {
private readonly Window Window; private readonly Window Window;
+32 -6
View File
@@ -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>
@@ -73,15 +73,44 @@
</ui:NavigationView.MenuItems> </ui:NavigationView.MenuItems>
<ui:NavigationView.FooterMenuItems> <ui:NavigationView.FooterMenuItems>
<ui:NavigationViewItem Content="{DynamicResource requestFeature}" Click="ReportBugNavigationViewItem_Click"> <ui:NavigationViewItem Content="Documentation" Click="DocumentationNavigationViewItem_Click">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="LightbulbFilament24"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="{DynamicResource requestFeature}">
<ui:NavigationViewItem.Icon> <ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="ChatHelp24"/> <ui:SymbolIcon Symbol="ChatHelp24"/>
</ui:NavigationViewItem.Icon> </ui:NavigationViewItem.Icon>
<ui:NavigationViewItem.MenuItems>
<ui:NavigationViewItem Content="GitHub" Click="RequestFeatureNavigationViewItem_Click">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Globe24"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem> </ui:NavigationViewItem>
<ui:NavigationViewItem Content="{DynamicResource reportBug}" Click="ReportBugNavigationViewItem_Click"> <ui:NavigationViewItem Content="{DynamicResource email}" Click="RequestFeatureEmailNavigationViewItem_Click">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Mail24"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
</ui:NavigationViewItem.MenuItems>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="{DynamicResource reportBug}">
<ui:NavigationViewItem.Icon> <ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Bug24"/> <ui:SymbolIcon Symbol="Bug24"/>
</ui:NavigationViewItem.Icon> </ui:NavigationViewItem.Icon>
<ui:NavigationViewItem.MenuItems>
<ui:NavigationViewItem Content="GitHub" Click="ReportBugNavigationViewItem_Click">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Globe24"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="{DynamicResource email}" Click="ReportBugEmailNavigationViewItem_Click">
<ui:NavigationViewItem.Icon>
<ui:SymbolIcon Symbol="Mail24"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
</ui:NavigationViewItem.MenuItems>
</ui:NavigationViewItem> </ui:NavigationViewItem>
</ui:NavigationView.FooterMenuItems> </ui:NavigationView.FooterMenuItems>
</ui:NavigationView> </ui:NavigationView>
@@ -94,10 +123,7 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Grid.Column="0">
<ui:HyperlinkButton FontWeight="Bold" Content="{DynamicResource developedBy}" NavigateUri="https://me.stone-red.net"/> <ui:HyperlinkButton FontWeight="Bold" Content="{DynamicResource developedBy}" NavigateUri="https://me.stone-red.net"/>
</StackPanel>
<ui:ToggleSwitch x:Name="autoStartCheckBox" Style="{StaticResource ToggleSwitchContentLeftStyle}" Grid.Column="2" Margin="0 0 5 0" Content="{DynamicResource autoStart}" IsChecked="{Binding IsAutoStartEnabled}" VerticalAlignment="Center"/> <ui:ToggleSwitch x:Name="autoStartCheckBox" Style="{StaticResource ToggleSwitchContentLeftStyle}" Grid.Column="2" Margin="0 0 5 0" Content="{DynamicResource autoStart}" IsChecked="{Binding IsAutoStartEnabled}" VerticalAlignment="Center"/>
</Grid> </Grid>
</Border> </Border>
+141 -1
View File
@@ -1,7 +1,13 @@
using DesktopMagic.DataContexts; using DesktopMagic.DataContexts;
using DesktopMagic.Dialogs;
using DesktopMagic.Helpers;
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
using Wpf.Ui.Appearance; using Wpf.Ui.Appearance;
@@ -14,6 +20,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();
[Obsolete]
public MainWindow() public MainWindow()
{ {
SystemThemeWatcher.Watch(this); SystemThemeWatcher.Watch(this);
@@ -32,6 +39,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
@@ -40,8 +49,21 @@ public partial class MainWindow : FluentWindow
_manager.LoadPlugins(); _manager.LoadPlugins();
_manager.LoadLayout(); _manager.LoadLayout();
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();
App.Logger.LogInfo("Application loaded", source: "MainWindow"); App.Logger.LogInfo("Application loaded", source: "MainWindow");
} }
@@ -79,6 +101,8 @@ 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;
Visibility = Visibility.Collapsed; Visibility = Visibility.Collapsed;
UpdateLayout(); UpdateLayout();
_manager.CloseAllPluginWindows(); _manager.CloseAllPluginWindows();
@@ -93,6 +117,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++)
@@ -123,6 +157,18 @@ public partial class MainWindow : FluentWindow
_ = Process.Start(psi); _ = Process.Start(psi);
} }
private void ReportBugEmailNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
string uri = "mailto:[email protected]";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
};
_ = Process.Start(psi);
}
private void RequestFeatureNavigationViewItem_Click(object sender, RoutedEventArgs e) private void RequestFeatureNavigationViewItem_Click(object sender, RoutedEventArgs e)
{ {
string uri = "https://github.com/Stone-Red-Code/DesktopMagic/issues/new?template=feature_request.md"; string uri = "https://github.com/Stone-Red-Code/DesktopMagic/issues/new?template=feature_request.md";
@@ -134,6 +180,29 @@ public partial class MainWindow : FluentWindow
_ = Process.Start(psi); _ = Process.Start(psi);
} }
private void RequestFeatureEmailNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
string uri = "mailto:[email protected]";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
};
_ = Process.Start(psi);
}
private void DocumentationNavigationViewItem_Click(object sender, RoutedEventArgs e)
{
string uri = "https://github.com/Stone-Red-Code/DesktopMagic";
ProcessStartInfo psi = new()
{
UseShellExecute = true,
FileName = uri
};
_ = Process.Start(psi);
}
private void NotifyIcon_LeftClick(Wpf.Ui.Tray.Controls.NotifyIcon sender, RoutedEventArgs e) private void NotifyIcon_LeftClick(Wpf.Ui.Tray.Controls.NotifyIcon sender, RoutedEventArgs e)
{ {
RestoreWindow(); RestoreWindow();
@@ -154,4 +223,75 @@ public partial class MainWindow : FluentWindow
{ {
Quit(); Quit();
} }
private async Task ShowLatestReleaseInfoAfterUpdateAsync()
{
string currentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0";
string? lastShownVersion = _manager.Settings.ReleaseInfoLastAppVersion;
if (string.Equals(lastShownVersion, currentVersion, StringComparison.Ordinal))
{
return;
}
GitHubLatestRelease? latestRelease = await GetLatestReleaseInfoAsync();
if (latestRelease is null)
{
return;
}
string releaseName = !string.IsNullOrWhiteSpace(latestRelease.Name) ? latestRelease.Name : latestRelease.TagName;
string publishedAt = latestRelease.PublishedAt?.ToLocalTime().ToString("g") ?? (string)FindResource("unknown");
string publishedAtText = string.Format((string)FindResource("releaseInfoPublishedFormat"), publishedAt);
string releaseNotesMarkdown = string.IsNullOrWhiteSpace(latestRelease.Body) ? "-" : latestRelease.Body.Trim();
ReleaseInfoDialog releaseInfoDialog = new(
(string)FindResource("releaseInfoTitle"),
releaseName,
publishedAtText,
releaseNotesMarkdown,
(string)FindResource("openReleasePage"),
(string)FindResource("ok"))
{
Owner = this,
Topmost = true
};
_ = releaseInfoDialog.ShowDialog();
if (releaseInfoDialog.OpenReleaseRequested)
{
ProcessStartInfo processStartInfo = new()
{
UseShellExecute = true,
FileName = latestRelease.HtmlUrl
};
_ = Process.Start(processStartInfo);
}
_manager.Settings.ReleaseInfoLastAppVersion = currentVersion;
_manager.SaveSettings();
}
private static async Task<GitHubLatestRelease?> GetLatestReleaseInfoAsync()
{
try
{
return await GitHubReleaseService.GetLatestReleaseInfoAsync();
}
catch (HttpRequestException ex)
{
App.Logger.LogError($"Failed to retrieve latest release information: {ex.Message}", source: "MainWindow");
}
catch (TaskCanceledException ex)
{
App.Logger.LogWarn($"Timed out while retrieving latest release information: {ex.Message}", source: "MainWindow");
}
catch (JsonException ex)
{
App.Logger.LogError($"Failed to parse latest release information: {ex.Message}", source: "MainWindow");
}
return null;
}
} }
+247 -33
View File
@@ -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()
@@ -44,6 +48,10 @@ public sealed class Manager
{new((string)App.LanguageDictionary["date"],3) { Author = "Stone_Red" }, typeof(DatePlugin)}, {new((string)App.LanguageDictionary["date"],3) { Author = "Stone_Red" }, typeof(DatePlugin)},
{new((string)App.LanguageDictionary["cpuUsage"], 4) { Author = "Stone_Red" }, typeof(CpuMonitorPlugin)}, {new((string)App.LanguageDictionary["cpuUsage"], 4) { Author = "Stone_Red" }, typeof(CpuMonitorPlugin)},
{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["agenda"], 7) { Author = "Stone_Red" }, typeof(AgendaPlugin)},
{new((string)App.LanguageDictionary["benchmark"], 8) { Author = "Stone_Red" }, typeof(BenchmarkPlugin)},
{new((string)App.LanguageDictionary["skiaBenchmark"], 9) { Author = "Stone_Red" }, typeof(SkiaBenchmarkPlugin)},
}; };
// Window management // Window management
@@ -61,6 +69,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() }
@@ -133,20 +148,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)
{ {
@@ -172,21 +213,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()
}; };
@@ -201,10 +242,21 @@ public sealed class Manager
Action exitHandler = () => Action exitHandler = () =>
{ {
PluginWindows.Remove(window); // Close the widget on every screen using this layout
foreach (System.Windows.Forms.Screen sharedScreen in ScreenUtilities.GetAllScreens())
{
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; BlockWindowsClosing = false;
window.Close(); sharedWindow.Close();
BlockWindowsClosing = true; BlockWindowsClosing = true;
}
}
}
pluginSettings.Enabled = false; pluginSettings.Enabled = false;
}; };
@@ -222,7 +274,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)
@@ -232,10 +284,39 @@ public sealed class Manager
{ {
window.SetEditMode(editMode); window.SetEditMode(editMode);
} }
Application.Current.MainWindow.Topmost = editMode;
EditModeChanged?.Invoke(editMode); EditModeChanged?.Invoke(editMode);
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
@@ -248,7 +329,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;
} }
@@ -265,9 +348,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)
@@ -285,9 +421,60 @@ 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>
/// 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)
@@ -298,7 +485,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)
@@ -306,42 +533,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
+33 -14
View File
@@ -20,11 +20,19 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ui:Card Padding="5" Margin="0 0 0 5"> <ui:Card Padding="5" Margin="0 0 0 5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="10" />
<ColumnDefinition />
</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" />
</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 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">
@@ -62,6 +70,27 @@
<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.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="5" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ui:Button x:Name="screenSelectorButton" VerticalAlignment="Stretch" Padding="10,4" Click="ScreenSelectorButton_Click">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="screenSelectorButtonText" VerticalAlignment="Center" Text="{Binding SelectedScreen.DisplayName}" />
<ui:SymbolIcon Symbol="ChevronDown24" Margin="8,0,0,0" FontSize="12" />
</StackPanel>
</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> <Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" /> <ColumnDefinition Width="1*" />
<ColumnDefinition Width="5" /> <ColumnDefinition Width="5" />
@@ -70,19 +99,9 @@
<ColumnDefinition Width="0.5*" /> <ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions> </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="newLayoutButton" Content="{DynamicResource newLayout}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="NewLayoutButton_Click" FontWeight="Regular" />
<Button x:Name="newLayoutButton" 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" />
<StackPanel Orientation="Horizontal"> </Grid>
<ui:SymbolIcon Symbol="Add24" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource newLayout}" />
</StackPanel>
</Button>
<Button x:Name="removeLayoutButton" Grid.Column="4" HorizontalAlignment="Stretch" Click="RemoveLayoutButton_Click" FontWeight="Regular">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="Delete24" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource deleteLayout}" />
</StackPanel>
</Button>
</Grid> </Grid>
</ui:Card> </ui:Card>
</Grid> </Grid>
+111 -35
View File
@@ -4,10 +4,12 @@ using DesktopMagic.Helpers;
using DesktopMagic.Plugins; using DesktopMagic.Plugins;
using DesktopMagic.Settings; using DesktopMagic.Settings;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Data;
namespace DesktopMagic.Pages; namespace DesktopMagic.Pages;
@@ -18,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()
{ {
@@ -43,6 +44,8 @@ public partial class MainPage : Page
{ {
// Initialize edit checkbox state // Initialize edit checkbox state
editCheckBox.IsChecked = _manager.IsEditMode; editCheckBox.IsChecked = _manager.IsEditMode;
_dataContext.RefreshScreens();
} }
private void MainPage_Unloaded(object sender, RoutedEventArgs e) private void MainPage_Unloaded(object sender, RoutedEventArgs e)
@@ -56,8 +59,8 @@ public partial class MainPage : Page
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>
{ {
// Refresh the UI if needed
_dataContext.Settings = _manager.Settings; _dataContext.Settings = _manager.Settings;
ApplyPluginsFilter();
}); });
} }
@@ -74,6 +77,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)
@@ -83,7 +96,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(() =>
{ {
@@ -107,36 +120,82 @@ public partial class MainPage : Page
} }
} }
#region Plugin Search
private void AllPluginsSearchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
ApplyPluginsFilter();
}
private void ApplyPluginsFilter()
{
string? searchText = _dataContext.PluginsSearchText;
System.ComponentModel.ICollectionView view = CollectionViewSource.GetDefaultView(pluginsItemsControl.ItemsSource);
if (string.IsNullOrWhiteSpace(searchText))
{
view.Filter = null;
}
else
{
view.Filter = (item) =>
{
if (item is KeyValuePair<uint, PluginSettings> kvp)
{
return kvp.Value.Metadata.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase);
}
return false;
};
}
}
#endregion
#region Layout Management #region Layout Management
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) }
/// <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;
} }
string? layoutName = _dataContext.SelectedLayoutName;
if (layoutName is null)
{
return;
} }
try Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == layoutName);
if (layout is null)
{ {
_isLoadingLayout = true; return;
_manager.SaveSettings();
_manager.LoadLayout(false);
} }
finally
if (_manager.GetLayoutForScreen(screen) == layout)
{ {
_isLoadingLayout = false; 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)
@@ -160,18 +219,11 @@ public partial class MainPage : Page
return; return;
} }
try
{
_isLoadingLayout = true;
_manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim())); _manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim()));
_manager.Settings.CurrentLayoutName = inputDialog.ResponseText.Trim();
_manager.SaveSettings(); _manager.SaveSettings();
_manager.LoadLayout(false);
} // Select the new layout so the user can apply it to the current screen
finally _dataContext.SelectedLayoutName = inputDialog.ResponseText.Trim();
{
_isLoadingLayout = false;
}
} }
} }
@@ -203,17 +255,40 @@ 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); }
if (layout.Name == Manager.EmptyLayoutName)
{
Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox
{
Title = App.AppName,
Content = (string)FindResource("cannotDeleteEmptyLayout"),
CloseButtonText = "Ok"
};
_ = await cannotDeleteMessageBox.ShowDialogAsync();
return;
}
_ = _manager.Settings.Layouts.Remove(layout);
// Remove any screen bindings pointing to the deleted layout
List<string> boundScreens = _manager.Settings.ScreenLayouts
.Where(kvp => kvp.Value == layout.Name)
.Select(kvp => kvp.Key)
.ToList();
foreach (string screenId in boundScreens)
{
_ = _manager.Settings.ScreenLayouts.Remove(screenId);
}
_manager.SaveSettings(); _manager.SaveSettings();
_manager.LoadLayout(false); _manager.LoadLayout();
} _dataContext.RefreshSelection();
finally
{
_isLoadingLayout = false;
}
} }
#endregion #endregion
@@ -274,6 +349,7 @@ public partial class MainPage : Page
if (control is not null) if (control is not null)
{ {
control.MinWidth = 200; control.MinWidth = 200;
control.MaxWidth = 200;
card.Content = control; card.Content = control;
} }
} }
+6 -19
View File
@@ -15,7 +15,7 @@
<RowDefinition Height="auto" /> <RowDefinition Height="auto" />
</Grid.RowDefinitions> </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>
@@ -25,12 +25,9 @@
</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}">
<Button x:Name="editThemeButton" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}"> <ui:Button x:Name="editThemeButton" Content="{DynamicResource editTheme}" Icon="{ui:SymbolIcon Color24}" HorizontalAlignment="Stretch" Click="EditThemeButton_Click" FontWeight="Regular" Tag="{Binding}">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="Color24" Margin="0 0 10 0" /> </ui:Button>
<TextBlock Text="{DynamicResource editTheme}" />
</StackPanel>
</Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
@@ -47,18 +44,8 @@
<ColumnDefinition Width="0.5*" /> <ColumnDefinition Width="0.5*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button x:Name="newThemeButton" 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"/>
<StackPanel Orientation="Horizontal"> <ui:Button x:Name="deleteThemeButton" Content="{DynamicResource deleteTheme}" Icon="{ui:SymbolIcon Delete24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular"/>
<ui:SymbolIcon Symbol="Add24" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource newTheme}" />
</StackPanel>
</Button>
<Button x:Name="deleteThemeButton" Grid.Column="4" HorizontalAlignment="Stretch" Click="DeleteThemeButton_Click" FontWeight="Regular">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="Delete24" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource deleteTheme}" />
</StackPanel>
</Button>
</Grid> </Grid>
</ui:Card> </ui:Card>
</Grid> </Grid>
+1 -1
View File
@@ -69,7 +69,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();
} }
} }
@@ -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,8 @@ public interface IPluginWindow
void Hide(); void Hide();
void Close(); void Close();
void ApplySettingValue(string id, string value);
void ApplyButtonClick(string id);
event System.ComponentModel.CancelEventHandler Closing; event System.ComponentModel.CancelEventHandler Closing;
} }
+2 -12
View File
@@ -40,18 +40,8 @@
<ColumnDefinition /> <ColumnDefinition />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Button Grid.Column="0" Command="{Binding InstallUninstallButtonData.Command}" IsEnabled="{Binding InstallUninstallButtonData.IsEnabled}" VerticalAlignment="Bottom" HorizontalAlignment="Stretch"> <ui:Button Content="{Binding InstallUninstallButtonData.Text}" Icon="{Binding InstallUninstallButtonData.Icon}" Grid.Column="0" Command="{Binding InstallUninstallButtonData.Command}" IsEnabled="{Binding InstallUninstallButtonData.IsEnabled}" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"/>
<StackPanel Orientation="Horizontal"> <ui:Button Content="{Binding OpenButtonData.Text}" Icon="{Binding OpenButtonData.Icon}" Grid.Column="1" Command="{Binding OpenButtonData.Command}" IsEnabled="{Binding OpenButtonData.IsEnabled}" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Margin="5 0 5 0"/>
<ui:SymbolIcon Symbol="Delete24" Margin="0 0 10 0" />
<TextBlock Text="{Binding InstallUninstallButtonData.Text}" />
</StackPanel>
</Button>
<Button Grid.Column="1" Command="{Binding OpenButtonData.Command}" IsEnabled="{Binding OpenButtonData.IsEnabled}" VerticalAlignment="Bottom" HorizontalAlignment="Stretch" Margin="5 0 5 0">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="{Binding OpenButtonData.Icon}" Margin="0 0 10 0" />
<TextBlock Text="{Binding OpenButtonData.Text}" />
</StackPanel>
</Button>
</Grid> </Grid>
<StackPanel Grid.Row="2" Grid.ColumnSpan="2" Margin="5"> <StackPanel Grid.Row="2" Grid.ColumnSpan="2" Margin="5">
+7 -22
View File
@@ -14,7 +14,7 @@
ScrollViewer.CanContentScroll="False" ScrollViewer.CanContentScroll="False"
Title="{DynamicResource pluginManager}"> Title="{DynamicResource pluginManager}">
<busyIndicator:BusyMask x:Name="BusyIndicator" IsBusy="{Binding IsLoading}" IndicatorType="Cogs" BusyContent="Please wait..." BusyContentMargin="0,20,0,0" IsBusyAtStartup="False" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}"> <busyIndicator:BusyMask x:Name="BusyIndicator" IsBusy="{Binding IsLoading}" IndicatorType="ThreeDots" BusyContent="Please wait..." BusyContentMargin="0,20,0,0" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Background="{DynamicResource ControlStrokeColorDefaultBrush}">
<Grid Margin="{StaticResource DefaultMargin}"> <Grid Margin="{StaticResource DefaultMargin}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="auto" /> <RowDefinition Height="auto" />
@@ -36,12 +36,12 @@
<ColumnDefinition /> <ColumnDefinition />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<ui:TextBox Grid.Column="0" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchAllPlugins}" /> <ui:TextBox Grid.Column="0" Text="{Binding AllPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="AllPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchAllPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
<ui:TextBox Grid.Column="2" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" /> <ui:TextBox Grid.Column="2" Text="{Binding InstalledPluginsSearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="InstalledPluginsSearchTextBox_TextChanged" PlaceholderText="{DynamicResource searchInstalledPlugins}" Icon="{ui:SymbolIcon Search24}" IconPlacement="Right" />
</Grid> </Grid>
</ui:Card> </ui:Card>
<busyIndicator:BusyMask Grid.Row="2" IsBusy="{Binding IsSearching}" IndicatorType="Ring" BusyContent="Searching..." BusyContentMargin="0,50,0,0" IsBusyAtStartup="False" 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 AllPlugins}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -106,24 +106,9 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Image Source="{Binding ModIoIcon}" Cursor="Hand" Height="30" MouseUp="Image_MouseUp" HorizontalAlignment="Left" /> <Image Source="{Binding ModIoIcon}" Cursor="Hand" Height="30" MouseUp="Image_MouseUp" HorizontalAlignment="Left" />
<Button Grid.Column="2" HorizontalAlignment="Stretch" Click="CreatePluginButton_Click"> <ui:Button Content="{DynamicResource createNewPlugin}" Icon="{ui:SymbolIcon Add24}" Grid.Column="2" HorizontalAlignment="Stretch" Click="CreatePluginButton_Click"/>
<StackPanel Orientation="Horizontal"> <ui:Button Content="{DynamicResource reloadPlugins}" Icon="{ui:SymbolIcon ArrowClockwise24}" Grid.Column="4" HorizontalAlignment="Stretch" Click="ReloadPluginsButton_Click"/>
<ui:SymbolIcon Symbol="Add24" Margin="0 0 10 0" /> <ui:Button Content="{Binding LoginButtonText}" Icon="{ui:SymbolIcon PersonKey20}" Grid.Column="6" HorizontalAlignment="Stretch" Click="LogInButton_Click"/>
<TextBlock Text="{DynamicResource createNewPlugin}" />
</StackPanel>
</Button>
<Button Grid.Column="4" HorizontalAlignment="Stretch" Click="ReloadPluginsButton_Click">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="ArrowClockwise24" Margin="0 0 10 0" />
<TextBlock Text="{DynamicResource reloadPlugins}" />
</StackPanel>
</Button>
<Button Grid.Column="6" HorizontalAlignment="Stretch" Click="LogInButton_Click">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon Symbol="PersonKey20" Margin="0 0 10 0" />
<TextBlock Text="{Binding LoginButtonText}" />
</StackPanel>
</Button>
</Grid> </Grid>
</ui:Card> </ui:Card>
</Grid> </Grid>
+118 -67
View File
@@ -40,13 +40,12 @@ public partial class PluginManager : Page
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;
public PluginManager() public PluginManager()
@@ -80,6 +79,70 @@ public partial class PluginManager : Page
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();
@@ -179,59 +242,6 @@ public partial class PluginManager : Page
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");
@@ -440,7 +450,7 @@ public partial class PluginManager : Page
string pluginPath = Path.Combine(pluginsPath, pluginGuid); string pluginPath = Path.Combine(pluginsPath, pluginGuid);
uint pluginId = (uint)Random.Shared.Next(1000, 9999); uint pluginId = (uint)Random.Shared.Next(1000, 9999);
InputDialog inputDialog = new((string)FindResource("enterPluginName"), "Plugin Manager") CreatePluginDialog inputDialog = new()
{ {
Owner = Window.GetWindow(this), Owner = Window.GetWindow(this),
}; };
@@ -486,6 +496,15 @@ public partial class PluginManager : Page
string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json"); string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json");
await File.WriteAllTextAsync(pluginMetadataPath, JsonSerializer.Serialize(pluginMetadata)); await File.WriteAllTextAsync(pluginMetadataPath, JsonSerializer.Serialize(pluginMetadata));
if (inputDialog.IsWebPlugin)
{
await CreateNewWebPlugin(pluginPath, pluginName);
changed = true;
await InitializePluginManager();
pluginManagerDataContext.IsLoading = false;
return;
}
App.Logger.LogInfo($"Creating .NET project at: {pluginProjectPath}", source: "PluginManager"); App.Logger.LogInfo($"Creating .NET project at: {pluginProjectPath}", source: "PluginManager");
string cmd = $"new classlib -n {pluginSafeName} -o {pluginProjectPath} -f net8.0 --target-framework-override net8.0-windows7"; string cmd = $"new classlib -n {pluginSafeName} -o {pluginProjectPath} -f net8.0 --target-framework-override net8.0-windows7";
Process process = Process.Start("dotnet", cmd); Process process = Process.Start("dotnet", cmd);
@@ -608,6 +627,49 @@ public class {pluginSafeName}Plugin : Plugin
pluginManagerDataContext.IsLoading = false; pluginManagerDataContext.IsLoading = false;
} }
private async Task CreateNewWebPlugin(string pluginPath, string pluginName)
{
App.Logger.LogInfo($"Creating web plugin at: {pluginPath}", source: "PluginManager");
string mainHtml = $@"<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<style>
body {{ margin: 0; padding: 16px; font-family: var(--font-family); }}
h1 {{ color: var(--primary-color); }}
</style>
</head>
<body>
<h1 id=""greeting"">{pluginName}</h1>
<script>
function render() {{
const s = window.desktopMagic.getSettings();
document.getElementById('greeting').innerHTML = s.message;
document.getElementById('greeting').style.color = s.color;
document.getElementById('greeting').style.fontWeight = s.bold ? 'bold' : 'normal';
}}
window.desktopMagic.onSettingChanged = (id, value) => render();
render();
</script>
</body>
</html>";
var settings = new List<Dictionary<string, object?>>
{
new() { ["id"] = "message", ["name"] = "Greeting Message", ["type"] = "textbox", ["default"] = "Hello, World!" },
new() { ["id"] = "bold", ["name"] = "Bold Text", ["type"] = "checkbox", ["default"] = true },
new() { ["id"] = "color", ["name"] = "Text Color", ["type"] = "colorpicker", ["default"] = "#FF5722" }
};
string settingsJson = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(Path.Combine(pluginPath, "settings.json"), settingsJson);
await File.WriteAllTextAsync(Path.Combine(pluginPath, "main.html"), mainHtml);
App.Logger.LogInfo($"Successfully created web plugin: {pluginName}", source: "PluginManager");
}
private async void LogInButton_Click(object sender, RoutedEventArgs e) private async void LogInButton_Click(object sender, RoutedEventArgs e)
{ {
if (pluginManagerDataContext.IsAuthenticated) if (pluginManagerDataContext.IsAuthenticated)
@@ -745,17 +807,6 @@ public class {pluginSafeName}Plugin : Plugin
} }
} }
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 string GetCsprojPath(string pluginName) private string GetCsprojPath(string pluginName)
{ {
string pluginSafeName = GetPluginSafeName(pluginName); string pluginSafeName = GetPluginSafeName(pluginName);
+2 -2
View File
@@ -16,7 +16,7 @@
Closing="Window_Closing" Closing="Window_Closing"
ContentRendered="Window_ContentRendered" ContentRendered="Window_ContentRendered"
x:Name="window"> x:Name="window">
<busyIndicator:BusyMask x:Name="busyMask" IsBusyAtStartup="True" Background="Transparent" BusyContent="" BusyContentMargin="-5" IndicatorType="Cogs"> <busyIndicator:BusyMask x:Name="busyMask" IsBusy="True" Background="Transparent" BusyContent="" BusyContentMargin="-5" IndicatorType="Cogs">
<Grid> <Grid>
<Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" /> <Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" />
@@ -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>
+480 -75
View File
@@ -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;
@@ -37,16 +42,38 @@ public partial class PluginWindow : Window, IPluginWindow
private Plugin? pluginClassInstance; private Plugin? pluginClassInstance;
private AssemblyLoadContext assemblyLoadContext; private AssemblyLoadContext assemblyLoadContext;
private readonly Rectangle screenBounds;
private readonly string screenDeviceName;
private bool isUpdatingPosition = false;
private bool _editMode = false;
private CancellationTokenSource? pluginCancellationTokenSource; private CancellationTokenSource? pluginCancellationTokenSource;
private FileSystemWatcher? pluginFileWatcher; private FileSystemWatcher? pluginFileWatcher;
private System.Timers.Timer? reloadDebounceTimer; private System.Timers.Timer? reloadDebounceTimer;
private bool isReloading = false; private bool isReloading = false;
private WriteableBitmap? writeableBitmap;
private BitmapScalingMode lastBitmapScalingMode = BitmapScalingMode.Unspecified;
// 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();
@@ -66,33 +93,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))
settings.Theme.PropertyChanged += (se, ev) =>
{ {
ThemeChanged(); UpdatePosition();
}
else if (s.PropertyName == nameof(PluginSettings.Size))
{
UpdateSize();
}
}; };
settings.PropertyChanged += settingsPropertyChangedHandler;
themePropertyChangedHandler = (_, _) => ThemeChanged();
SubscribeToTheme(settings.Theme);
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
@@ -102,11 +143,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);
@@ -115,11 +172,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;
}; };
@@ -241,13 +297,13 @@ public partial class PluginWindow : Window, IPluginWindow
assemblyLoadContext = CreateAssemblyLoadContext(); assemblyLoadContext = CreateAssemblyLoadContext();
// Show busy indicator // Show busy indicator
await Dispatcher.InvokeAsync(() => busyMask.IsBusy = true); _ = await Dispatcher.InvokeAsync(() => busyMask.IsBusy = true);
// Reload the plugin // Reload the plugin
await ExecuteSource(); await ExecuteSource();
// Hide busy indicator // Hide busy indicator
await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false); _ = await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false);
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Plugin reloaded successfully", source: "Plugin"); App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Plugin reloaded successfully", source: "Plugin");
} }
@@ -255,7 +311,7 @@ public partial class PluginWindow : Window, IPluginWindow
{ {
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to reload plugin: {ex}", source: "Plugin"); App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to reload plugin: {ex}", source: "Plugin");
await Dispatcher.InvokeAsync(async () => _ = await Dispatcher.InvokeAsync(async () =>
{ {
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
{ {
@@ -303,6 +359,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;
@@ -319,9 +377,8 @@ public partial class PluginWindow : Window, IPluginWindow
panel.Visibility = Visibility.Collapsed; panel.Visibility = Visibility.Collapsed;
imageBorder.BorderThickness = new Thickness(0); imageBorder.BorderThickness = new Thickness(0);
image.Margin = new Thickness(0); image.Margin = new Thickness(0);
WindowPos.SendWpfWindowBack(this); WindowPos.SetWindowLayer(this, pluginClassInstance?.windowLayer.Value ?? "Always on Bottom");
WindowPos.SendWpfWindowBack(this); WindowPos.SetIsLocked(window, true);
WindowPos.SetIsLocked(this, true);
tileBar.CaptionHeight = 0; tileBar.CaptionHeight = 0;
ResizeMode = ResizeMode.NoResize; ResizeMode = ResizeMode.NoResize;
} }
@@ -337,22 +394,52 @@ public partial class PluginWindow : Window, IPluginWindow
WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE); WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE);
} }
private static BitmapSource BitmapToImageSource(Bitmap bitmap) private void UpdateImageFromBitmap(Bitmap bitmap, BitmapScalingMode scalingMode)
{ {
BitmapData bitmapData = bitmap.LockBits( BitmapData bitmapData = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
new Rectangle(0, 0, bitmap.Width, bitmap.Height),
ImageLockMode.ReadOnly, bitmap.PixelFormat);
BitmapSource bitmapSource = BitmapSource.Create( try
{
Dispatcher.Invoke(() =>
{
if (lastBitmapScalingMode != scalingMode)
{
RenderOptions.SetBitmapScalingMode(image, scalingMode);
lastBitmapScalingMode = scalingMode;
}
if (writeableBitmap == null || writeableBitmap.PixelWidth != bitmapData.Width || writeableBitmap.PixelHeight != bitmapData.Height)
{
writeableBitmap = new WriteableBitmap(
bitmapData.Width, bitmapData.Height, bitmapData.Width, bitmapData.Height,
bitmap.HorizontalResolution, bitmap.VerticalResolution, bitmap.HorizontalResolution, bitmap.VerticalResolution,
PixelFormats.Bgra32, null, PixelFormats.Bgra32, null);
bitmapData.Scan0, bitmapData.Stride * bitmapData.Height, bitmapData.Stride); image.Source = writeableBitmap;
}
writeableBitmap.Lock();
try
{
unsafe
{
Buffer.MemoryCopy(
bitmapData.Scan0.ToPointer(),
writeableBitmap.BackBuffer.ToPointer(),
writeableBitmap.BackBufferStride * writeableBitmap.PixelHeight,
bitmapData.Stride * bitmapData.Height);
}
writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, bitmapData.Width, bitmapData.Height));
}
finally
{
writeableBitmap.Unlock();
}
});
}
finally
{
bitmap.UnlockBits(bitmapData); bitmap.UnlockBits(bitmapData);
}
bitmapSource.Freeze();
return bitmapSource;
} }
private void Window_ContentRendered(object? sender, EventArgs e) private void Window_ContentRendered(object? sender, EventArgs e)
@@ -449,7 +536,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)
{ {
@@ -525,15 +612,23 @@ public partial class PluginWindow : Window, IPluginWindow
{ {
SetHorizontalAlignment(); SetHorizontalAlignment();
SetVerticalAlignment(); SetVerticalAlignment();
SetWindowLayer();
SetRotation();
SetThemeOverride(); SetThemeOverride();
SetThemeOverrideItems(); SetThemeOverrideItems();
pluginClassInstance.horizontalAlignment.OnValueChanged += SetHorizontalAlignment; SubscribeToDefaultSetting(pluginClassInstance.horizontalAlignment, SetHorizontalAlignment);
pluginClassInstance.verticalAlignment.OnValueChanged += SetVerticalAlignment; SubscribeToDefaultSetting(pluginClassInstance.verticalAlignment, SetVerticalAlignment);
pluginClassInstance.themeOverride.OnValueChanged += SetThemeOverride; SubscribeToDefaultSetting(pluginClassInstance.windowLayer, SetWindowLayer);
SubscribeToDefaultSetting(pluginClassInstance.rotation, SetRotation);
SubscribeToDefaultSetting(pluginClassInstance.themeOverride, SetThemeOverride);
if (themesCollectionChangedHandler is null)
{
themesCollectionChangedHandler = (_, _) => SetThemeOverrideItems();
DesktopMagicSettings desktopMagicSettings = MainWindowDataContext.GetSettings(); DesktopMagicSettings desktopMagicSettings = MainWindowDataContext.GetSettings();
desktopMagicSettings.Themes.CollectionChanged += (s, e) => SetThemeOverrideItems(); desktopMagicSettings.Themes.CollectionChanged += themesCollectionChangedHandler;
}
}); });
void SetVerticalAlignment() void SetVerticalAlignment()
@@ -564,6 +659,23 @@ public partial class PluginWindow : Window, IPluginWindow
border.HorizontalAlignment = horizontalAlignment; border.HorizontalAlignment = horizontalAlignment;
} }
void SetWindowLayer()
{
if (_editMode)
{
Topmost = true;
}
else
{
WindowPos.SetWindowLayer(this, pluginClassInstance.windowLayer.Value);
}
}
void SetRotation()
{
image.LayoutTransform = new RotateTransform(pluginClassInstance.rotation.Value);
}
void SetThemeOverride() void SetThemeOverride()
{ {
if (pluginClassInstance.themeOverride.Value == "<None>") if (pluginClassInstance.themeOverride.Value == "<None>")
@@ -590,6 +702,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");
@@ -607,23 +763,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)
{ {
pluginClassInstance.Application.UpdateWindow(); Action clickHandler = () =>
{
if (!suppressButtonSync)
{
synchronizer?.ButtonClicked(this, elementAttribute.Id);
} }
}; };
button.OnClick += clickHandler;
subscribedButtonClicks.Add((button, clickHandler));
}
settingElements.Add(settingElement); settingElements.Add(settingElement);
break; break;
@@ -784,34 +953,39 @@ 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;
}
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,
@@ -819,19 +993,33 @@ public partial class PluginWindow : Window, IPluginWindow
_ => BitmapScalingMode.Unspecified _ => BitmapScalingMode.Unspecified
}; };
// Update Image UpdateImageFromBitmap(result, renderOptions);
BitmapSource frozenSource = BitmapToImageSource(result);
_ = Dispatcher.BeginInvoke(() =>
{
RenderOptions.SetBitmapScalingMode(image, renderOptions);
image.Source = frozenSource;
});
} }
} }
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 () =>
@@ -845,13 +1033,74 @@ public partial class PluginWindow : Window, IPluginWindow
_ = await messageBox.ShowDialogAsync(); _ = await messageBox.ShowDialogAsync();
}); });
Exit(); Exit();
}
}
private async Task RenderSkiaFrame()
{
Plugin? plugin = pluginClassInstance;
if (plugin is null)
{
return; return;
} }
if (!IsRunning) int width = 0;
int height = 0;
await Dispatcher.InvokeAsync(() =>
{ {
updateTimer!.Stop(); width = Math.Max(1, (int)ActualWidth);
height = Math.Max(1, (int)ActualHeight);
});
using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul));
surface.Canvas.Clear(SKColors.Transparent);
if (plugin is SkiaAsyncPlugin skiaAsyncPlugin)
{
CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None;
await skiaAsyncPlugin.MainAsync(surface.Canvas, token);
} }
else if (plugin is SkiaPlugin skiaPlugin)
{
skiaPlugin.Main(surface.Canvas);
}
SKPixmap? pixmap = surface.PeekPixels();
if (pixmap is null)
{
return;
}
await Dispatcher.InvokeAsync(() =>
{
if (writeableBitmap is null || writeableBitmap.PixelWidth != width || writeableBitmap.PixelHeight != height)
{
writeableBitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
image.Source = writeableBitmap;
}
writeableBitmap.Lock();
try
{
unsafe
{
Buffer.MemoryCopy(
pixmap.GetPixels().ToPointer(),
writeableBitmap.BackBuffer.ToPointer(),
writeableBitmap.BackBufferStride * height,
pixmap.RowBytes * pixmap.Height);
}
writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height));
}
finally
{
writeableBitmap.Unlock();
}
});
} }
private void Border_SizeChanged(object sender, SizeChangedEventArgs e) private void Border_SizeChanged(object sender, SizeChangedEventArgs e)
@@ -882,26 +1131,148 @@ 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;
}
System.Windows.Point topLeft = new(Left, Top);
System.Windows.Point clamped = ScreenUtilities.ClampToScreenBounds(topLeft, new System.Windows.Size(ActualWidth, ActualHeight), screenBounds);
if (clamped != topLeft)
{
isUpdatingPosition = true;
Left = clamped.X;
Top = clamped.Y;
isUpdatingPosition = false;
}
settings.Position = ScreenUtilities.PositionToPercent(new System.Windows.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 System.Windows.Point(Width, Height), screenBounds);
}
tileBar.CaptionHeight = ActualHeight - 10; tileBar.CaptionHeight = ActualHeight - 10;
} }
private void Window_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e) 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 pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight;
@@ -931,7 +1302,7 @@ 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;
@@ -942,7 +1313,7 @@ public partial class PluginWindow : Window, IPluginWindow
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;
@@ -953,5 +1324,39 @@ public partial class PluginWindow : Window, IPluginWindow
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
} }
+1 -1
View File
@@ -9,7 +9,7 @@ public class SettingElement
private string jsonValue = string.Empty; private string jsonValue = string.Empty;
[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; }
@@ -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));
}
}
});
}
}
}
@@ -18,7 +18,7 @@
ContentRendered="Window_ContentRendered" ContentRendered="Window_ContentRendered"
x:Name="window"> x:Name="window">
<busyIndicator:BusyMask x:Name="busyMask" IsBusyAtStartup="True" Background="Transparent" BusyContent="" BusyContentMargin="-5" IndicatorType="Cogs"> <busyIndicator:BusyMask x:Name="busyMask" IsBusy="True" Background="Transparent" BusyContent="" BusyContentMargin="-5" IndicatorType="Cogs">
<Grid> <Grid>
<Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" /> <Rectangle x:Name="panel" Fill="#4CBAFFEF" Stroke="White" Margin="0,0,0,0" Visibility="Collapsed" />
+579 -17
View File
@@ -1,3 +1,4 @@
using DesktopMagic.Api.Settings;
using DesktopMagic.Helpers; using DesktopMagic.Helpers;
using DesktopMagic.Plugins; using DesktopMagic.Plugins;
using DesktopMagic.Settings; using DesktopMagic.Settings;
@@ -5,7 +6,13 @@ 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.ComponentModel;
using System.IO; using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
using System.Windows.Interop; using System.Windows.Interop;
using System.Windows.Media; using System.Windows.Media;
@@ -20,12 +27,31 @@ public partial class WebPluginWindow : Window, IPluginWindow
private readonly PluginSettings settings; private readonly PluginSettings settings;
private bool isInitialized = false; private bool isInitialized = false;
private FileSystemWatcher? fileWatcher;
private System.Timers.Timer? reloadDebounceTimer;
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();
@@ -45,32 +71,67 @@ 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))
settings.Theme.PropertyChanged += (se, ev) =>
{ {
ThemeChanged(); UpdatePosition();
}
else if (s.PropertyName == nameof(PluginSettings.Size))
{
UpdateSize();
}
}; };
settings.PropertyChanged += settingsPropertyChangedHandler;
themePropertyChangedHandler = (_, _) => ThemeChanged();
SubscribeToTheme(settings.Theme);
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))
{
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()
@@ -107,6 +168,32 @@ 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;
}
}
protected override void OnSourceInitialized(EventArgs e) protected override void OnSourceInitialized(EventArgs e)
{ {
base.OnSourceInitialized(e); base.OnSourceInitialized(e);
@@ -193,6 +280,8 @@ public partial class WebPluginWindow : Window, IPluginWindow
try try
{ {
LoadWebPluginSettings();
string userDataFolder = Path.Combine(Path.GetTempPath(), "DesktopMagic", "WebView2", PluginMetadata.Id.ToString()); string userDataFolder = Path.Combine(Path.GetTempPath(), "DesktopMagic", "WebView2", PluginMetadata.Id.ToString());
CoreWebView2Environment environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder); CoreWebView2Environment environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder);
await webView.EnsureCoreWebView2Async(environment); await webView.EnsureCoreWebView2Async(environment);
@@ -202,6 +291,8 @@ public partial class WebPluginWindow : Window, IPluginWindow
webView.CoreWebView2.Settings.IsStatusBarEnabled = false; webView.CoreWebView2.Settings.IsStatusBarEnabled = false;
webView.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = true; webView.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = true;
_ = await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(GetSettingsBridgeScript());
string htmlUri = new Uri(htmlPath).AbsoluteUri; string htmlUri = new Uri(htmlPath).AbsoluteUri;
webView.Source = new Uri(htmlUri); webView.Source = new Uri(htmlUri);
@@ -224,6 +315,27 @@ public partial class WebPluginWindow : Window, IPluginWindow
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping web plugin", source: "WebPlugin"); App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping web plugin", source: "WebPlugin");
IsRunning = false; IsRunning = false;
if (fileWatcher != null)
{
fileWatcher.EnableRaisingEvents = false;
fileWatcher.Dispose();
}
reloadDebounceTimer?.Stop();
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
{ {
if (isInitialized && webView.CoreWebView2 != null) if (isInitialized && webView.CoreWebView2 != null)
@@ -237,20 +349,470 @@ 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;
}
Point topLeft = new(Left, Top);
Point clamped = ScreenUtilities.ClampToScreenBounds(topLeft, new Size(ActualWidth, ActualHeight), screenBounds);
if (clamped != topLeft)
{
isUpdatingPosition = true;
Left = clamped.X;
Top = clamped.Y;
isUpdatingPosition = false;
}
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;
} }
private bool firstLoad = true;
private void WebView_CoreWebView2InitializationCompleted(object sender, CoreWebView2InitializationCompletedEventArgs e) private void WebView_CoreWebView2InitializationCompleted(object sender, CoreWebView2InitializationCompletedEventArgs e)
{ {
webView.CoreWebView2.DOMContentLoaded += (_, _) => ThemeChanged(); webView.CoreWebView2.DOMContentLoaded += (_, _) =>
{
if (firstLoad)
{
firstLoad = false;
if (fileWatcher != null)
{
fileWatcher.EnableRaisingEvents = true;
}
}
ThemeChanged();
};
}
private void LoadWebPluginSettings()
{
string settingsPath = Path.Combine(PluginFolderPath, "settings.json");
if (!File.Exists(settingsPath))
{
return;
}
try
{
string json = File.ReadAllText(settingsPath);
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Array)
{
App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - settings.json must be a JSON array", source: "WebPlugin");
return;
}
List<SettingElement> settingElements = [];
int orderIndex = 0;
foreach (JsonElement element in root.EnumerateArray())
{
string id = element.GetProperty("id").GetString() ?? $"setting-{orderIndex}";
string name = element.TryGetProperty("name", out JsonElement nameElement) ? nameElement.GetString() ?? id : id;
string type = element.TryGetProperty("type", out JsonElement typeElement) ? typeElement.GetString() ?? "textbox" : "textbox";
Setting? setting = CreateSetting(type, element);
if (setting is null)
{
App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Unknown setting type \"{type}\" for \"{id}\"", source: "WebPlugin");
continue;
}
localSettings[id] = setting;
SettingElement settingElement = new(setting, id, name, orderIndex);
if (settings.Settings.Exists(e => e.Id == id))
{
SettingElement saved = settings.Settings.First(e => e.Id == id);
string savedValue = saved.JsonValue;
if (!string.IsNullOrEmpty(savedValue) || setting is not Label and not Button)
{
settingElement.JsonValue = savedValue;
}
}
string capturedId = id;
if (setting is Button button)
{
Action clickHandler = () =>
{
if (!suppressButtonSync)
{
synchronizer?.ButtonClicked(this, capturedId);
}
_ = webView.Dispatcher.InvokeAsync(async () =>
{
try
{
_ = await webView.ExecuteScriptAsync($"window.desktopMagic?.onClick?.({JsonSerializer.Serialize(capturedId)})");
}
catch (Exception ex)
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to notify button click for \"{capturedId}\": {ex}", source: "WebPlugin");
}
});
};
button.OnClick += clickHandler;
subscribedButtonClicks.Add((button, clickHandler));
}
Action valueChangedHandler = () =>
{
synchronizer?.SettingChanged(this, capturedId, setting.GetJsonValue());
_ = webView.Dispatcher.InvokeAsync(async () =>
{
try
{
await NotifySettingChange(capturedId, setting);
}
catch (Exception ex)
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to notify setting change for \"{capturedId}\": {ex}", source: "WebPlugin");
}
});
};
setting.OnValueChanged += valueChangedHandler;
subscribedSettings.Add((setting, valueChangedHandler));
settingElements.Add(settingElement);
orderIndex++;
}
settings.Settings = [.. settingElements.OrderBy(x => x.OrderIndex)];
}
catch (Exception ex)
{
App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to load settings from settings.json: {ex.Message}", source: "WebPlugin");
}
}
private string GetSettingsBridgeScript()
{
string settingsJson = SerializeSettingsToJson();
return $@"
(function() {{
window.desktopMagic = {{}};
window.desktopMagic._settings = {settingsJson};
window.desktopMagic.getSettings = function() {{
return JSON.parse(JSON.stringify(window.desktopMagic._settings));
}};
window.desktopMagic.getSetting = function(id) {{
return window.desktopMagic._settings ? window.desktopMagic._settings[id] : undefined;
}};
window.desktopMagic.onSettingChanged = null;
window.desktopMagic.onButtonClick = null;
window.desktopMagic.dispatchSettingChanged = function(id, value) {{
if (window.desktopMagic._settings) {{
window.desktopMagic._settings[id] = value;
}}
if (typeof window.desktopMagic.onSettingChanged === 'function') {{
window.desktopMagic.onSettingChanged(id, value);
}}
}};
window.desktopMagic.onClick = function(id) {{
if (typeof window.desktopMagic.onButtonClick === 'function') {{
window.desktopMagic.onButtonClick(id);
}}
}};
}})();
";
}
private void InitializeHotReload()
{
try
{
fileWatcher = new FileSystemWatcher(PluginFolderPath)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
EnableRaisingEvents = false
};
fileWatcher.Changed += OnPluginFileChanged;
reloadDebounceTimer = new System.Timers.Timer(500)
{
AutoReset = false
};
reloadDebounceTimer.Elapsed += async (s, e) =>
{
await ReloadWebPlugin();
};
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Hot reload enabled", source: "WebPlugin");
}
catch (Exception ex)
{
App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to initialize hot reload: {ex.Message}", source: "WebPlugin");
}
}
private void OnPluginFileChanged(object sender, FileSystemEventArgs e)
{
if (isReloading)
{
return;
}
string? fileName = e.Name;
if (string.IsNullOrEmpty(fileName))
{
return;
}
string ext = Path.GetExtension(fileName).ToLowerInvariant();
if (ext is not ".html" and not ".json" and not ".js" and not ".css")
{
return;
}
reloadDebounceTimer?.Stop();
reloadDebounceTimer?.Start();
}
private async Task ReloadWebPlugin()
{
if (isReloading || !IsRunning)
{
return;
}
isReloading = true;
try
{
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Reloading web plugin", source: "WebPlugin");
if (fileWatcher != null)
{
fileWatcher.EnableRaisingEvents = false;
}
LoadWebPluginSettings();
await Dispatcher.Invoke(async () =>
{
if (isInitialized && webView.CoreWebView2 != null)
{
_ = await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(GetSettingsBridgeScript());
busyMask.IsBusy = true;
string htmlPath = Path.Combine(PluginFolderPath, "main.html");
string htmlUri = new Uri(htmlPath).AbsoluteUri;
webView.CoreWebView2.Navigate(htmlUri);
busyMask.IsBusy = false;
}
});
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Web plugin reloaded", source: "WebPlugin");
}
catch (Exception ex)
{
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to reload web plugin: {ex}", source: "WebPlugin");
}
finally
{
isReloading = false;
if (fileWatcher != null && IsRunning)
{
fileWatcher.EnableRaisingEvents = true;
}
}
}
private async System.Threading.Tasks.Task NotifySettingChange(string id, Setting setting)
{
object? value = GetSettingValue(setting);
string serializedValue = JsonSerializer.Serialize(value);
string serializedId = JsonSerializer.Serialize(id);
_ = await webView.ExecuteScriptAsync($"window.desktopMagic?.dispatchSettingChanged?.({serializedId}, {serializedValue})");
}
private string SerializeSettingsToJson()
{
Dictionary<string, object?> dict = [];
foreach (SettingElement element in settings.Settings)
{
dict[element.Id] = GetSettingValue(element.Input!);
}
return JsonSerializer.Serialize(dict);
}
private static object? GetSettingValue(Setting setting)
{
return setting switch
{
TextBox tb => tb.Value,
CheckBox cb => cb.Value,
Slider sl => sl.Value,
IntegerUpDown iud => iud.Value,
ComboBox cb => cb.Value,
ColorPicker cp => MultiColorConverter.ConvertToHexRgba(cp.Value),
Button btn => btn.Value,
Label lbl => lbl.Value,
FileSelector fs => fs.Value,
_ => null
};
}
private static Setting? CreateSetting(string type, JsonElement element)
{
return type.ToLowerInvariant() switch
{
"textbox" => new TextBox(
element.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue.GetString() ?? "" : ""),
"checkbox" => new CheckBox(
element.TryGetProperty("default", out JsonElement defaultValue) && defaultValue.ValueKind == JsonValueKind.True),
"slider" => new Slider(
element.TryGetProperty("min", out JsonElement minValue) ? minValue.GetDouble() : 0,
element.TryGetProperty("max", out JsonElement maxValue) ? maxValue.GetDouble() : 100,
element.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue.GetDouble() : 0),
"integer" => new IntegerUpDown(
element.TryGetProperty("min", out JsonElement minValue) ? minValue.GetInt32() : 0,
element.TryGetProperty("max", out JsonElement maxValue) ? maxValue.GetInt32() : 100,
element.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue.GetInt32() : 0),
"combobox" => CreateComboBox(element),
"colorpicker" => new ColorPicker(
element.TryGetProperty("default", out JsonElement defaultValue)
? MultiColorConverter.ConvertToSystemColor(defaultValue.GetString() ?? "#FFFFFFFF", System.Drawing.Color.White)
: System.Drawing.Color.White),
"button" => new Button(
element.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue.GetString() ?? "" : ""),
"label" => new Label(
element.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue.GetString() ?? "" : "",
element.TryGetProperty("bold", out JsonElement boldValue) && boldValue.ValueKind == JsonValueKind.True),
"file" => new FileSelector(
element.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue.GetString() ?? "" : "",
element.TryGetProperty("filter", out JsonElement filterValue) ? filterValue.GetString() ?? "All Files|*.*" : "All Files|*.*",
element.TryGetProperty("title", out JsonElement titleValue) ? titleValue.GetString() ?? "Select File" : "Select File",
element.TryGetProperty("selectFolder", out JsonElement selectFolderValue) && selectFolderValue.ValueKind == JsonValueKind.True),
_ => null
};
}
private static ComboBox CreateComboBox(JsonElement element)
{
List<string> items = [];
if (element.TryGetProperty("items", out JsonElement itemsValue) && itemsValue.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in itemsValue.EnumerateArray())
{
items.Add(item.GetString() ?? "");
}
}
ComboBox comboBox = new([.. items]);
if (element.TryGetProperty("default", out JsonElement defaultValue))
{
string defaultText = defaultValue.GetString() ?? "";
if (!string.IsNullOrEmpty(defaultText) && items.Contains(defaultText))
{
comboBox.Value = defaultText;
}
}
return comboBox;
} }
} }
@@ -3,9 +3,10 @@
xmlns:col="clr-namespace:System.Collections;assembly=mscorlib" xmlns:col="clr-namespace:System.Collections;assembly=mscorlib"
xmlns:system="clr-namespace:System;assembly=mscorlib"> xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="developedBy">Entwickelt von Stone_Red</system:String> <system:String x:Key="developedBy">Entwickelt von Stone__Red</system:String>
<system:String x:Key="reportBug">Bug Melden</system:String> <system:String x:Key="reportBug">Bug Melden</system:String>
<system:String x:Key="requestFeature">Feature Anfragen</system:String> <system:String x:Key="requestFeature">Feature Anfragen</system:String>
<system:String x:Key="email">E-Mail</system:String>
<system:String x:Key="lineMode">Linie</system:String> <system:String x:Key="lineMode">Linie</system:String>
<system:String x:Key="mirrorMode">Spiegeln</system:String> <system:String x:Key="mirrorMode">Spiegeln</system:String>
<system:String x:Key="folder">Ordner</system:String> <system:String x:Key="folder">Ordner</system:String>
@@ -37,6 +38,10 @@
<system:String x:Key="cpuUsage">CPU Auslastung</system:String> <system:String x:Key="cpuUsage">CPU Auslastung</system:String>
<system:String x:Key="musicVisualizer">Musik Visualisierer</system:String> <system:String x:Key="musicVisualizer">Musik Visualisierer</system:String>
<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="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>
@@ -50,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>
@@ -59,6 +65,11 @@
<system:String x:Key="addedFormat">Erstellt: {0}</system:String> <system:String x:Key="addedFormat">Erstellt: {0}</system:String>
<system:String x:Key="updatedFormat">Aktualisiert: {0}</system:String> <system:String x:Key="updatedFormat">Aktualisiert: {0}</system:String>
<system:String x:Key="versionFormat">Version: {0}</system:String> <system:String x:Key="versionFormat">Version: {0}</system:String>
<system:String x:Key="releaseInfoTitle">DesktopMagic wurde aktualisiert!</system:String>
<system:String x:Key="releaseInfoHeaderFormat">Neueste Version: {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="openReleasePage">Release-Seite öffnen</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>
@@ -3,9 +3,10 @@
xmlns:col="clr-namespace:System.Collections;assembly=mscorlib" xmlns:col="clr-namespace:System.Collections;assembly=mscorlib"
xmlns:system="clr-namespace:System;assembly=mscorlib"> xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="developedBy">Developed by Stone_Red</system:String> <system:String x:Key="developedBy">Developed by Stone__Red</system:String>
<system:String x:Key="reportBug">Report Bug</system:String> <system:String x:Key="reportBug">Report Bug</system:String>
<system:String x:Key="requestFeature">Request Feature</system:String> <system:String x:Key="requestFeature">Request Feature</system:String>
<system:String x:Key="email">Email</system:String>
<system:String x:Key="lineMode">Line</system:String> <system:String x:Key="lineMode">Line</system:String>
<system:String x:Key="mirrorMode">Mirror</system:String> <system:String x:Key="mirrorMode">Mirror</system:String>
<system:String x:Key="folder">Folder</system:String> <system:String x:Key="folder">Folder</system:String>
@@ -37,6 +38,10 @@
<system:String x:Key="cpuUsage">CPU Usage</system:String> <system:String x:Key="cpuUsage">CPU Usage</system:String>
<system:String x:Key="musicVisualizer">Music Visualizer</system:String> <system:String x:Key="musicVisualizer">Music Visualizer</system:String>
<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="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>
@@ -51,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>
@@ -61,6 +67,11 @@
<system:String x:Key="addedFormat">Added: {0}</system:String> <system:String x:Key="addedFormat">Added: {0}</system:String>
<system:String x:Key="updatedFormat">Updated: {0}</system:String> <system:String x:Key="updatedFormat">Updated: {0}</system:String>
<system:String x:Key="versionFormat">Version: {0}</system:String> <system:String x:Key="versionFormat">Version: {0}</system:String>
<system:String x:Key="releaseInfoTitle">DesktopMagic has been updated!</system:String>
<system:String x:Key="releaseInfoHeaderFormat">Latest release: {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="openReleasePage">Open release page</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,11 +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 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));
+18
View File
@@ -16,6 +16,7 @@ public class Layout(string name) : INotifyPropertyChanged
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;
[JsonIgnore] [JsonIgnore]
public Theme Theme public Theme Theme
@@ -60,6 +61,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();
+13 -3
View File
@@ -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;
+3 -3
View File
@@ -14,10 +14,10 @@ namespace DesktopMagic.PluginTest;
public class GifPlugin : Plugin public class GifPlugin : Plugin
{ {
[Setting("gif-path", "GIF path")] [Setting("gif-path", "GIF path")]
private readonly TextBox input = new TextBox(""); private readonly FileSelector input = new FileSelector();
[Setting("info")] [Setting("info")]
private readonly Label info = new Label(""); private readonly Label info = new Label("Select a GIF file to load.");
private readonly List<Bitmap> bitmaps = []; private readonly List<Bitmap> bitmaps = [];
@@ -92,7 +92,7 @@ public class GifPlugin : Plugin
info.Value = $"Loading frame {i + 1} of {gif.GetFrameCount(FrameDimension.Time)}"; info.Value = $"Loading frame {i + 1} of {gif.GetFrameCount(FrameDimension.Time)}";
} }
info.Value = string.Empty; info.Value = $"Loaded {bitmaps.Count} frames.";
} }
else else
{ {
@@ -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>
+46
View File
@@ -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;
}
}
+160
View File
@@ -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,
}
+23 -1
View File
@@ -23,7 +23,13 @@ public abstract class Plugin
[Setting("desktopmagic-vertical-alignment", "Vertical Alignment", -998)] [Setting("desktopmagic-vertical-alignment", "Vertical Alignment", -998)]
internal ComboBox verticalAlignment = new ComboBox("Center", "Top", "Bottom"); internal ComboBox verticalAlignment = new ComboBox("Center", "Top", "Bottom");
[Setting("theme-override", "Theme Override", -997)] [Setting("desktopmagic-window-layer", "Window Layer", -997)]
internal ComboBox windowLayer = new ComboBox("Always on Bottom", "Always on Top");
[Setting("desktopmagic-rotation", "Rotation", -996)]
internal IntegerUpDown rotation = new IntegerUpDown(-360, 360, 0);
[Setting("desktopmagic-theme-override", "Theme Override", -995)]
internal ComboBox themeOverride = new ComboBox("<None>"); internal ComboBox themeOverride = new ComboBox("<None>");
private IPluginData application = null!; private IPluginData application = null!;
@@ -93,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;
}
} }
@@ -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;
}
} }
@@ -0,0 +1,31 @@
using SkiaSharp;
using System.Drawing;
using System.Threading;
using System.Threading.Tasks;
namespace DesktopMagic.Api;
/// <summary>
/// Provides an abstract base class for creating asynchronous plugins using SkiaSharp rendering.
/// Plugins override <see cref="MainAsync(SKCanvas, CancellationToken)"/> instead of <see cref="Plugin.Main()"/>.
/// All existing <see cref="Plugin"/>, <see cref="AsyncPlugin"/>, and <see cref="SkiaPlugin"/> code is unaffected.
/// </summary>
public abstract class SkiaAsyncPlugin : AsyncPlugin
{
/// <summary>
/// This method is sealed and returns null for async Skia plugins.
/// Override <see cref="MainAsync(SKCanvas, CancellationToken)"/> instead.
/// </summary>
public sealed override Task<Bitmap?> MainAsync(CancellationToken cancellationToken)
{
return Task.FromResult<Bitmap?>(null);
}
/// <summary>
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin asynchronously.
/// </summary>
/// <param name="canvas">The SkiaSharp canvas to draw on.</param>
/// <param name="cancellationToken">Token signaled when the host requests cancellation.</param>
public abstract Task MainAsync(SKCanvas canvas, CancellationToken cancellationToken);
}
+29
View File
@@ -0,0 +1,29 @@
using SkiaSharp;
using System.Drawing;
namespace DesktopMagic.Api;
/// <summary>
/// Provides an abstract base class for creating plugins using SkiaSharp rendering.
/// Plugins override <see cref="Main(SKCanvas)"/> instead of <see cref="Plugin.Main()"/>.
/// All existing <see cref="Plugin"/> and <see cref="AsyncPlugin"/> code is unaffected.
/// </summary>
public abstract class SkiaPlugin : Plugin
{
/// <summary>
/// This method is sealed and returns null for Skia plugins.
/// Override <see cref="Main(SKCanvas)"/> instead.
/// </summary>
/// <returns>Always null.</returns>
public sealed override Bitmap? Main()
{
return null;
}
/// <summary>
/// Called every <see cref="Plugin.UpdateInterval"/> milliseconds to render the plugin.
/// </summary>
/// <param name="canvas">The SkiaSharp canvas to draw on.</param>
public abstract void Main(SKCanvas canvas);
}
+1 -1
View File
@@ -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.0.0" /> Version="1.3.3.0" />
<Properties> <Properties>
<DisplayName>Desktop Magic</DisplayName> <DisplayName>Desktop Magic</DisplayName>